From ff5f94d3fb298968a6ba7acb77e376f73e833287 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 22:40:00 +0800 Subject: [PATCH 001/284] refactor(client): import conversation node types from their declaring package Adding another client-face project to the client aggregate reshuffles the typert analyzer's batches, which exposed latent provenance violations in ui-chat and ui-approval: types consumed through ui-conversation's re-export path instead of their declaring package, and one slot-contract literal whose type the analyzer could not name. Import the types from @deepseek-ai/dsh-client-ui-conversation/client directly and give the approval interaction kind a named alias so the explicit annotation survives both the analyzer and lint. --- packages/client/ui-approval/src/client/contract/slots.ts | 5 ++++- .../client/ui-chat/src/client/chat/AssistantMarkdown.tsx | 2 +- .../client/ui-chat/src/client/chat/CompactionItem.tsx | 2 +- packages/client/ui-chat/src/client/chat/ContextBody.tsx | 5 +++-- .../ui-chat/src/client/chat/ContextInjectionRow.tsx | 2 +- packages/client/ui-chat/src/client/chat/MessageItem.tsx | 2 +- .../client/ui-chat/src/client/chat/turn-assistant.ts | 2 +- packages/client/ui-chat/src/client/contract/slots.ts | 4 ++-- .../ui-chat/src/client/conversation-nodes/assistant.ts | 3 +-- .../client/conversation-nodes/chat-snapshot-builder.ts | 9 +++------ .../ui-chat/src/client/conversation-nodes/command.ts | 3 +-- .../ui-chat/src/client/conversation-nodes/compaction.ts | 3 +-- .../ui-chat/src/client/conversation-nodes/fallback.ts | 5 +++-- .../ui-chat/src/client/conversation-nodes/message.ts | 5 +++-- .../ui-chat/src/client/conversation-nodes/partial.ts | 2 +- .../ui-chat/src/client/conversation-nodes/retry.ts | 3 +-- .../client/ui-chat/src/client/conversation-nodes/tool.ts | 3 +-- .../ui-chat/src/client/conversation-nodes/turn-error.ts | 3 +-- .../src/client/conversation-nodes/turn-max-tokens.ts | 3 +-- .../client/ui-chat/src/client/details/DetailsPanel.tsx | 3 ++- .../ui-chat/src/client/details/tool-node-reader.ts | 3 ++- .../ui-chat/src/client/model/conversation-context.ts | 5 +++-- .../client/ui-chat/src/client/model/tool-call-tree.ts | 4 +--- 23 files changed, 39 insertions(+), 42 deletions(-) diff --git a/packages/client/ui-approval/src/client/contract/slots.ts b/packages/client/ui-approval/src/client/contract/slots.ts index f71a33008b..2980754542 100644 --- a/packages/client/ui-approval/src/client/contract/slots.ts +++ b/packages/client/ui-approval/src/client/contract/slots.ts @@ -65,10 +65,13 @@ export type ApprovalDecision = 'allowed-once' | 'rejected' let nextApprovalKey = 0 +/** Domain discriminator literal carried by {@link PendingApproval.kind}. */ +export type ApprovalInteractionKind = 'approval' + /** One answerable Client presentation of a pending Host waterfall. */ export class PendingApproval { /** Domain discriminator used by Session pending-interaction consumers. */ - readonly kind = 'approval' as const + readonly kind: ApprovalInteractionKind = 'approval' /** Opaque render identity and one-shot remount axis. */ readonly key: string /** Tool requesting the decision. */ diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 8838725f75..a42758519a 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -1,9 +1,9 @@ +import type { AssistantBlock } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Fragment, memo, useMemo } from 'react' import type { ReactNode } from 'react' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' -import type { AssistantBlock } from '../contract/snapshot.ts' import { markdownLabels } from '../markdown-labels.ts' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' diff --git a/packages/client/ui-chat/src/client/chat/CompactionItem.tsx b/packages/client/ui-chat/src/client/chat/CompactionItem.tsx index b6108daf1a..da1346a582 100644 --- a/packages/client/ui-chat/src/client/chat/CompactionItem.tsx +++ b/packages/client/ui-chat/src/client/chat/CompactionItem.tsx @@ -1,3 +1,4 @@ +import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-ui-conversation/client' // A compaction marker does not replace shadowed transcript rows. It is // expandable only when the current window includes its cited summary. @@ -10,7 +11,6 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { markdownLabels } from '../markdown-labels.ts' -import type { CompactionSummaryNode } from '../contract/snapshot.ts' import css from './MessageItem.module.css' interface CompactionItemProps { diff --git a/packages/client/ui-chat/src/client/chat/ContextBody.tsx b/packages/client/ui-chat/src/client/chat/ContextBody.tsx index 6501ffda8f..3278f3d876 100644 --- a/packages/client/ui-chat/src/client/chat/ContextBody.tsx +++ b/packages/client/ui-chat/src/client/chat/ContextBody.tsx @@ -7,8 +7,9 @@ import type { ReactNode } from 'react' import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import type { ContextMessageNode } from '../contract/snapshot.ts' -import type { KnownContextForm } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ContextMessageNode, KnownContextForm, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import css from './ContextBody.module.css' /** Model-facing text stays bounded at the disclosure, not at the producer. */ diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx index 8be42c0f42..0930a25d56 100644 --- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx @@ -1,7 +1,7 @@ +import type { ContextMessageNode } from '@deepseek-ai/dsh-client-ui-conversation/client' import { useState } from 'react' import type { ChatViewSlotProps } from '../contract/slots.ts' import { DisclosureRow, IconBrowseOutline16, ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ContextMessageNode } from '../contract/snapshot.ts' import { contextBody } from './ContextBody.tsx' import css from './ContextInjectionRow.module.css' diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index c7368dc24d..fd0681bcbe 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,8 +1,8 @@ +import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '@deepseek-ai/dsh-client-ui-conversation/client' import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import { JsonBlock, MessageText, ReferenceIcon, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' -import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' diff --git a/packages/client/ui-chat/src/client/chat/turn-assistant.ts b/packages/client/ui-chat/src/client/chat/turn-assistant.ts index 90e075d79a..36c2ad2ec5 100644 --- a/packages/client/ui-chat/src/client/chat/turn-assistant.ts +++ b/packages/client/ui-chat/src/client/chat/turn-assistant.ts @@ -1,4 +1,4 @@ -import type { AssistantBlock } from '../contract/snapshot.ts' +import type { AssistantBlock } from '@deepseek-ai/dsh-client-ui-conversation/client' /** * Collect visible prose from one Assistant lifecycle. diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 5009781fd1..c035bd97af 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -3,7 +3,7 @@ import type { ReactNode } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { - ConversationTurnDataMap, TurnLocation, + CommandNode, CompactionSummaryNode, ConversationTurnDataMap, ToolCallBlock, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory, @@ -14,7 +14,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget } from './store.ts' import type { ChatNode, ChatNodeKind } from './chat-nodes.ts' -import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } from './snapshot.ts' +import type { ChatSnapshot } from './snapshot.ts' /** Selector hook over the current Conversation binding's Chat target. */ export type UseChat = SnapshotSelectorHook diff --git a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index f0f2b9be88..a2b5db79e2 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts @@ -1,11 +1,10 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { AssistantChatData } from '../contract/chat-nodes.ts' -import type { AssistantBlock, AssistantMessageNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' import { emptyAssistantBlock, isTokenDelta, toAssistantBlock, toAssistantBlocks, diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index f70e657014..2b306e2d99 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -1,14 +1,11 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationLocation, ConversationTimelineSnapshot, ConversationViewBuilder, - ConversationViewDefinition, + ConversationLocation, ConversationNode, ConversationTimelineSnapshot, ConversationViewBuilder, + ConversationViewDefinition, PartialAssistant, RunningToolCall, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' -import type { - ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ConversationNode, - LegacyConversationSlice, PartialAssistant, RunningToolCall, -} from '../contract/snapshot.ts' +import type { ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, LegacyConversationSlice } from '../contract/snapshot.ts' import { sessionRecallLabels } from './event-projection.ts' const EMPTY_KEYS: readonly string[] = [] diff --git a/packages/client/ui-chat/src/client/conversation-nodes/command.ts b/packages/client/ui-chat/src/client/conversation-nodes/command.ts index b4723d78fa..e21f50c5f5 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/command.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/command.ts @@ -1,13 +1,12 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/checkpoint' import type {} from '@deepseek-ai/dsh-compaction/types' import type {} from '@deepseek-ai/dsh-commands/types' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { ManualCompactionChatData } from '../contract/chat-nodes.ts' -import type { CommandNode, CompactionSummaryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts index 5874c7bd61..9612f08741 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts @@ -1,9 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-compaction/types' -import type { CompactionSummaryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' import { compactSource, compactSummary, updateCompactionState } from './command.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts index 356b46ed62..4675515dc3 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts @@ -1,7 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' -import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationNodeDefinition, UnknownSurfaceNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { UnknownSurfaceNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 9611a36cf8..548cf0dc12 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -1,7 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' -import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { ContextMessageNode, SteeringMessageNode, UserMessageNode } from '../contract/snapshot.ts' import type { InboxState } from './inbox.ts' import { chatNode } from './common.ts' import { contextForm, contextProvenance } from './event-projection.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts index ea52a3a565..04fc29dc13 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts @@ -1,5 +1,5 @@ +import type { AssistantBlock, PartialAssistant } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { StreamChunk } from '@deepseek-ai/dsh-llm/types' -import type { AssistantBlock, PartialAssistant } from '../contract/snapshot.ts' import { emptyAssistantBlock, toAssistantBlock } from './event-projection.ts' /** diff --git a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts index 5209e1dbc2..9f4f6bf9e9 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts @@ -1,10 +1,9 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationLocation, ConversationNodeDefinition, + ConversationLocation, ConversationNodeDefinition, ModelRetryNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { RetryChatData } from '../contract/chat-nodes.ts' -import type { ModelRetryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index b1f675c4e4..c96e66c434 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -1,11 +1,10 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type {} from '@deepseek-ai/dsh-tools/types' import type { ToolChatData } from '../contract/chat-nodes.ts' -import type { RunningToolCall, ToolCallBlock, ToolResultNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index e3b41095ce..941f23ac43 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -1,8 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { TurnErrorNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' import { displayFailure } from './event-projection.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts index 31508ab77c..746d00bc95 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts @@ -1,8 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { TurnMaxTokensNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/details/DetailsPanel.tsx b/packages/client/ui-chat/src/client/details/DetailsPanel.tsx index 05c997e1fe..9a31274925 100644 --- a/packages/client/ui-chat/src/client/details/DetailsPanel.tsx +++ b/packages/client/ui-chat/src/client/details/DetailsPanel.tsx @@ -1,8 +1,9 @@ +import type { RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Fragment } from 'react' import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-store' import type { DetailsSlotProps } from '../contract/slots.ts' -import type { ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '../contract/snapshot.ts' +import type { ChatSnapshot } from '../contract/snapshot.ts' import { findToolCall } from './tool-node-reader.ts' import css from './DetailsPanel.module.css' diff --git a/packages/client/ui-chat/src/client/details/tool-node-reader.ts b/packages/client/ui-chat/src/client/details/tool-node-reader.ts index 9e2488b68f..358286368e 100644 --- a/packages/client/ui-chat/src/client/details/tool-node-reader.ts +++ b/packages/client/ui-chat/src/client/details/tool-node-reader.ts @@ -1,5 +1,6 @@ +import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatNode } from '../contract/chat-nodes.ts' -import type { ChatNodeStore, ChatSnapshot, ToolCallBlock } from '../contract/snapshot.ts' +import type { ChatNodeStore, ChatSnapshot } from '../contract/snapshot.ts' function toolNode(node: ReturnType): ChatNode<'tool-call'> | undefined { return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined diff --git a/packages/client/ui-chat/src/client/model/conversation-context.ts b/packages/client/ui-chat/src/client/model/conversation-context.ts index 7f8116ffcc..442c455435 100644 --- a/packages/client/ui-chat/src/client/model/conversation-context.ts +++ b/packages/client/ui-chat/src/client/model/conversation-context.ts @@ -1,5 +1,6 @@ -import type { ConversationNode } from '../contract/snapshot.ts' -import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationNode, ConversationPromptSnapshot, +} from '@deepseek-ai/dsh-client-ui-conversation/client' /** Operation that started a new append-only model context. */ export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite' diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts index ce311f5291..130d14bd65 100644 --- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -1,8 +1,6 @@ +import type { ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-tools/types' -import type { - ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode, -} from '../contract/snapshot.ts' interface ProjectedBlock { source: ToolCallBlock From 4a793103394a334e38e81b5fbe4127bf22c43a2f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 22:40:14 +0800 Subject: [PATCH 002/284] feat(activity): optional live-output observation seam with a web viewer Add the activity capability seam: ctx.activities is a pure observation plane where long-running work (background bash/pwsh, workflow runs) opens a handle, appends transient output into a byte-offset ring buffer, and settles exactly once. Nothing model-visible changes: reads are non-consuming and lossy-by-design (absolute UTF-8 offsets survive eviction and report gaps), correlation carries {callId, jobId}, and visibility is owner-fenced like jobs. The seam is strictly optional. Producers tap it through ctx.get('activities') and contain every observation failure, so an unregistered or broken registry never affects the underlying job (proven by broken-registry tests). Ship the full path in one change: - @deepseek-ai/dsh-activity: Service Definition, pumpActivityOutput pull substrate, invariant companion; dsh-activity-local: ring-buffer provider with retention config and owner cleanup. - shell: ShellProcess.observed exposes non-consuming stdout/stderr readers; tool-bash/tool-pwsh mirror background jobs into activities. - tool-workflow: phase/log/agent lines mirrored as a workflow activity. - api-activity-controller: Typert Remote control (roster baseline+buckets) and observe (opened/output/status frames, coalesced and byte-capped) streams plus the client feed service with reference-counted observation. - ui-activity: session-header action rendering live output through TerminalBlock, which now shows output while a command is still running. Docs: activity subsystem page, architecture row, regenerated catalogs, and the Agent Note (2026-08-24-activity-observation-seam), all bilingual. A keyless web e2e replays a real run_in_background stream to ARIA goldens. --- ...-08-24-activity-observation-seam.i18n.yaml | 6 + .../2026-08-24-activity-observation-seam.md | 51 ++ ...2026-08-24-activity-observation-seam.zh.md | 51 ++ apps/cli/composition.md | 3 + apps/web/tests/live-activity-stream.e2e.ts | 134 ++++++ .../live-activity-stream/settled.expected.md | 8 + .../streaming.expected.md | 8 + apps/web/tsconfig.json | 1 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 1 + docs/architecture.zh.md | 1 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 17 +- docs/capability-seams.zh.md | 17 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 46 +- docs/config-catalog.zh.md | 44 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 4 +- docs/event-producer-consumer.zh.md | 4 +- docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 1 + docs/subsystems/README.zh.md | 1 + docs/subsystems/activity.i18n.yaml | 6 + docs/subsystems/activity.md | 141 ++++++ docs/subsystems/activity.zh.md | 141 ++++++ docs/subsystems/shell.i18n.yaml | 4 +- docs/subsystems/shell.md | 7 + docs/subsystems/shell.zh.md | 7 + knip.json | 5 + packages/activity/README.i18n.yaml | 6 + packages/activity/README.md | 14 + packages/activity/README.zh.md | 14 + .../activity/activity-local/README.i18n.yaml | 6 + packages/activity/activity-local/README.md | 30 ++ packages/activity/activity-local/README.zh.md | 30 ++ packages/activity/activity-local/package.json | 55 +++ packages/activity/activity-local/src/index.ts | 438 ++++++++++++++++++ .../activity/activity-local/src/invariant.ts | 33 ++ .../activity-local/tests/activity.spec.ts | 413 +++++++++++++++++ .../tests/loader-composition.spec.ts | 60 +++ .../activity/activity-local/tsconfig.json | 33 ++ packages/activity/activity/README.i18n.yaml | 6 + packages/activity/activity/README.md | 32 ++ packages/activity/activity/README.zh.md | 32 ++ packages/activity/activity/package.json | 57 +++ packages/activity/activity/src/brand.ts | 28 ++ packages/activity/activity/src/index.ts | 138 ++++++ packages/activity/activity/src/invariant.ts | 62 +++ packages/activity/activity/src/pump.ts | 95 ++++ packages/activity/activity/src/types.ts | 179 +++++++ .../activity/activity/tests/invariant.spec.ts | 82 ++++ .../activity/activity/tests/service.spec.ts | 98 ++++ packages/activity/activity/tsconfig.json | 36 ++ .../api/activity-controller/README.i18n.yaml | 6 + packages/api/activity-controller/README.md | 30 ++ packages/api/activity-controller/README.zh.md | 30 ++ packages/api/activity-controller/package.json | 103 ++++ .../activity-controller/src/client/index.ts | 86 ++++ .../activity-controller/src/client/model.ts | 211 +++++++++ .../activity-controller/src/client/service.ts | 156 +++++++ packages/api/activity-controller/src/feed.ts | 184 ++++++++ packages/api/activity-controller/src/index.ts | 104 +++++ .../api/activity-controller/src/invariant.ts | 23 + .../api/activity-controller/src/observe.ts | 178 +++++++ packages/api/activity-controller/src/types.ts | 104 +++++ .../tests/activity-controller.host.spec.ts | 306 ++++++++++++ .../tests/client-apply.client.spec.ts | 113 +++++ .../tests/client-model.client.spec.ts | 168 +++++++ .../tests/client-service.client.spec.ts | 213 +++++++++ .../tests/controller.host.spec.ts | 102 ++++ .../activity-controller/tsconfig.client.json | 46 ++ .../activity-controller/tsconfig.host.json | 26 ++ .../api/activity-controller/tsconfig.json | 7 + .../api/activity-controller/tsdown.config.ts | 7 + packages/api/remotes/package.json | 2 + packages/api/remotes/src/client/index.ts | 5 +- packages/api/remotes/src/index.ts | 1 + packages/api/remotes/tsconfig.client.json | 3 + packages/api/remotes/tsconfig.host.json | 3 + packages/bundle/base/cordis.patch.yml | 5 + packages/bundle/base/package.json | 17 +- packages/bundle/web-app/cordis.patch.yml | 9 + packages/bundle/web-app/package.json | 42 +- packages/client/ui-activity/README.i18n.yaml | 6 + packages/client/ui-activity/README.md | 21 + packages/client/ui-activity/README.zh.md | 21 + packages/client/ui-activity/package.json | 77 +++ .../src/client/ActivityListAction.module.css | 155 +++++++ .../src/client/ActivityListAction.tsx | 250 ++++++++++ .../client/ui-activity/src/client/index.ts | 48 ++ .../client/ui-activity/src/client/locales.ts | 65 +++ .../client/ui-activity/src/css-modules.d.ts | 4 + packages/client/ui-activity/src/index.ts | 9 + packages/client/ui-activity/src/invariant.ts | 27 ++ .../activity-list-action.client.spec.tsx | 244 ++++++++++ .../tests/browser-plugin.client.spec.ts | 115 +++++ packages/client/ui-activity/tsconfig.json | 22 + packages/client/ui-activity/tsdown.config.ts | 6 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/TerminalBlock.tsx | 12 +- .../tests/terminal-block.client.spec.tsx | 21 +- .../src/client/slot-catalog.ts | 1 + .../extensions/tool-cordis/src/api-catalog.ts | 156 ++++++- packages/jobs/jobs/README.i18n.yaml | 4 +- packages/jobs/jobs/README.md | 2 +- packages/jobs/jobs/README.zh.md | 2 +- packages/shell/bash-local/src/index.ts | 1 + packages/shell/pwsh-local/src/index.ts | 1 + packages/shell/shell/README.i18n.yaml | 4 +- packages/shell/shell/README.md | 1 + packages/shell/shell/README.zh.md | 1 + packages/shell/shell/src/types.ts | 23 +- packages/shell/tool-bash/README.i18n.yaml | 4 +- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-bash/package.json | 26 +- packages/shell/tool-bash/src/index.ts | 24 +- packages/shell/tool-bash/src/observe.ts | 72 +++ .../shell/tool-bash/tests/observe.spec.ts | 194 ++++++++ packages/shell/tool-bash/tsconfig.json | 3 + packages/shell/tool-pwsh/README.i18n.yaml | 4 +- packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/README.zh.md | 2 +- packages/shell/tool-pwsh/package.json | 24 +- packages/shell/tool-pwsh/src/index.ts | 32 +- packages/shell/tool-pwsh/src/observe.ts | 74 +++ .../shell/tool-pwsh/tests/observe.spec.ts | 154 ++++++ packages/shell/tool-pwsh/tsconfig.json | 3 + .../workflow/tool-workflow/README.i18n.yaml | 4 +- packages/workflow/tool-workflow/README.md | 2 +- packages/workflow/tool-workflow/README.zh.md | 2 +- packages/workflow/tool-workflow/package.json | 11 +- .../workflow/tool-workflow/src/activity.ts | 119 +++++ packages/workflow/tool-workflow/src/index.ts | 13 +- .../tests/activity-mirror.spec.ts | 106 +++++ packages/workflow/tool-workflow/tsconfig.json | 3 + pnpm-lock.yaml | 170 +++++++ python/sdk-runtime/package.json | 49 +- scripts/gen-cordis-catalog.ts | 13 + scripts/gen-doc-graphs.ts | 16 + .../verify-package-readme-model-experience.ts | 4 + tsconfig.base.json | 6 + tsconfig.client.json | 2 + tsconfig.host.json | 4 + 147 files changed, 7311 insertions(+), 138 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md create mode 100644 .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md create mode 100644 apps/web/tests/live-activity-stream.e2e.ts create mode 100644 apps/web/tests/snapshots/live-activity-stream/settled.expected.md create mode 100644 apps/web/tests/snapshots/live-activity-stream/streaming.expected.md create mode 100644 docs/subsystems/activity.i18n.yaml create mode 100644 docs/subsystems/activity.md create mode 100644 docs/subsystems/activity.zh.md create mode 100644 packages/activity/README.i18n.yaml create mode 100644 packages/activity/README.md create mode 100644 packages/activity/README.zh.md create mode 100644 packages/activity/activity-local/README.i18n.yaml create mode 100644 packages/activity/activity-local/README.md create mode 100644 packages/activity/activity-local/README.zh.md create mode 100644 packages/activity/activity-local/package.json create mode 100644 packages/activity/activity-local/src/index.ts create mode 100644 packages/activity/activity-local/src/invariant.ts create mode 100644 packages/activity/activity-local/tests/activity.spec.ts create mode 100644 packages/activity/activity-local/tests/loader-composition.spec.ts create mode 100644 packages/activity/activity-local/tsconfig.json create mode 100644 packages/activity/activity/README.i18n.yaml create mode 100644 packages/activity/activity/README.md create mode 100644 packages/activity/activity/README.zh.md create mode 100644 packages/activity/activity/package.json create mode 100644 packages/activity/activity/src/brand.ts create mode 100644 packages/activity/activity/src/index.ts create mode 100644 packages/activity/activity/src/invariant.ts create mode 100644 packages/activity/activity/src/pump.ts create mode 100644 packages/activity/activity/src/types.ts create mode 100644 packages/activity/activity/tests/invariant.spec.ts create mode 100644 packages/activity/activity/tests/service.spec.ts create mode 100644 packages/activity/activity/tsconfig.json create mode 100644 packages/api/activity-controller/README.i18n.yaml create mode 100644 packages/api/activity-controller/README.md create mode 100644 packages/api/activity-controller/README.zh.md create mode 100644 packages/api/activity-controller/package.json create mode 100644 packages/api/activity-controller/src/client/index.ts create mode 100644 packages/api/activity-controller/src/client/model.ts create mode 100644 packages/api/activity-controller/src/client/service.ts create mode 100644 packages/api/activity-controller/src/feed.ts create mode 100644 packages/api/activity-controller/src/index.ts create mode 100644 packages/api/activity-controller/src/invariant.ts create mode 100644 packages/api/activity-controller/src/observe.ts create mode 100644 packages/api/activity-controller/src/types.ts create mode 100644 packages/api/activity-controller/tests/activity-controller.host.spec.ts create mode 100644 packages/api/activity-controller/tests/client-apply.client.spec.ts create mode 100644 packages/api/activity-controller/tests/client-model.client.spec.ts create mode 100644 packages/api/activity-controller/tests/client-service.client.spec.ts create mode 100644 packages/api/activity-controller/tests/controller.host.spec.ts create mode 100644 packages/api/activity-controller/tsconfig.client.json create mode 100644 packages/api/activity-controller/tsconfig.host.json create mode 100644 packages/api/activity-controller/tsconfig.json create mode 100644 packages/api/activity-controller/tsdown.config.ts create mode 100644 packages/client/ui-activity/README.i18n.yaml create mode 100644 packages/client/ui-activity/README.md create mode 100644 packages/client/ui-activity/README.zh.md create mode 100644 packages/client/ui-activity/package.json create mode 100644 packages/client/ui-activity/src/client/ActivityListAction.module.css create mode 100644 packages/client/ui-activity/src/client/ActivityListAction.tsx create mode 100644 packages/client/ui-activity/src/client/index.ts create mode 100644 packages/client/ui-activity/src/client/locales.ts create mode 100644 packages/client/ui-activity/src/css-modules.d.ts create mode 100644 packages/client/ui-activity/src/index.ts create mode 100644 packages/client/ui-activity/src/invariant.ts create mode 100644 packages/client/ui-activity/tests/activity-list-action.client.spec.tsx create mode 100644 packages/client/ui-activity/tests/browser-plugin.client.spec.ts create mode 100644 packages/client/ui-activity/tsconfig.json create mode 100644 packages/client/ui-activity/tsdown.config.ts create mode 100644 packages/shell/tool-bash/src/observe.ts create mode 100644 packages/shell/tool-bash/tests/observe.spec.ts create mode 100644 packages/shell/tool-pwsh/src/observe.ts create mode 100644 packages/shell/tool-pwsh/tests/observe.spec.ts create mode 100644 packages/workflow/tool-workflow/src/activity.ts create mode 100644 packages/workflow/tool-workflow/tests/activity-mirror.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml new file mode 100644 index 0000000000..07186d7a2c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md +2026-08-24-activity-observation-seam.md: da356b3b871a45958dc5a5214ccff8b49c0c193f +2026-08-24-activity-observation-seam.zh.md: 1c0c87d23a08d41882f00fd8f729f794d09169b1 diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md new file mode 100644 index 0000000000..da356b3b87 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md @@ -0,0 +1,51 @@ +# Agent Note: The activity observation seam (`ctx.activities`) and live output streaming to the Web client + +Status: implemented + +English | [中文](2026-08-24-activity-observation-seam.zh.md) + +## Problem + +Every incremental reader in the shell, terminal, and jobs layers is single-consumer and consuming: `ShellProcess.readOutput()`, `TerminalSendOperation.readOutput()`, and `JobRegistry.read()` all destroy the delta they return, because the owning model is the intended reader. The [web background-job display](2026-08-08-web-background-job-display.md) shipped the roster on those terms — its carrier is test-pinned to never call `ctx.jobs.read()`, since a browser read would silently steal bytes the model's `job_output` would never see — and explicitly deferred the output phase to "a separate non-consuming observation API". So a human watching the Web client saw that a background build was running but never a line of its output, and the `workflow/log` / `workflow/phase` events scripts are told to narrate with had no consumer at all. + +The substrate below bash already had the right shape: `SubprocessOutputReader.readFrom(fromByte)` is offset-based and explicitly non-consuming, and `bash-local` narrows it to a single cursor pair at the shell seam. + +## Decision + +A new capability seam, `packages/activity/`, is the non-consuming observation plane. An **activity** is one observable unit of long-running work: an append-only bounded output stream plus live status. The seam is pure observation — it starts nothing, cancels nothing, and is invisible to the model. Model-visible facts stay exactly where they were (tool results, `ctx.jobs` cursors), so the "model-visible ⟺ logged" invariant is untouched and a composition without the seam loses only live observation. + +- **`@deepseek-ai/dsh-activity` (Service Definition)** — abstract `ActivityRegistry` on `ctx.activities`: `open(spec) → ActivityHandle { append, updateDetail, end }`, fenced `get`/`list`, non-consuming `read(id, from)` over absolute UTF-8 byte offsets, owner-relative `onActivitiesChanged` (roster) and `onOutput` (advancement signal, id only). `ActivityCorrelation { callId?, jobId? }` links a row to its tool call and job. `pumpActivityOutput` is the shared pull-substrate pump. +- **`@deepseek-ai/dsh-activity-local` (Service Provider)** — in-memory chunk ring per activity. Offsets stay absolute across eviction (`outputEarliest` marks the oldest retained byte; a read below it is `lossy`, never an error); a single over-cap chunk keeps its UTF-8-safe tail with `gapBefore`. Config `retainBytes` (256 KiB) live, trimmed to `settledRetainBytes` (16 KiB) at settlement. Records outlive producer fibers; owner disposal force-ends and removes; `end` is first-wins and post-end writes log-and-drop so a producer's trailing flush cannot break its own teardown. +- **`@deepseek-ai/dsh-api-activity-controller`** — the wire, in the workspace-controller shape: `activity.control` streams one roster baseline then whole-bucket replacement frames per owner session (the jobs-frame self-healing semantics), and `activity.observe({ activityId, from? })` streams one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget), then the terminal `status` on the same stream before closing — so settlement can never race a still-open output channel. Reconnects resume from the last frame's `next`. The client half installs `ctx.activityFeed` (roster mirror plus reference-counted observation streams with a bounded render tail). +- **`@deepseek-ai/dsh-client-ui-activity`** — one session-header action beside the job list; expanding a row opens its observation stream into a `TerminalBlock` panel, collapsing closes it, so output only flows while someone is watching. `TerminalBlock` itself gained live rendering: a running block with supplied `output` shows the text under the running state instead of the historical prompt-only frame. + +Producers mirror best-effort behind `ctx.get('activities')`, never a declared inject, and every observation failure is logged and swallowed — the seam is strictly optional and can never break the work it observes: + +- `ShellProcess` gained optional `observed` — the substrate's non-consuming offset readers re-exposed (bash-local and pwsh-local return `handle.collected`; the e2b subprocess provider satisfies the same contract). `dsh-tool-bash` / `dsh-tool-pwsh` open a correlated activity after `jobs.start()` commits and pump `observed` at `activityPollMs` (default 150 ms). +- `dsh-tool-workflow` mirrors each recorded top-level run as a push-mode `workflow` activity, giving the previously unconsumed `workflow/phase` / `workflow/log` events their consumer. + +### Why not durable session events, the control stream, or the jobs seam + +Peer products converge on the same split (Codex marks every exec-output delta "Transient, non-durable" in its rollout policy; Claude Code persists a file path, never deltas; Kimi's envelope has a first-class `volatile: true`; OpenCode's V2 corrected V1's per-chunk durable part updates to "stream fragments are live-only"). In this repository the durable route is also concretely bad: chunk-run packing is hardcoded to the three `assistant/chunk` shapes so a new output event gets zero compression, each event is one uncoalesced WebSocket frame, and reconnect gap-repair re-reads the window — and the job-display note already rejected it ("spill exists precisely so oversized tool output stays out of the log"). The session control stream's whole-snapshot frames are self-healing for rosters but quadratic for streams. Extending the jobs seam would cover only jobs — not workflow narration, not a future foreground stream — and would re-litigate the settled "producers own their buffers" decision; the activity plane instead recovers the substrate's existing offsets. + +### Two cursor regimes, kept separate + +The model keeps its consuming cursors (`job_output` semantics unchanged, zero prompt or tool changes anywhere in this work); observers get absolute offsets any number of readers can hold. This mirrors the strongest peer pattern (Codex: destructive drain for the model, seq-cursor reads for infrastructure) and is what makes the wire path provably non-stealing. + +## Alternatives considered + +**Durable `activity/output` session events with a conversation node.** Replay for free, but the log-growth math above, plus retention pressure on every follower, killed it; the transcript's durable story remains the producers' tool results. + +**Extending `SessionControlFrame` with output.** One fewer stream, but whole-snapshot semantics resend the accumulated buffer per chunk — O(n²) on exactly the data that grows. + +**A signal frame plus RPC pull.** The subagent-catalog shape; rejected for the split-authority apparatus the job-display note documents, and it fails worst at settlement (running row, dead stream). + +**Observation inside the jobs seam.** One integration point, but only for jobs; foreground runs, terminals, and workflow narration would each need another mechanism, and the registry would grow a second buffer beside the producers' own. + +## Testing + +Unit suites pin the registry (offsets, eviction, UTF-8-safe tail trims, lossy reads, first-wins settlement, owner cleanup, scoped delivery, listener containment, HMR disposal, Loader composition with row config), the pump (cadence, per-source cursors, lossy `gapBefore`, settlement drain, timer hygiene), the controller (baseline/bucket frames, anchor/output/status ordering, resume, frame splitting, fenced owned reads, abort), the client model and reference-counted streams, and both producer taps (real subprocess output for bash including the never-steals-the-model's-cursor assertion; scripted readers for pwsh; event-driven mirror for workflow, including the throwing-registry containment the optionality promise requires). The keyless web e2e drives a real `run_in_background` bash through the full composition and pins ARIA goldens for the streaming panel while the command still runs and for the settled panel after a registry kill. + +## Consequences + +The Web client now shows live output for background bash/pwsh and workflow narration with zero model-facing change, and any plugin can join with three calls (`open`/`append`/`end`) against an optional service. Bought at the cost of one more seam family and a per-producer tap; the roster duplicates a background job's row across two header lists (accepted, as the subagent catalog precedent was). Deferred, recorded on the package READMEs: foreground streaming (needs a shell-seam run/observe variant), terminal `pty-send` observation (needs a non-consuming face on terminal buffers), a human kill control (blocked on the jobs `reported` contract), SDK/ACP consumers, and durable replay after restart. diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md new file mode 100644 index 0000000000..1c0c87d23a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md @@ -0,0 +1,51 @@ +# Agent Note:activity 观察 seam(`ctx.activities`)与 Web 客户端实时输出流 + +状态:implemented + +[English](2026-08-24-activity-observation-seam.md) | 中文 + +## 问题 + +shell、terminal 与 jobs 各层的增量读取器全都是单读者且消费型的:`ShellProcess.readOutput()`、`TerminalSendOperation.readOutput()` 与 `JobRegistry.read()` 返回即销毁增量,因为预期读者就是持有模型。[Web 后台任务显示](2026-08-08-web-background-job-display.zh.md)在这一前提下交付了 roster——其载体被测试钉死绝不调用 `ctx.jobs.read()`,否则浏览器读取会无声偷走模型 `job_output` 永远看不到的字节——并把输出阶段明确推迟给"一个独立的非消费观察 API"。于是人在 Web 客户端只能看到后台构建"在跑",看不到一行输出;脚本被告知用来叙述进度的 `workflow/log` / `workflow/phase` 事件更是全无消费者。 + +bash 之下的底座其实早有正确形态:`SubprocessOutputReader.readFrom(fromByte)` 是按 offset、明确非消费的,`bash-local` 在 shell seam 把它窄化成了单游标对。 + +## 决策 + +新建能力 seam `packages/activity/`,作为非消费观察面。**activity** 是一个可观察的长时工作单元:一条 append-only 的有界输出流加实时状态。该 seam 是纯观察——不启动、不取消任何工作,对模型不可见。模型可见事实原地不动(工具结果、`ctx.jobs` 游标),"模型可见 ⟺ 已记录"不变量不受影响;不装该 seam 的组合只失去实时观察。 + +- **`@deepseek-ai/dsh-activity`(Service Definition)**——`ctx.activities` 上的抽象 `ActivityRegistry`:`open(spec) → ActivityHandle { append, updateDetail, end }`、有栅栏的 `get`/`list`、按绝对 UTF-8 字节 offset 的非消费 `read(id, from)`、owner 相对投递的 `onActivitiesChanged`(roster)与 `onOutput`(推进信号,只带 id)。`ActivityCorrelation { callId?, jobId? }` 把行关联到工具调用与 job。`pumpActivityOutput` 是 pull 型底座的共享泵。 +- **`@deepseek-ai/dsh-activity-local`(Service Provider)**——每个 activity 一个内存 chunk 环。offset 跨淘汰稳定(`outputEarliest` 指明最老保留字节;低于它的读取是 `lossy` 而非错误);单个超上限 chunk 保留 UTF-8 安全尾部并带 `gapBefore`。配置 `retainBytes`(256 KiB)存活,结算时裁到 `settledRetainBytes`(16 KiB)。记录存续期长于生产者 fiber;owner 销毁强制终结并移除;`end` first-wins,end 后写入记日志后丢弃,生产者的收尾 flush 砸不掉自己的 teardown。 +- **`@deepseek-ai/dsh-api-activity-controller`**——wire 层,取 workspace-controller 的形态:`activity.control` 流出一份 roster baseline 加按 owner 会话的整桶替换帧(jobs 帧的自愈语义);`activity.observe({ activityId, from? })` 流出一帧 `opened` 锚点、合并的 `output` 帧(`flushMs` 窗口、`maxFrameBytes` 软预算)、再在同一条流上发终态 `status` 后关闭——结算永远不会与仍开着的输出通道竞态。重连以上一帧的 `next` 续传。客户端半区安装 `ctx.activityFeed`(roster 镜像 + 按引用计数的观察流与有界渲染尾巴)。 +- **`@deepseek-ai/dsh-client-ui-activity`**——job 列表旁的一个会话头部 action;展开某行即把其观察流打开进 `TerminalBlock` 面板,收起即关——只有有人在看时输出才流动。`TerminalBlock` 本身获得实时渲染能力:running 且提供了 `output` 的块在 running 状态下显示文本,而非历史上的仅提示行画面。 + +生产者一律经 `ctx.get('activities')` 尽力镜像,绝不声明 inject,一切观察失败只记日志并吞掉——该 seam 严格可选,永远不能破坏它所观察的工作: + +- `ShellProcess` 新增可选 `observed`——底座的非消费 offset 读取器复出(bash-local 与 pwsh-local 返回 `handle.collected`;e2b subprocess provider 满足同一契约)。`dsh-tool-bash` / `dsh-tool-pwsh` 在 `jobs.start()` 提交后打开带关联的 activity,并以 `activityPollMs`(默认 150 ms)泵取 `observed`。 +- `dsh-tool-workflow` 把每个被记录的顶层运行镜像为 push 形态的 `workflow` activity,让此前无人消费的 `workflow/phase` / `workflow/log` 事件有了消费者。 + +### 为何不用持久会话事件、control 流或 jobs seam + +同行产品收敛于同一切分(Codex 在 rollout policy 里把所有 exec 输出 delta 标为 "Transient, non-durable";Claude Code 持久化文件路径而非 delta;Kimi 的信封有一等的 `volatile: true`;OpenCode V2 把 V1 逐 chunk 持久化 part 更新纠正为 "stream fragments are live-only")。在本仓库持久路线还有具体的坏处:chunk-run 打包只认三种 `assistant/chunk` 形态,新输出事件零压缩;每事件一条未合并的 WebSocket 帧;重连缺口修复要重读窗口——且 job 显示 note 已否决过它("spill 的存在就是为了让超大工具输出不进日志")。session control 流的整值帧对 roster 自愈、对流则是二次方开销。扩 jobs seam 只覆盖 jobs——workflow 叙述与未来的前台流都覆盖不到——还要重翻已定案的"生产者拥有自己的缓冲";activity 面转而复用底座既有的 offset。 + +### 两套游标制,各归其位 + +模型保留消费游标(`job_output` 语义不变,本次工作零 prompt/工具改动);观察者拿任意多读者可共持的绝对 offset。这正是最强的同行范式(Codex:模型用破坏性 drain,基础设施用 seq 游标读取),也是 wire 路径可证明"不偷字节"的原因。 + +## 曾考虑的替代方案 + +**持久 `activity/output` 会话事件 + conversation node。** 回放免费,但上述日志膨胀账加上对每个 follower 的保留压力否掉了它;transcript 侧的持久故事仍是生产者的工具结果。 + +**给 `SessionControlFrame` 加输出。** 少一条流,但整值语义会按 chunk 重发累计缓冲——恰好在会增长的数据上 O(n²)。 + +**信号帧 + RPC 拉取。** subagent catalog 的形态;因 job 显示 note 记录的权威割裂机制而否决,且它在结算时刻失效得最难看(行还在跑、流已死)。 + +**在 jobs seam 内做观察。** 一个集成点,但只对 jobs;前台运行、terminal 与 workflow 叙述各需另一套机制,注册表还得在生产者自己的缓冲旁再养一份。 + +## 测试 + +单测钉住注册表(offset、淘汰、UTF-8 安全裁尾、lossy 读取、first-wins 结算、owner 清理、scope 投递、监听容错、HMR 可逆、带行配置的 Loader 组合)、泵(节奏、每源游标、lossy `gapBefore`、结算排干、定时器卫生)、controller(baseline/桶帧、锚点/输出/状态排序、续传、帧切分、有栅栏的有主读取、abort)、客户端模型与引用计数流,以及两类生产者接入(bash 用真实子进程输出,含"绝不偷模型游标"断言;pwsh 用脚本化读取器;workflow 用事件驱动镜像,含可选性承诺要求的 throwing-registry 容错)。无钥 web e2e 经完整组合驱动真实 `run_in_background` bash,钉住命令仍在运行时流式面板与注册表 kill 后已结算面板的 ARIA golden。 + +## 后果 + +Web 客户端在零模型面改动下获得后台 bash/pwsh 实时输出与 workflow 叙述;任何插件对着一个可选服务用三个调用(`open`/`append`/`end`)即可接入。代价是多一个 seam 家族与每生产者一个接入点;后台 job 的行会在两个头部列表重复出现(沿用 subagent catalog 的既有取舍)。暂缓项记录在各包 README:前台流式(需 shell seam 的 run/observe 变体)、terminal `pty-send` 观察(需给 terminal 缓冲加非消费面)、人工 kill 控件(被 jobs `reported` 契约阻塞)、SDK/ACP 消费端、重启后的持久回放。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 9ec9a9a22d..31548a2d43 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -40,6 +40,8 @@ flowchart LR cfg --> plugin_dsh_base_agent_default_model plugin_dsh_base_jobs["jobs
@deepseek-ai/dsh-jobs-local"] cfg --> plugin_dsh_base_jobs + plugin_dsh_base_activity["activity
@deepseek-ai/dsh-activity-local"] + cfg --> plugin_dsh_base_activity plugin_dsh_base_llm_retry["llm-retry
@deepseek-ai/dsh-llm-retry"] cfg --> plugin_dsh_base_llm_retry plugin_dsh_base_settings["settings
@deepseek-ai/dsh-settings-file"] @@ -190,6 +192,7 @@ flowchart LR | `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` | | `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` | | `jobs` | `@deepseek-ai/dsh-jobs-local` | +| `activity` | `@deepseek-ai/dsh-activity-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | | `settings` | `@deepseek-ai/dsh-settings-file` | | `credentials` | `@deepseek-ai/dsh-credentials-local` | diff --git a/apps/web/tests/live-activity-stream.e2e.ts b/apps/web/tests/live-activity-stream.e2e.ts new file mode 100644 index 0000000000..750e1697f3 --- /dev/null +++ b/apps/web/tests/live-activity-stream.e2e.ts @@ -0,0 +1,134 @@ +// Session-header live activities driven by a real background bash run: the +// roster row arrives over the activity control stream, expanding it opens the +// observation stream, and the panel shows the process's real output while it +// is still running. No model call is involved. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { JobId } from '@deepseek-ai/dsh-jobs' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-activity-stream', import.meta.url)) +const STREAMING_EXPECTED = join(SNAPSHOT_DIR, 'streaming.expected.md') +const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'live-activity-stream-web-e2e' +// One fixed line of early output, then silence long enough that the streaming +// assertions never race the process exiting; the test kills it explicitly. +const COMMAND = "printf 'streamed-%s\\n' marker-line; sleep 45" + +/** + * Wait for opening a session to publish its live Agent. + * @param scaffold - the booted web scaffold. + * @param sessionId - the opened session's identity. + * @returns the registered Agent instance. + */ +async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise { + const deadline = Date.now() + 30_000 + for (;;) { + const found = scaffold.ctx.agents.get(sessionId) + if (found !== undefined) return found + if (Date.now() > deadline) throw new Error(`opening session "${sessionId}" published no live Agent`) + await new Promise(resolve => setTimeout(resolve, 100)) + } +} + +describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let agent: Agent + let jobId: JobId + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + + // Opening the session drives the Host's ordinary Agent resolution; the + // activity owner must be that exact live instance, never a second one. + agent = await liveAgent(scaffold, SessionId(SEED_ID)) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('streams a running background command\'s real output into the expanded panel', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-streaming')) + const trigger = page.getByRole('button', { name: '1 live activity' }) + expect(await trigger.count()).toBe(0) + + const started = await scaffold.ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('live-activity-stream-e2e'), + name: 'bash', + arguments: { command: COMMAND, description: 'Emit one live line then hold', run_in_background: true }, + agent, + }) + const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') + const matched = /\bbash-\d+\b/.exec(reported) + if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) + jobId = JobId(matched[0]) + + // The roster row reaches the header over the activity control stream. + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + + // Expanding the row opens the observation stream; the panel then renders + // the command's real stdout while the process is still running. + const expand = page.getByRole('button', { name: /Show live output/ }) + await expand.waitFor({ timeout: 10_000 }) + await expand.click() + await page.getByText('streamed-marker-line').waitFor({ timeout: 15_000 }) + + const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STREAMING_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('settles the open panel when the job is killed, keeping the streamed tail', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-settled')) + expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') + + // Settlement arrives on the observation stream itself, so the open panel + // flips without any further interaction; the roster trigger follows. + const idle = page.getByRole('button', { name: '1 activity' }) + await idle.waitFor({ timeout: 20_000 }) + await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 }) + + const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['settled.expected.md', 'streaming.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md new file mode 100644 index 0000000000..7ffec602cb --- /dev/null +++ b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md @@ -0,0 +1,8 @@ +- list "Live activities": + - listitem: + - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: + - text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM" + - img + - text: done $ printf 'streamed-%s\n' marker-line; sleep 45 + - button "Copy" + - text: streamed-marker-line diff --git a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md new file mode 100644 index 0000000000..fd536ef430 --- /dev/null +++ b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md @@ -0,0 +1,8 @@ +- list "Live activities": + - listitem: + - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: + - text: bash printf 'streamed-%s\n' marker-line; sleep 45 running + - img + - text: running $ printf 'streamed-%s\n' marker-line; sleep 45 + - button "Copy" + - text: streamed-marker-line diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 386615f059..9ea5d0c61e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -90,6 +90,7 @@ "tests/subagent-interrupt-ui.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", "tests/background-job-list.e2e.ts", + "tests/live-activity-stream.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", "tests/turn-tail-actions.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 82e57fd2f7..a7e83a6223 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 docs/architecture.md -architecture.md: add615c252948adab8db7dec7059f94b8f45e52c -architecture.zh.md: 48baf14d6a29e5ef77e6e47f4d9fa9adc4e9e748 +architecture.md: cfa1dee639ebc4ae60db13d11d246c2ffa103cd0 +architecture.zh.md: 0139f575a2721a429ac6b241638c031c68f8530f diff --git a/docs/architecture.md b/docs/architecture.md index add615c252..cfa1dee639 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -127,6 +127,7 @@ New behavior attaches to a documented extension point. Changing the loop itself | Add persistent terminal execution | register a `ctx.terminals` backend plus `dsh-tool-terminal` | | Add a human command | register on `ctx.commands`; it dispatches without a model turn | | Add background work | register on `ctx.jobs`; `job_*` tools collect or stop it | +| Stream live output to observers | open a `ctx.activities` handle; the Web client renders it | | Start a Session from an external webhook | register a trusted rule on `ctx.webhookRuntime` and mount a provider adapter | | Add filesystem access or policy | register a `ctx.fs` provider or listen to `fs/*` events | | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 48baf14d6a..0139f575a2 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -131,6 +131,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 添加持久化终端执行 | 注册 `ctx.terminals` 后端和 `dsh-tool-terminal` | | 添加用户命令 | 在 `ctx.commands` 上注册;它无需模型轮次即可分派 | | 添加后台工作 | 在 `ctx.jobs` 上注册;`job_*` 工具负责收集或停止 | +| 向观察者流式输出 | 打开一个 `ctx.activities` 句柄;Web 客户端负责渲染 | | 从外部 webhook 启动 Session | 在 `ctx.webhookRuntime` 上注册可信规则,并挂载提供方适配器 | | 添加文件系统访问或策略 | 注册 `ctx.fs` 提供方,或监听 `fs/*` 事件 | | 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index d7e1c70e5a..c1a409d2e8 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -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 docs/capability-seams.md -capability-seams.md: 0994b186f7daa6afbff0f1484216e55fc79170ff -capability-seams.zh.md: 48aa0a5353bba8d56f63abfcfc4cd2c601569cb6 +capability-seams.md: de9b8e1eb68dd7bf110609044dbf96a93a59adc5 +capability-seams.zh.md: 0b8e93fd3f0b08770652ca735966649b35f98330 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 0994b186f7..de9b8e1eb6 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -40,6 +40,8 @@ flowchart LR pkg_apiproxy["apiproxy"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] + pkg_api_activity_controller["api-activity-controller"] + svc_activityController["ctx.activityController
Host Activity Remote controller"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -178,6 +180,11 @@ flowchart LR svc_jobs["ctx.jobs
Background job registry"] pkg_jobs_local["jobs-local"] pkg_tool_jobs["tool-jobs"] + pkg_activity["activity"] + svc_activities["ctx.activities
Live activity observation registry"] + pkg_activity_local["activity-local"] + pkg_tool_workflow["tool-workflow"] + pkg_activity_controller["activity-controller"] pkg_web["web"] svc_web["ctx.web
Web access provider registry"] pkg_web_search_exa["web-search-exa"] @@ -201,7 +208,6 @@ flowchart LR pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
Workflow script engine"] pkg_workflow_worker_thread["workflow-worker-thread"] - pkg_tool_workflow["tool-workflow"] pkg_webhook["webhook"] svc_webhookRuntime["ctx.webhookRuntime
Webhook rule runtime"] pkg_webhook_github["webhook-github"] @@ -214,11 +220,14 @@ flowchart LR svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] pkg_acp --> svc_approval + pkg_activity --> svc_activities + pkg_activity_local --> svc_activities pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets pkg_agent_team --> svc_agentTeams + pkg_api_activity_controller --> svc_activityController pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController pkg_api_workspace_controller --> svc_workspaceController @@ -325,6 +334,10 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry + svc_activities --> pkg_activity_controller + svc_activities --> pkg_tool_bash + svc_activities --> pkg_tool_pwsh + svc_activities --> pkg_tool_workflow svc_agentDefaultModel --> pkg_headless svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo @@ -456,6 +469,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | `apiproxy` | - | Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | +| `ctx.activityController` | `core` | [`api-activity-controller`](../packages/api/activity-controller) | - | - | - | Owns the live-activity roster stream and per-activity non-consuming output observation through the generated Remote namespace. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | @@ -501,6 +515,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.agentTeams` | `core` | `agent-team` | - | `tool-agent-team` | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes the scoped model policy and controls. | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | +| `ctx.activities` | `seam` | [`activity`](../packages/activity/activity) | [`activity-local`](../packages/activity/activity-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-workflow`](../packages/workflow/tool-workflow), `activity-controller` | - | Producers mirror live output and status into non-consuming offset-addressed streams; activity-controller serves them to browsers, and the model-facing paths (jobs cursors, tool results) stay untouched. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 48aa0a5353..0b8e93fd3f 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -42,6 +42,8 @@ flowchart LR pkg_apiproxy["apiproxy"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] + pkg_api_activity_controller["api-activity-controller"] + svc_activityController["ctx.activityController
Host Activity Remote controller"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -180,6 +182,11 @@ flowchart LR svc_jobs["ctx.jobs
Background job registry"] pkg_jobs_local["jobs-local"] pkg_tool_jobs["tool-jobs"] + pkg_activity["activity"] + svc_activities["ctx.activities
Live activity observation registry"] + pkg_activity_local["activity-local"] + pkg_tool_workflow["tool-workflow"] + pkg_activity_controller["activity-controller"] pkg_web["web"] svc_web["ctx.web
Web access provider registry"] pkg_web_search_exa["web-search-exa"] @@ -203,7 +210,6 @@ flowchart LR pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
Workflow script engine"] pkg_workflow_worker_thread["workflow-worker-thread"] - pkg_tool_workflow["tool-workflow"] pkg_webhook["webhook"] svc_webhookRuntime["ctx.webhookRuntime
Webhook rule runtime"] pkg_webhook_github["webhook-github"] @@ -216,11 +222,14 @@ flowchart LR svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] pkg_acp --> svc_approval + pkg_activity --> svc_activities + pkg_activity_local --> svc_activities pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets pkg_agent_team --> svc_agentTeams + pkg_api_activity_controller --> svc_activityController pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController pkg_api_workspace_controller --> svc_workspaceController @@ -327,6 +336,10 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry + svc_activities --> pkg_activity_controller + svc_activities --> pkg_tool_bash + svc_activities --> pkg_tool_pwsh + svc_activities --> pkg_tool_workflow svc_agentDefaultModel --> pkg_headless svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo @@ -458,6 +471,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | `apiproxy` | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态与 Agent 激活策略;apiProxy 在需要 Session 上下文的领域中复用其检查和 Agent 解析操作。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | +| `ctx.activityController` | `core` | [`api-activity-controller`](../packages/api/activity-controller) | - | - | - | 通过生成的 Remote namespace 负责实时活动 roster 流与逐 activity 的非消费输出观察。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | @@ -503,6 +517,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.agentTeams` | `core` | `agent-team` | - | `tool-agent-team` | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG 与 continuable child 生命周期;tool-agent-team 提供作用域化模型策略和控制工具。 | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | +| `ctx.activities` | `seam` | [`activity`](../packages/activity/activity) | [`activity-local`](../packages/activity/activity-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-workflow`](../packages/workflow/tool-workflow), `activity-controller` | - | 生产方把实时输出与状态镜像进非消费、按 offset 寻址的流;activity-controller 把它们送达浏览器,模型侧路径(jobs 游标、工具结果)不受影响。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9ba399370d..6c2e824a55 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 6494070f6204ce6130e22d6f71ddbfd18fdc69dd -config-catalog.zh.md: da19d854ca520425a799cb699238a7dca05a90da +config-catalog.md: 0bf883bbfde80377602be90bd913c32f1b320f63 +config-catalog.zh.md: 381319465dab9dafeecaacf704caba8bab7455f3 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6494070f62..0bf883bbfd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -33,6 +33,22 @@ Depends on: `Stream` (`@agentclientprotocol/sdk`) Source: [`packages/acp/acp/src/index.ts:74`](../packages/acp/acp/src/index.ts) + + +## `@deepseek-ai/dsh-activity-local` + +```ts config-catalog +/** Configuration for the process-local observation registry. */ +export interface Config { + /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number +} +``` + +Source: [`packages/activity/activity-local/src/index.ts:31`](../packages/activity/activity-local/src/index.ts) + ## `@deepseek-ai/dsh-agent-default-model` @@ -276,6 +292,24 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md) Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-activity-controller` + +Requires: `typert` + +```ts config-catalog +/** Configuration for the Activity Remote owner. */ +export interface Config { + /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ + flushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + maxFrameBytes?: number +} +``` + +Source: [`packages/api/activity-controller/src/index.ts:26`](../packages/api/activity-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -2559,10 +2593,12 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv` export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } ``` -Source: [`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) +Source: [`packages/shell/tool-bash/src/index.ts:36`](../packages/shell/tool-bash/src/index.ts) @@ -2724,10 +2760,12 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv` export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } ``` -Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) +Source: [`packages/shell/tool-pwsh/src/index.ts:61`](../packages/shell/tool-pwsh/src/index.ts) @@ -3004,7 +3042,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-workflow/src/index.ts:33`](../packages/workflow/tool-workflow/src/index.ts) +Source: [`packages/workflow/tool-workflow/src/index.ts:34`](../packages/workflow/tool-workflow/src/index.ts) @@ -3303,6 +3341,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-activity` ([`packages/client/ui-activity/src/index.ts`](../packages/client/ui-activity/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-approval` ([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) @@ -3376,6 +3415,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). +- `@deepseek-ai/dsh-activity` — abstract `ActivityRegistry` ([`packages/activity/activity/src/index.ts`](../packages/activity/activity/src/index.ts)) - `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index da19d854ca..381319465d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -35,6 +35,22 @@ export interface AcpConfig { 来源:[`packages/acp/acp/src/index.ts:74`](../packages/acp/acp/src/index.ts) + + +## `@deepseek-ai/dsh-activity-local` + +```ts config-catalog +/** Configuration for the process-local observation registry. */ +export interface Config { + /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number +} +``` + +来源:[`packages/activity/activity-local/src/index.ts:31`](../packages/activity/activity-local/src/index.ts) + ## `@deepseek-ai/dsh-agent-default-model` @@ -278,6 +294,24 @@ export interface Config { 来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-activity-controller` + +需要:`typert` + +```ts config-catalog +/** Configuration for the Activity Remote owner. */ +export interface Config { + /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ + flushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + maxFrameBytes?: number +} +``` + +来源:[`packages/api/activity-controller/src/index.ts:26`](../packages/api/activity-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -2561,10 +2595,12 @@ export type TokenMeterConfig = Record export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } ``` -来源:[`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) +来源:[`packages/shell/tool-bash/src/index.ts:36`](../packages/shell/tool-bash/src/index.ts) @@ -2726,10 +2762,12 @@ export interface Config { export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } ``` -来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) +来源:[`packages/shell/tool-pwsh/src/index.ts:60`](../packages/shell/tool-pwsh/src/index.ts) @@ -3305,6 +3343,7 @@ export interface Config { - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-activity`([`packages/client/ui-activity/src/index.ts`](../packages/client/ui-activity/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-approval`([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) @@ -3378,6 +3417,7 @@ export interface Config { 抽象服务类——部署时应改为加载具体的实现包(参见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md))。 +- `@deepseek-ai/dsh-activity` — 抽象 `ActivityRegistry`([`packages/activity/activity/src/index.ts`](../packages/activity/activity/src/index.ts)) - `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — 抽象 `CodeRuntime`([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 33c536f50a..b48dba34e8 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: 4ab1275e9309e150504d6a3bdd80792bb1a49ea1 -event-producer-consumer.zh.md: 8d8a401bfdccc74d5774ce5ce7ceac2c548f6c72 +event-producer-consumer.md: ad9a32ed1fd479d1cd14422333f0cd5b6845e55f +event-producer-consumer.zh.md: 783f524b8b31bfcd80237b10f078ed6a27ce13f2 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4ab1275e93..ad9a32ed1f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -69,8 +69,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## Non-harness or undeclared event strings seen in package source diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 8d8a401bfd..783f524b8b 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -71,8 +71,8 @@ | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## 包源码中出现的非 harness 或未声明事件字符串 diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index e43820301d..c7bb24dd77 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -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 docs/subsystems/README.md -README.md: 2b277650c4b9e320183f75845d68f922b31a522e -README.zh.md: 2279857e0d58cb36e8027c196075622344d705ae +README.md: b41c74e46e43ca2fea378ac7e9988136a6ee61ee +README.zh.md: fa4fb55034450bfc5f537ea1a2c0aaccfba469de diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 2b277650c4..b41c74e46e 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -44,6 +44,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | | [jobs.md](jobs.md) | the background-job runtime: branded `JobId`s, the producer contract, consumer views, and `ctx.jobs` service behavior | +| [activity.md](activity.md) | live activity observation: branded `ActivityId`s, the producer handle, non-consuming offset reads, and the wire streams | | [permission-presets.md](permission-presets.md) | the permission-preset layer: `PresetSpec`/`PresetOption`, the derived `custom` state, the log-only `permission/preset` event | | [plan.md](plan.md) | plan mode: the log-only `plan/mode` state, pending-selection flush, `PlanModeConfig`, the `exit_plan_mode` review arc | | [invariants.md](invariants.md) | the runtime-invariant registry: selection `Config`, `InvariantInstaller`/`InvariantFailure`, the empty-companion contract | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 2279857e0d..fa4fb55034 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -44,6 +44,7 @@ | [spill.md](spill.zh.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` | | [workflow.md](workflow.zh.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 | | [jobs.md](jobs.zh.md) | 后台任务运行时:品牌化 `JobId`、producer 约定、消费方视图和 `ctx.jobs` 服务行为 | +| [activity.md](activity.zh.md) | 实时活动观察:品牌化 `ActivityId`、生产者句柄、非消费 offset 读取与 wire 流 | | [permission-presets.md](permission-presets.zh.md) | 权限预设层:`PresetSpec`/`PresetOption`、派生的 `custom` 状态、仅记日志的 `permission/preset` 事件 | | [plan.md](plan.zh.md) | 计划模式:仅记日志的 `plan/mode` 状态、待定选择的冲刷、`PlanModeConfig`、`exit_plan_mode` 审阅流程 | | [invariants.md](invariants.zh.md) | 运行时不变式注册表:选择配置 `Config`、`InvariantInstaller`/`InvariantFailure`、空配套插件约定 | diff --git a/docs/subsystems/activity.i18n.yaml b/docs/subsystems/activity.i18n.yaml new file mode 100644 index 0000000000..cf285e6ba7 --- /dev/null +++ b/docs/subsystems/activity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 docs/subsystems/activity.md +activity.md: 000bdc30be05eb17d670367df3fd214e7d0af7f3 +activity.zh.md: 1fc79e9d596351a1f818ea83c15d1b5082689b79 diff --git a/docs/subsystems/activity.md b/docs/subsystems/activity.md new file mode 100644 index 0000000000..000bdc30be --- /dev/null +++ b/docs/subsystems/activity.md @@ -0,0 +1,141 @@ +# Live Activity Observation + +English | [中文](activity.zh.md) + +Types shared by streaming-output producers, `ctx.activities`, and the observation consumers. The [observation-seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) owns the design; this page records the cross-package vocabulary from [`packages/activity/activity/src/types.ts`](../../packages/activity/activity/src/types.ts) and the wire frames from [`packages/api/activity-controller/src/types.ts`](../../packages/api/activity-controller/src/types.ts). + +An **activity** is one observable unit of long-running work: an append-only bounded output stream plus live status. The registry is a pure observation plane — it starts nothing, cancels nothing, and is invisible to the model. Producers keep their execution resources and their model-facing surfaces (`ctx.jobs` consuming cursors, tool results); observers read non-consumingly at absolute byte offsets, so any number of them coexist with the model's single consuming cursor. + +## Ids, kinds, and correlation + +`ActivityId` is a [branded id](core.md#branded-ids) generated as `-N`; visibility relies on owner authorization, not id secrecy. `ActivityKindMap` is merge-extensible (`bash` seeded; `pwsh` and `workflow` merged by their producers). `ActivityCorrelation` links one activity to the `CallId` that started the work and the `JobId` carrying its model-facing control surface, so a consumer attaches the stream to an existing card or row without guessing from labels. + +## Producer face + +`open(spec)` returns an `ActivityHandle` with synchronous `append(text, { channel?, gapBefore? })`, `updateDetail(detail)`, and first-wins `end(outcome)`. Offsets advance by each chunk's UTF-8 byte length; `gapBefore` marks producer-side loss so a discontinuity stays visible. After `end` — including a teardown force-end — `append` and `updateDetail` log and drop rather than throw, so a producer's trailing flush cannot break its own teardown. + +`pumpActivityOutput(handle, sources, { pollMs, done })` adapts pull substrates: it copies each non-consuming offset reader (the subprocess `readFrom(fromByte)` family, re-exposed on `ShellProcess.observed`) into the handle at a bounded cadence and drains once more after `done` settles. Push-natural producers (the workflow tool's run mirror) append directly. + +## Observation + +`read(id, from, caller?)` returns the retained chunks overlapping `[from, total)` and the resume offset `next`, consuming nothing. Offsets stay absolute across eviction: `outputEarliest` on the snapshot names the oldest retained byte, and a read below it is `lossy: true`, never an error. Retention is provider policy ([`dsh-activity-local`](../../packages/activity/activity-local/README.md): `retainBytes` live, trimmed to `settledRetainBytes` at settlement). + +Two effect-scoped listener families deliver owner-relative on the jobs-registry terms: `onActivitiesChanged` fires per visible-set change (open, detail, settlement, removal) so consumers re-read whole rows, and `onOutput` signals stream advancement (append or settlement) carrying only the id, so each consumer reads from its own cursor. + +## Wire transport + +[`dsh-api-activity-controller`](../../packages/api/activity-controller/README.md) exposes the plane to browsers as two Remote streams: `activity.control` (baseline plus whole-bucket roster replacement frames) and `activity.observe` (one `opened` anchor, coalesced `output` frames, then the terminal `status` on the same stream, after which the generation closes). Output frames are transient — durable history stays with the producers' tool results, exactly as the [web job display decision](../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md) requires. [`dsh-client-ui-activity`](../../packages/client/ui-activity/README.md) renders the roster in the session header and opens observation streams only while a panel is expanded. + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.activities` — `ActivityRegistry` (abstract seam) + +Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Implementations must honor these semantics: + +- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. +- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource. +- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap. +- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition's own carrier — sees every owner, while one registered under an agent composition's scope sees exactly the agents composed under it. Every listener is contained. + +```ts cordis-catalog +/** + * Validate the spec, attach owner cleanup, and atomically register one + * activity. A rejection leaves no id; after return the producer owns the + * handle and the visible set has changed. + * @param spec - activity identity, owner, and correlation. + * @returns the producer face of the registered activity. + */ +abstract open(spec: ActivityOpen): ActivityHandle + +/** + * List caller-owned and unowned activities in registration order without + * exposing another session's labels or output. + * @param caller - reading agent; a non-agent caller sees only unowned activities. + * @returns fresh snapshots. + */ +abstract list(caller?: Agent): ActivitySnapshot[] + +/** + * Return a fresh snapshot. Throws for an unknown or foreign activity. + * @param id - activity to look up. + * @param caller - reading agent checked against the owner. + * @returns a fresh snapshot. + */ +abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot + +/** + * Read retained output from an absolute byte offset without consuming it. + * Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Throws for an unknown or foreign activity or a negative or non-integer + * offset. + * @param id - activity to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ +abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead + +/** + * Register an effect-scoped observer of visible-set changes: opening, + * detail updates, settlement, owner-disposal removal, and the emptying that + * service disposal commits — so an observer re-reads rather than + * accumulating deltas. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned activity changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ +abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void + +/** + * Register an effect-scoped observer of stream advancement — one signal per + * committed append and one at settlement, carrying only the activity id. + * A consumer schedules a {@link read} from its own cursor; the registry + * never pushes payloads. Listeners are contained and never awaited. + * @param listener - receives the id whose stream advanced. + * @returns disposer that unregisters the listener. + */ +abstract onOutput(listener: ActivityOutputListener): () => void +``` + +Types: [Agent](core.md) + +Source: [`packages/activity/activity/src/index.ts`](../../packages/activity/activity/src/index.ts) + + + +### `ctx.activityController` — `ActivityController` + +Host service backing the generated `ctx.remote.activity` namespace. + +```ts cordis-catalog +/** + * Stream a complete roster baseline followed by whole-bucket replacements. + * @param signal - generation cancellation. + * @returns baseline followed by per-owner roster replacement frames. + */ +@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable + +/** + * Stream one activity's retained output from an absolute byte offset, then + * its terminal status once settled and drained. Non-consuming: the + * model-facing cursors never observe these reads. + * @param request - target activity and optional resume offset. + * @param signal - generation cancellation. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/activity-controller/src/index.ts`](../../packages/api/activity-controller/src/index.ts) + diff --git a/docs/subsystems/activity.zh.md b/docs/subsystems/activity.zh.md new file mode 100644 index 0000000000..1fc79e9d59 --- /dev/null +++ b/docs/subsystems/activity.zh.md @@ -0,0 +1,141 @@ +# 实时活动观察 + +[English](activity.md) | 中文 + +流式输出生产者、`ctx.activities` 与观察消费者共享的类型。设计归[观察 seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)所有;本页记录 [`packages/activity/activity/src/types.ts`](../../packages/activity/activity/src/types.ts) 的跨包词汇与 [`packages/api/activity-controller/src/types.ts`](../../packages/api/activity-controller/src/types.ts) 的 wire 帧。 + +**activity** 是一个可观察的长时工作单元:一条 append-only 的有界输出流加实时状态。注册表是纯观察面——不启动、不取消任何工作,对模型不可见。生产者保留自己的执行资源与模型面(`ctx.jobs` 消费游标、工具结果);观察者按绝对字节 offset 非消费读取,任意数量的观察者可与模型的单一消费游标共存。 + +## Id、kind 与关联 + +`ActivityId` 是[品牌化 id](core.zh.md#branded-ids),生成为 `-N`;可见性靠 owner 授权而非 id 保密。`ActivityKindMap` 可合并扩展(内置 `bash`;`pwsh` 与 `workflow` 由各自生产者合并)。`ActivityCorrelation` 把一个 activity 关联到发起工作的 `CallId` 与承载其模型控制面的 `JobId`,消费者据此把流挂到已有卡片或行上,不必靠 label 猜。 + +## 生产者面 + +`open(spec)` 返回 `ActivityHandle`:同步的 `append(text, { channel?, gapBefore? })`、`updateDetail(detail)` 与 first-wins 的 `end(outcome)`。offset 按每个 chunk 的 UTF-8 字节长度推进;`gapBefore` 标记生产者侧丢失,让断点保持可见。`end` 之后——包括 teardown 强制 end——`append` 与 `updateDetail` 记日志后丢弃而不抛错,生产者的收尾 flush 不会砸掉自己的 teardown。 + +`pumpActivityOutput(handle, sources, { pollMs, done })` 适配 pull 型底座:把每个非消费 offset 读取器(subprocess 的 `readFrom(fromByte)` 家族,经 `ShellProcess.observed` 复出)按有界节奏复制进句柄,并在 `done` 结算后再排干一次。push 天然的生产者(workflow 工具的运行镜像)直接 append。 + +## 观察 + +`read(id, from, caller?)` 返回与 `[from, total)` 重叠的保留 chunk 及续读 offset `next`,绝不消费。offset 跨淘汰稳定:快照上的 `outputEarliest` 指明最老保留字节,低于它的读取得到 `lossy: true` 而非错误。保留量是 provider 策略([`dsh-activity-local`](../../packages/activity/activity-local/README.zh.md):存活 `retainBytes`,结算时裁到 `settledRetainBytes`)。 + +两个 effect 范围的监听族按 jobs 注册表条款做 owner 相对投递:`onActivitiesChanged` 在每次可见集变化(开启、detail、结算、移除)时触发,消费者整读行;`onOutput` 发出流推进信号(append 或结算),只带 id,各消费者按自己的游标读取。 + +## Wire 传输 + +[`dsh-api-activity-controller`](../../packages/api/activity-controller/README.zh.md) 以两条 Remote 流把观察面暴露给浏览器:`activity.control`(baseline + 整桶 roster 替换帧)与 `activity.observe`(一帧 `opened` 锚点、合并的 `output` 帧、同流的终态 `status`,之后本代关闭)。输出帧是瞬态的——持久历史仍在生产者的工具结果里,与 [web job display 决策](../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md)的要求一致。[`dsh-client-ui-activity`](../../packages/client/ui-activity/README.zh.md) 在会话头部渲染 roster,只在面板展开时打开观察流。 + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.activities` — `ActivityRegistry` (abstract seam) + +Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Implementations must honor these semantics: + +- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. +- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource. +- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap. +- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition's own carrier — sees every owner, while one registered under an agent composition's scope sees exactly the agents composed under it. Every listener is contained. + +```ts cordis-catalog +/** + * Validate the spec, attach owner cleanup, and atomically register one + * activity. A rejection leaves no id; after return the producer owns the + * handle and the visible set has changed. + * @param spec - activity identity, owner, and correlation. + * @returns the producer face of the registered activity. + */ +abstract open(spec: ActivityOpen): ActivityHandle + +/** + * List caller-owned and unowned activities in registration order without + * exposing another session's labels or output. + * @param caller - reading agent; a non-agent caller sees only unowned activities. + * @returns fresh snapshots. + */ +abstract list(caller?: Agent): ActivitySnapshot[] + +/** + * Return a fresh snapshot. Throws for an unknown or foreign activity. + * @param id - activity to look up. + * @param caller - reading agent checked against the owner. + * @returns a fresh snapshot. + */ +abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot + +/** + * Read retained output from an absolute byte offset without consuming it. + * Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Throws for an unknown or foreign activity or a negative or non-integer + * offset. + * @param id - activity to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ +abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead + +/** + * Register an effect-scoped observer of visible-set changes: opening, + * detail updates, settlement, owner-disposal removal, and the emptying that + * service disposal commits — so an observer re-reads rather than + * accumulating deltas. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned activity changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ +abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void + +/** + * Register an effect-scoped observer of stream advancement — one signal per + * committed append and one at settlement, carrying only the activity id. + * A consumer schedules a {@link read} from its own cursor; the registry + * never pushes payloads. Listeners are contained and never awaited. + * @param listener - receives the id whose stream advanced. + * @returns disposer that unregisters the listener. + */ +abstract onOutput(listener: ActivityOutputListener): () => void +``` + +Types: [Agent](core.zh.md) + +Source: [`packages/activity/activity/src/index.ts`](../../packages/activity/activity/src/index.ts) + + + +### `ctx.activityController` — `ActivityController` + +Host service backing the generated `ctx.remote.activity` namespace. + +```ts cordis-catalog +/** + * Stream a complete roster baseline followed by whole-bucket replacements. + * @param signal - generation cancellation. + * @returns baseline followed by per-owner roster replacement frames. + */ +@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable + +/** + * Stream one activity's retained output from an absolute byte offset, then + * its terminal status once settled and drained. Non-consuming: the + * model-facing cursors never observe these reads. + * @param request - target activity and optional resume offset. + * @param signal - generation cancellation. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/activity-controller/src/index.ts`](../../packages/api/activity-controller/src/index.ts) + diff --git a/docs/subsystems/shell.i18n.yaml b/docs/subsystems/shell.i18n.yaml index 1515b4ed7b..4a80f2cea2 100644 --- a/docs/subsystems/shell.i18n.yaml +++ b/docs/subsystems/shell.i18n.yaml @@ -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 docs/subsystems/shell.md -shell.md: 554adcfb1b37a5e2fe787a78dbb61a1206f80cf9 -shell.zh.md: f15b57cc0050bb8f38d2652ddb9ad0e250568663 +shell.md: 8635290b98a19403cc1839b8262831cb99995fd5 +shell.zh.md: ad8fa3254444e43501bbba09964bcfbd95d56bf9 diff --git a/docs/subsystems/shell.md b/docs/subsystems/shell.md index 554adcfb1b..8635290b98 100644 --- a/docs/subsystems/shell.md +++ b/docs/subsystems/shell.md @@ -192,6 +192,13 @@ interface ShellProcess { * full-stream spill files when available. */ readOutput(): ShellProcessRead + /** + * Non-consuming offset readers over the same captured streams the consuming + * {@link readOutput} cursor drains. Independent observers read here at their + * own offsets without stealing bytes from `readOutput`. Absent when the + * backend cannot expose offset reads; observers then see no stream. + */ + observed?: ShellObservedStreams /** * Kill the process group. Returns false when it had already finished * (no-op); idempotent. diff --git a/docs/subsystems/shell.zh.md b/docs/subsystems/shell.zh.md index f15b57cc00..ad8fa32544 100644 --- a/docs/subsystems/shell.zh.md +++ b/docs/subsystems/shell.zh.md @@ -192,6 +192,13 @@ interface ShellProcess { * full-stream spill files when available. */ readOutput(): ShellProcessRead + /** + * Non-consuming offset readers over the same captured streams the consuming + * {@link readOutput} cursor drains. Independent observers read here at their + * own offsets without stealing bytes from `readOutput`. Absent when the + * backend cannot expose offset reads; observers then see no stream. + */ + observed?: ShellObservedStreams /** * Kill the process group. Returns false when it had already finished * (no-op); idempotent. diff --git a/knip.json b/knip.json index e8dc6139ee..b6ab568c31 100644 --- a/knip.json +++ b/knip.json @@ -117,6 +117,11 @@ "tests/**/*.ts" ] }, + "packages/api/activity-controller": { + "ignoreDependencies": [ + "zod" + ] + }, "packages/api/workspace-controller": { "ignoreDependencies": [ "zod" diff --git a/packages/activity/README.i18n.yaml b/packages/activity/README.i18n.yaml new file mode 100644 index 0000000000..4044dfcfec --- /dev/null +++ b/packages/activity/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/activity/README.md +README.md: 1988ca73b4a71095d42f85086d4958eb91bad928 +README.zh.md: b75c7bfcfda6aa829d59d1d46439144bdfaa80e6 diff --git a/packages/activity/README.md b/packages/activity/README.md new file mode 100644 index 0000000000..1988ca73b4 --- /dev/null +++ b/packages/activity/README.md @@ -0,0 +1,14 @@ +# activity/ — streaming-output observation family + +English | [中文](README.zh.md) + +This family gives every long-running producer one non-consuming observation plane: an activity is an append-only bounded output stream plus live status, readable by any number of independent observers at absolute byte offsets while the model-facing paths (`ctx.jobs` cursors, tool results) stay untouched. + +| Package | Role | ctx key | +|---|---|---| +| [`activity/`](activity/README.md) | Defines the observation registry and vocabulary | `ctx.activities` | +| [`activity-local/`](activity-local/README.md) | Implements the in-memory ring-buffer registry | registers on `ctx.activities` | + +The Web transport and client consumers live in [`dsh-api-activity-controller`](../api/activity-controller/README.md) and [`dsh-client-ui-activity`](../client/ui-activity/README.md); background bash/pwsh and the workflow tool are the shipped producers. + +The subsystem reference — offsets, retention, listener delivery — is [docs/subsystems/activity.md](../../docs/subsystems/activity.md); design in the [activity observation seam](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) Agent Note. diff --git a/packages/activity/README.zh.md b/packages/activity/README.zh.md new file mode 100644 index 0000000000..b75c7bfcfd --- /dev/null +++ b/packages/activity/README.zh.md @@ -0,0 +1,14 @@ +# activity/ — 流式输出观察能力族 + +[English](README.md) | 中文 + +本能力族为所有长时生产者提供统一的非消费观察面:一个 activity 是一条 append-only 的有界输出流加实时状态,任意数量的独立观察者按绝对字节 offset 读取,而模型侧路径(`ctx.jobs` 游标、工具结果)完全不受影响。 + +| 包 | 角色 | ctx key | +|---|---|---| +| [`activity/`](activity/README.zh.md) | 定义观察注册表与词汇 | `ctx.activities` | +| [`activity-local/`](activity-local/README.zh.md) | 实现内存环形缓冲注册表 | 注册到 `ctx.activities` | + +Web 传输与客户端消费者在 [`dsh-api-activity-controller`](../api/activity-controller/README.zh.md) 与 [`dsh-client-ui-activity`](../client/ui-activity/README.zh.md);后台 bash/pwsh 与 workflow 工具是已接入的生产者。 + +子系统参考——offset、保留策略、监听投递——见 [docs/subsystems/activity.zh.md](../../docs/subsystems/activity.zh.md);设计见 [activity 观察 seam](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md) Agent Note。 diff --git a/packages/activity/activity-local/README.i18n.yaml b/packages/activity/activity-local/README.i18n.yaml new file mode 100644 index 0000000000..adbd77bb80 --- /dev/null +++ b/packages/activity/activity-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/activity/activity-local/README.md +README.md: 511511a6a5164e0ae4be65230e83a2c1ef37797a +README.zh.md: e4bee07b74abf6c75b68245147f3f5b6fd9f3f6c diff --git a/packages/activity/activity-local/README.md b/packages/activity/activity-local/README.md new file mode 100644 index 0000000000..511511a6a5 --- /dev/null +++ b/packages/activity/activity-local/README.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-activity-local + +English | [中文](README.zh.md) + +The in-memory Service Provider for the [`dsh-activity`](../activity/README.md) observation seam: `LocalActivityRegistry` keeps every record and its bounded output ring in process memory and hands out fresh snapshots and chunk copies, never live state. + +## Retention + +Each activity retains a chunk ring whose offsets stay absolute across eviction: dropping the head advances `outputEarliest`, and a single chunk larger than the live cap keeps only its UTF-8-safe tail with a `gapBefore` marker. Settlement trims the ring to the settled cap so a finished activity still shows its tail without holding the live budget. + +| Config | Default | Meaning | +|---|---|---| +| `retainBytes` | `262144` | Live output retention per activity, in UTF-8 bytes. | +| `settledRetainBytes` | `16384` | Retention kept after an activity settles. | + +## Lifecycle + +Records outlive producer fibers. The first activity for an owner attaches one cleanup to the exact `Agent` scope; owner disposal ends still-open records (`killed`, `owner disposed`) and removes them, announcing the removal. Service disposal ends everything, clears the store, and announces each emptied owner. Neither awaits a producer — the registry owns no execution resource. Listeners are layered by registration scope exactly like the jobs registry, so delivery stays owner-relative in one process-wide instance. + +## Model Experience + +None, as the provider stores and serves observation state only; the producers own every model-visible surface. + +#### KV Cache effect + +None; the package never assembles or alters provider requests. + +## Known Limitations and Deferred Work + +- **The unowned bucket has no admission bound** — owned producers are already bounded by the jobs admission policy, but a plugin opening many unowned activities grows the store until service disposal. diff --git a/packages/activity/activity-local/README.zh.md b/packages/activity/activity-local/README.zh.md new file mode 100644 index 0000000000..e4bee07b74 --- /dev/null +++ b/packages/activity/activity-local/README.zh.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-activity-local + +[English](README.md) | 中文 + +[`dsh-activity`](../activity/README.zh.md) 观察 seam 的内存 Service Provider:`LocalActivityRegistry` 把每条记录及其有界输出环保存在进程内存中,对外只给全新快照与 chunk 副本,绝不暴露活状态。 + +## 保留策略 + +每个 activity 保留一个 offset 跨淘汰稳定的 chunk 环:丢弃头部使 `outputEarliest` 前移;单个超过存活上限的 chunk 只保留其 UTF-8 安全的尾部并打上 `gapBefore` 标记。结算时把环裁到 settled 上限,已完成的 activity 仍能展示尾巴而不占存活预算。 + +| 配置 | 默认值 | 含义 | +|---|---|---| +| `retainBytes` | `262144` | 每个 activity 的存活输出保留量(UTF-8 字节)。 | +| `settledRetainBytes` | `16384` | activity 结算后的保留量。 | + +## 生命周期 + +记录的存续期长于生产者 fiber。owner 的第一个 activity 会在 exact `Agent` scope 上挂一个清理;owner 销毁时把未结束的记录终结(`killed`、`owner disposed`)并移除,同时公告移除。服务销毁终结一切、清空存储并逐个公告被清空的 owner。两者都不等待生产者——注册表不拥有任何执行资源。监听按注册 scope 分层,与 jobs 注册表完全一致,一个进程级实例内投递保持 owner 相对。 + +## 模型体验 + +无:provider 只存储与提供观察状态;所有模型可见面都归生产者。 + +#### KV Cache 影响 + +无;本包从不组装或改动 provider 请求。 + +## 已知限制与暂缓事项 + +- **无主桶没有准入上限**——有主生产者已被 jobs 准入策略约束,但插件开出的大量无主 activity 会让存储一直增长到服务销毁。 diff --git a/packages/activity/activity-local/package.json b/packages/activity/activity-local/package.json new file mode 100644 index 0000000000..b06864d251 --- /dev/null +++ b/packages/activity/activity-local/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-activity-local", + "description": "Process-local implementation of the DeepSeek Harness streaming-output observation seam", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/activity/activity-local" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/activity/activity-local/src/index.ts b/packages/activity/activity-local/src/index.ts new file mode 100644 index 0000000000..caabb5499e --- /dev/null +++ b/packages/activity/activity-local/src/index.ts @@ -0,0 +1,438 @@ +/** + * Process-local provider for the streaming-output observation seam + * (`ctx.activities`). It keeps every record and its bounded output ring in + * memory and hands out fresh snapshots and chunk copies, never live state. + * + * Records outlive producer fibers. Owner disposal ends still-open records and + * removes them; service disposal ends and clears everything. Neither awaits a + * producer — the registry owns no execution resource. + * @module @deepseek-ai/dsh-activity-local + */ + +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeLayer } from '@deepseek-ai/dsh-scope' +import { ActivityRegistry, ActivityId } from '@deepseek-ai/dsh-activity' +import type { + ActivityAppendOptions, ActivityChannel, ActivityCorrelation, ActivitiesChangedListener, + ActivityHandle, ActivityKind, ActivityOpen, ActivityOutcome, ActivityOutputChunk, + ActivityOutputListener, ActivityRead, ActivitySnapshot, ActivityStatus, +} from '@deepseek-ai/dsh-activity' + +/** Default live retention per activity, in UTF-8 bytes. */ +const DEFAULT_RETAIN_BYTES = 256 * 1024 + +/** Default retention kept after settlement, in UTF-8 bytes. */ +const DEFAULT_SETTLED_RETAIN_BYTES = 16 * 1024 + +/** Configuration for the process-local observation registry. */ +export interface Config { + /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number +} + +/** The registry's mutable per-activity record (never handed out — see {@link LocalActivityRegistry.snapshot}). */ +interface TrackedActivity { + id: ActivityId + kind: ActivityKind + label: string + /** Exact lifecycle owner; session-id visibility is derived from it. */ + owner: Agent | undefined + correlation: ActivityCorrelation | undefined + status: ActivityStatus + detail: string | undefined + startedAt: number + finishedAt: number | undefined + /** Retained chunks in offset order; offsets stay absolute across eviction. */ + chunks: RingChunk[] + /** Sum of the retained chunks' byte lengths. */ + retainedBytes: number + /** Total UTF-8 bytes ever appended. */ + total: number + /** Offset of the oldest retained byte (equals {@link total} when nothing is retained). */ + earliest: number +} + +/** One retained ring entry; `bytes` caches the chunk's UTF-8 length. */ +interface RingChunk { + at: number + text: string + bytes: number + channel?: ActivityChannel + gapBefore?: true +} + +/** True for the three terminal {@link ActivityStatus} values. */ +function isTerminal(status: ActivityStatus): boolean { + return status === 'completed' || status === 'failed' || status === 'killed' +} + +/** + * The UTF-8-safe tail of `text` no longer than `maxBytes`: the byte cut + * advances past continuation bytes so the surviving text never starts inside + * a code point. + * @param text - the oversized chunk text. + * @param maxBytes - positive byte budget for the surviving tail. + * @returns the surviving tail and its exact byte length. + */ +function utf8Tail(text: string, maxBytes: number): { text: string; bytes: number } { + const raw = Buffer.from(text, 'utf8') + let start = raw.length - maxBytes + // The loop bound proves the index is in range; the assertion only + // discharges noUncheckedIndexedAccess. + while (start < raw.length && ((raw[start] as number) & 0xC0) === 0x80) start += 1 + const tail = raw.subarray(start) + return { text: tail.toString('utf8'), bytes: tail.length } +} + +/** + * One scope's observer contributions. Both tables are anonymous because a + * contribution is identified by its own disposer, never by a name a second + * registrant could shadow. + */ +class ActivityLayer implements ScopeLayer { + readonly changed = new AnonymousEntries() + readonly output = new AnonymousEntries() + + isEmpty(): boolean { + return this.changed.isEmpty() && this.output.isEmpty() + } +} + +/** + * The in-memory `activities` registry. See the Service Definition contract in + * `@deepseek-ai/dsh-activity` for the observation, retention, and lifecycle + * semantics this implementation honors. + */ +export class LocalActivityRegistry extends ActivityRegistry { + static Config: z = z.object({ + retainBytes: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_RETAIN_BYTES), + settledRetainBytes: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_SETTLED_RETAIN_BYTES), + }) + + /** Schemastery-defaulted live retention cap. */ + private readonly retainBytes: number + /** Schemastery-defaulted settled retention cap. */ + private readonly settledRetainBytes: number + private store = new Map() + private counters = new Map() + /** + * Observers layered by the scope that registered them, in the jobs-registry + * shape: a contribution files into its registering context's scope, and a + * dispatch unions the global layer with the subject owner's scope chain, so + * delivery stays owner-relative in one process-wide registry. Nothing + * derives a cache from a layer, so change notification is a no-op. + */ + private readonly layers = new ScopedLayers(() => new ActivityLayer(), () => {}) + /** Owner agents with attached scope cleanup, mapped to the exact disposer. */ + private ownerCleanups = new Map Promise | void>() + /** Service context used by contained listener reporting and teardown. */ + private readonly selfCtx: Context + + constructor(ctx: Context, config: Config) { + super(ctx) + // Schemastery validates and fills the defaults before constructing the service. + this.retainBytes = (config as Required).retainBytes + this.settledRetainBytes = (config as Required).settledRetainBytes + this.selfCtx = ctx + ctx.effect(() => () => { this.disposeAll() }, 'activities teardown') + } + + open(spec: ActivityOpen): ActivityHandle { + if (spec.kind.length === 0) throw new Error('invalid activity kind: expected a non-empty string') + if (spec.label.length === 0) throw new Error('invalid activity label: expected a non-empty string') + if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) + + const count = (this.counters.get(spec.kind) ?? 0) + 1 + this.counters.set(spec.kind, count) + const id = ActivityId(`${spec.kind}-${count}`) + const record: TrackedActivity = { + id, + kind: spec.kind, + label: spec.label, + owner: spec.owner, + correlation: spec.correlation === undefined ? undefined : { ...spec.correlation }, + status: 'running', + detail: undefined, + startedAt: Date.now(), + finishedAt: undefined, + chunks: [], + retainedBytes: 0, + total: 0, + earliest: 0, + } + this.store.set(id, record) + // Registration is complete and cannot fail from here, so the visible set + // has genuinely changed. + this.notifyChanged(record.owner) + return { + id, + append: (text, options) => { this.append(record, text, options) }, + updateDetail: (detail) => { this.updateDetail(record, detail) }, + end: (outcome) => { this.settle(record, outcome) }, + } + } + + list(caller?: Agent): ActivitySnapshot[] { + const session = caller?.id + return [...this.store.values()] + .filter(record => record.owner === undefined || record.owner.id === session) + .map(record => this.snapshot(record)) + } + + get(id: ActivityId, caller?: Agent): ActivitySnapshot { + const record = this.expect(id) + this.assertAccess(record, caller) + return this.snapshot(record) + } + + read(id: ActivityId, from: number, caller?: Agent): ActivityRead { + const record = this.expect(id) + this.assertAccess(record, caller) + if (!Number.isSafeInteger(from) || from < 0) { + throw new Error(`invalid read offset: expected a non-negative safe integer, got ${JSON.stringify(from)}`) + } + const chunks: ActivityOutputChunk[] = [] + for (const chunk of record.chunks) { + if (chunk.at + chunk.bytes <= from) continue + chunks.push({ + at: chunk.at, + text: chunk.text, + ...chunk.channel !== undefined ? { channel: chunk.channel } : {}, + ...chunk.gapBefore !== undefined ? { gapBefore: chunk.gapBefore } : {}, + }) + } + return { chunks, next: record.total, lossy: from < record.earliest } + } + + onActivitiesChanged(listener: ActivitiesChangedListener): () => void { + return this.layers.effect( + this.ctx, + layer => layer.changed.append(listener), + { label: 'activities.onActivitiesChanged()' }, + ) + } + + onOutput(listener: ActivityOutputListener): () => void { + return this.layers.effect( + this.ctx, + layer => layer.output.append(listener), + { label: 'activities.onOutput()' }, + ) + } + + /** Append one chunk to a live record; log and drop on an ended one. */ + private append(record: TrackedActivity, text: string, options?: ActivityAppendOptions): void { + if (isTerminal(record.status)) { + this.selfCtx.logger.warn(`activities: append to ended activity ${record.id} dropped`) + return + } + if (text.length === 0) return + const bytes = Buffer.byteLength(text, 'utf8') + record.chunks.push({ + at: record.total, + text, + bytes, + ...options?.channel !== undefined ? { channel: options.channel } : {}, + ...options?.gapBefore !== undefined ? { gapBefore: options.gapBefore } : {}, + }) + record.total += bytes + record.retainedBytes += bytes + this.trim(record, this.retainBytes) + this.notifyOutput(record) + } + + /** Replace a live record's detail line; log and drop on an ended one. */ + private updateDetail(record: TrackedActivity, detail: string): void { + if (isTerminal(record.status)) { + this.selfCtx.logger.warn(`activities: detail update on ended activity ${record.id} dropped`) + return + } + record.detail = detail + this.notifyChanged(record.owner) + } + + /** + * Record the first terminal outcome, trim retention to the settled cap, and + * announce the change and the stream end. First-wins: a later call — the + * producer's own report after a teardown force-end, or a duplicate — is + * dropped silently. + */ + private settle(record: TrackedActivity, outcome: ActivityOutcome): void { + if (isTerminal(record.status)) return + record.status = outcome.status + if (outcome.detail !== undefined) record.detail = outcome.detail + record.finishedAt = Date.now() + this.trim(record, this.settledRetainBytes) + this.notifyChanged(record.owner) + this.notifyOutput(record) + } + + /** + * Drop retained head chunks until the ring fits `cap`; a single oversized + * chunk keeps only its UTF-8-safe tail with a `gapBefore` marker. Offsets + * stay absolute: `earliest` advances over everything dropped. + */ + private trim(record: TrackedActivity, cap: number): void { + while (record.retainedBytes > cap && record.chunks.length > 1) { + const dropped = record.chunks.shift() + /* v8 ignore next -- the length guard proves shift() returns a chunk; the check only satisfies noUncheckedIndexedAccess. */ + if (dropped === undefined) break + record.retainedBytes -= dropped.bytes + } + const single = record.chunks.length === 1 ? record.chunks[0] : undefined + if (single !== undefined && single.bytes > cap) { + const tail = utf8Tail(single.text, cap) + single.at += single.bytes - tail.bytes + single.text = tail.text + single.bytes = tail.bytes + single.gapBefore = true + record.retainedBytes = tail.bytes + } + record.earliest = record.chunks[0]?.at ?? record.total + } + + /** Look up an activity or fail loud. */ + private expect(id: ActivityId): TrackedActivity { + const record = this.store.get(id) + if (record === undefined) throw new Error(`unknown activity ${id}`) + return record + } + + /** + * The visibility fence: an activity with an owner is reachable only by + * callers whose session id matches (`!== undefined` semantics — an unowned + * activity is open, and a no-agent caller can never match an owned one). + */ + private assertAccess(record: TrackedActivity, caller?: Agent): void { + if (record.owner !== undefined && record.owner.id !== caller?.id) { + throw new Error(`activity ${record.id} belongs to another session`) + } + } + + /** Project a fresh read-only snapshot from the mutable record. */ + private snapshot(record: TrackedActivity): ActivitySnapshot { + const ownerSession = record.owner?.id + return { + id: record.id, + kind: record.kind, + label: record.label, + ...ownerSession !== undefined ? { ownerSession } : {}, + ...record.correlation !== undefined ? { correlation: { ...record.correlation } } : {}, + status: record.status, + ...record.detail !== undefined ? { detail: record.detail } : {}, + startedAt: record.startedAt, + ...record.finishedAt !== undefined ? { finishedAt: record.finishedAt } : {}, + outputTotal: record.total, + outputEarliest: record.earliest, + } + } + + /** + * The change observers that own `owner`'s updates: the global layer — a + * host composition's own carrier, which serves every owner — then each + * scoped layer along the owner's chain. + */ + private *changedFor(owner?: Agent): IterableIterator { + yield* this.layers.global.changed.values() + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) yield* layer.changed.values() + } + + /** The output observers that own `owner`'s stream signals, resolved like {@link changedFor}. */ + private *outputFor(owner?: Agent): IterableIterator { + yield* this.layers.global.output.values() + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) yield* layer.output.values() + } + + /** + * Announce that one owner's visible set changed. Each listener is contained + * so an observer cannot break a lifecycle commit that already happened. + */ + private notifyChanged(owner: Agent | undefined): void { + for (const listener of this.changedFor(owner)) { + try { + listener(owner) + } catch (error: unknown) { + this.selfCtx.logger.warn(`activities: onActivitiesChanged listener threw: ${String(error)}`) + } + } + } + + /** Announce that one activity's stream advanced (append or settlement), with per-listener containment. */ + private notifyOutput(record: TrackedActivity): void { + for (const listener of this.outputFor(record.owner)) { + try { + listener(record.id) + } catch (error: unknown) { + this.selfCtx.logger.warn(`activities: onOutput listener threw: ${String(error)}`) + } + } + } + + /** + * Attach one cleanup through the exact owner's scope. This survives + * producer reloads; the retained disposer lets service teardown detach the + * cross-fiber effect. Fails when the agent registry is absent or the owner + * is not its currently registered instance. + */ + private ensureOwnerCleanup(owner: Agent): void { + const ownerId = owner.id + const agents = this.selfCtx.get('agents') + if (agents === undefined) { + throw new Error('activity ownership requires the agent registry (load @deepseek-ai/dsh-agent)') + } + if (agents.get(ownerId) !== owner) { + throw new Error(`agent "${ownerId}" is not the registered agent instance (activity owner must be live)`) + } + if (this.ownerCleanups.has(owner)) return + // Record only after attach succeeds; a disposing scope rejects new effects. + const detach = owner.ctx.effect(() => () => { + this.ownerCleanups.delete(owner) + this.disposeOwned(owner) + }, 'activities.ownerCleanup()') + this.ownerCleanups.set(owner, detach) + } + + /** End still-open records and drop every activity owned by one exact agent lifecycle. */ + private disposeOwned(owner: Agent): void { + const owned = [...this.store.values()].filter(record => record.owner === owner) + for (const record of owned) { + if (!isTerminal(record.status)) this.settle(record, { status: 'killed', detail: 'owner disposed' }) + this.store.delete(record.id) + } + // Removal is the one visible-set change no per-activity record carries, so + // it must be announced here or an observer keeps the dropped rows forever. + if (owned.length > 0) this.notifyChanged(owner) + } + + /** End every open record, clear the store, and announce each emptied owner. */ + private disposeAll(): void { + const all = [...this.store.values()] + for (const record of all) { + if (!isTerminal(record.status)) this.settle(record, { status: 'killed', detail: 'activity registry disposed' }) + } + const emptied = new Set(all.map(record => record.owner)) + this.store.clear() + for (const owner of emptied) this.notifyChanged(owner) + // Detach cross-fiber owner effects after the shared store is quiescent. + const ownerCleanups = [...this.ownerCleanups.values()] + this.ownerCleanups.clear() + for (const cleanup of ownerCleanups) void cleanup() + } +} + +export default LocalActivityRegistry diff --git a/packages/activity/activity-local/src/invariant.ts b/packages/activity/activity-local/src/invariant.ts new file mode 100644 index 0000000000..7e15b7db09 --- /dev/null +++ b/packages/activity/activity-local/src/invariant.ts @@ -0,0 +1,33 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-activity-local`. + * @module @deepseek-ai/dsh-activity-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-activity-local' + +/** Cordis companion plugin name. */ +export const name = 'activity-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: `@deepseek-ai/dsh-activity/invariant` owns per-snapshot identity, status, + * timestamp, and offset checks on every visible-set change. This provider's remaining behavior — + * ring retention against private byte caps and pull-only chunk reads — has no authoritative event + * stream to observe: a read happens on a caller's schedule, and repeating the retention arithmetic + * against private configuration would duplicate the implementation rather than check a relation. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/activity/activity-local/tests/activity.spec.ts b/packages/activity/activity-local/tests/activity.spec.ts new file mode 100644 index 0000000000..557046d617 --- /dev/null +++ b/packages/activity/activity-local/tests/activity.spec.ts @@ -0,0 +1,413 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createScope } from '@deepseek-ai/dsh-scope' +import { ActivityId } from '@deepseek-ai/dsh-activity' +import type { ActivityHandle, ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import LocalActivityRegistry, { type Config as ProcessConfig } from '@deepseek-ai/dsh-activity-local' + +declare module '@deepseek-ai/dsh-activity' { + interface ActivityKindMap { + workflow: 'workflow' + } +} + +const agentScopeDisposers = new WeakMap Promise>() + +/** + * A minimal live agent whose ctx carries its own scope on a disposable plugin + * fiber, mirroring the harness convention that a live agent keys its scope. + */ +function stubAgent(ctx: Context, rawId: string): Agent { + const id = SessionId(rawId) + const scopeFiber = ctx.plugin(() => {}) + const agentCtx = createScope(scopeFiber.ctx, {}).ctx + const session = Session.create(id) + const agent = { + id, + options: {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle' as const, + ctx: agentCtx, + send: () => {}, + followup: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), + inject: () => {}, + cancel() {}, + runMaintenance: (job: (signal: AbortSignal) => Promise) => job(new AbortController().signal), + whenIdle() { return Promise.resolve() }, + } + agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() }) + return agent +} + +async function disposeAgentScope(agent: Agent): Promise { + const dispose = agentScopeDisposers.get(agent) + if (dispose === undefined) throw new Error(`missing test scope for agent "${agent.id}"`) + await dispose() +} + +async function harness(config: ProcessConfig = {}) { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalActivityRegistry, config) + return ctx +} + +/** Concatenated retained text of one process, as an observer from `from` sees it. */ +function textFrom(ctx: Context, id: ActivityId, from = 0): string { + return ctx.activities.read(id, from).chunks.map(chunk => chunk.text).join('') +} + +describe('LocalActivityRegistry.open', () => { + it('mints per-kind ordinal ids and announces the new visible row', async () => { + const ctx = await harness() + const changes: (Agent | undefined)[] = [] + ctx.activities.onActivitiesChanged((owner) => { changes.push(owner) }) + const first = ctx.activities.open({ kind: 'bash', label: 'sleep 60' }) + const second = ctx.activities.open({ kind: 'workflow', label: 'run docs' }) + const third = ctx.activities.open({ kind: 'bash', label: 'build' }) + expect([first.id, second.id, third.id]).toEqual(['bash-1', 'workflow-1', 'bash-2']) + expect(changes).toEqual([undefined, undefined, undefined]) + expect(ctx.activities.list().map(snapshot => snapshot.id)).toEqual(['bash-1', 'workflow-1', 'bash-2']) + }) + + it('rejects an empty kind or label without minting an id', async () => { + const ctx = await harness() + expect(() => ctx.activities.open({ kind: '' as never, label: 'x' })).toThrow(/invalid activity kind/) + expect(() => ctx.activities.open({ kind: 'bash', label: '' })).toThrow(/invalid activity label/) + expect(ctx.activities.list()).toEqual([]) + }) + + it('rejects an owned open when no agent registry is loaded', async () => { + const bare = new Context() + await bare.plugin(LocalActivityRegistry) + const ghost = { id: SessionId('ghost'), ctx: bare } as unknown as Agent + expect(() => bare.activities.open({ kind: 'bash', label: 'x', owner: ghost })) + .toThrow(/activity ownership requires the agent registry/) + }) + + it('rejects an owner that is not the registered agent instance', async () => { + const ctx = await harness() + const ghost = stubAgent(ctx, 'ghost') + expect(() => ctx.activities.open({ kind: 'bash', label: 'x', owner: ghost })) + .toThrow(/not the registered agent instance/) + }) + + it('carries owner session and correlation into snapshots', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'owner') + ctx.agents.register(owner) + const handle = ctx.activities.open({ + kind: 'bash', + label: 'npm test', + owner, + correlation: { jobId: 'bash-9' as never, callId: 'call-3' as never }, + }) + const snapshot = ctx.activities.get(handle.id, owner) + expect(snapshot.ownerSession).toBe(owner.id) + expect(snapshot.correlation).toEqual({ jobId: 'bash-9', callId: 'call-3' }) + expect(snapshot.status).toBe('running') + }) +}) + +describe('LocalActivityRegistry output', () => { + it('appends advance absolute offsets and reads are non-consuming', async () => { + const ctx = await harness() + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('hello ') + handle.append('world', { channel: 'stderr' }) + const first = ctx.activities.read(handle.id, 0) + expect(first.lossy).toBe(false) + expect(first.next).toBe(11) + expect(first.chunks).toEqual([ + { at: 0, text: 'hello ' }, + { at: 6, text: 'world', channel: 'stderr' }, + ]) + // A second identical read proves nothing was consumed. + expect(ctx.activities.read(handle.id, 0)).toEqual(first) + // Resuming from `next` yields nothing until more output arrives. + expect(ctx.activities.read(handle.id, first.next).chunks).toEqual([]) + handle.append('!') + expect(ctx.activities.read(handle.id, first.next).chunks).toEqual([{ at: 11, text: '!' }]) + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.outputTotal).toBe(12) + expect(snapshot.outputEarliest).toBe(0) + }) + + it('drops an empty append without waking observers', async () => { + const ctx = await harness() + const signals: ActivityId[] = [] + ctx.activities.onOutput((id) => { signals.push(id) }) + const handle = ctx.activities.open({ kind: 'bash', label: 'quiet' }) + handle.append('') + expect(signals).toEqual([]) + expect(ctx.activities.get(handle.id).outputTotal).toBe(0) + }) + + it('rejects a negative or fractional read offset', async () => { + const ctx = await harness() + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + expect(() => ctx.activities.read(handle.id, -1)).toThrow(/invalid read offset/) + expect(() => ctx.activities.read(handle.id, 0.5)).toThrow(/invalid read offset/) + }) + + it('evicts whole head chunks past the live cap and flags stale readers lossy', async () => { + const ctx = await harness({ retainBytes: 8 }) + const handle = ctx.activities.open({ kind: 'bash', label: 'spam' }) + handle.append('aaaa') + handle.append('bbbb') + handle.append('cccc') + const read = ctx.activities.read(handle.id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 4, text: 'bbbb' }, { at: 8, text: 'cccc' }]) + expect(read.next).toBe(12) + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.outputEarliest).toBe(4) + expect(snapshot.outputTotal).toBe(12) + // A reader at the retained boundary is not lossy. + expect(ctx.activities.read(handle.id, 4).lossy).toBe(false) + }) + + it('keeps only the UTF-8-safe tail of a single oversized chunk', async () => { + const ctx = await harness({ retainBytes: 5 }) + const handle = ctx.activities.open({ kind: 'bash', label: 'wide' }) + // '你好' is six UTF-8 bytes; a five-byte budget must not split the second code point. + handle.append('你好') + const read = ctx.activities.read(handle.id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 3, text: '好', gapBefore: true }]) + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.outputEarliest).toBe(3) + expect(snapshot.outputTotal).toBe(6) + }) + + it('returns the whole overlapped chunk for a foreign mid-chunk offset', async () => { + const ctx = await harness() + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('abcdef') + const read = ctx.activities.read(handle.id, 3) + expect(read.chunks).toEqual([{ at: 0, text: 'abcdef' }]) + expect(read.lossy).toBe(false) + }) + + it('preserves a producer-reported gap marker through reads', async () => { + const ctx = await harness() + const handle = ctx.activities.open({ kind: 'bash', label: 'lossy source' }) + handle.append('tail after drop', { gapBefore: true, channel: 'stdout' }) + expect(ctx.activities.read(handle.id, 0).chunks).toEqual([ + { at: 0, text: 'tail after drop', channel: 'stdout', gapBefore: true }, + ]) + }) +}) + +describe('LocalActivityRegistry settlement', () => { + it('records the first terminal outcome and trims retention to the settled cap', async () => { + const ctx = await harness({ retainBytes: 1024, settledRetainBytes: 4 }) + const handle = ctx.activities.open({ kind: 'bash', label: 'build' }) + handle.append('abcdefgh') + handle.end({ status: 'completed', detail: 'exit code: 0' }) + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.status).toBe('completed') + expect(snapshot.detail).toBe('exit code: 0') + expect(snapshot.finishedAt).toBeDefined() + const read = ctx.activities.read(handle.id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 4, text: 'efgh', gapBefore: true }]) + }) + + it('is first-wins: a later end and post-end writes are dropped without throwing', async () => { + const ctx = await harness() + const warn = vi.fn() + ctx.logger.warn = warn as never + const handle = ctx.activities.open({ kind: 'bash', label: 'build' }) + handle.end({ status: 'killed', detail: 'owner disposed' }) + handle.end({ status: 'completed' }) + handle.append('late') + handle.updateDetail('late detail') + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.status).toBe('killed') + expect(snapshot.detail).toBe('owner disposed') + expect(snapshot.outputTotal).toBe(0) + expect(warn.mock.calls.map(call => String(call[0]))).toEqual([ + expect.stringContaining('append to ended activity'), + expect.stringContaining('detail update on ended activity'), + ]) + }) + + it('signals observers for appends and once more at settlement', async () => { + const ctx = await harness() + const signals: ActivityId[] = [] + ctx.activities.onOutput((id) => { signals.push(id) }) + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('a') + handle.append('b') + handle.end({ status: 'completed' }) + expect(signals).toEqual([handle.id, handle.id, handle.id]) + }) + + it('updateDetail changes the visible row without touching output offsets', async () => { + const ctx = await harness() + const changes: (Agent | undefined)[] = [] + ctx.activities.onActivitiesChanged((owner) => { changes.push(owner) }) + const handle = ctx.activities.open({ kind: 'workflow', label: 'docs run' }) + handle.updateDetail('3/10 agents done') + expect(ctx.activities.get(handle.id).detail).toBe('3/10 agents done') + expect(ctx.activities.get(handle.id).outputTotal).toBe(0) + expect(changes).toHaveLength(2) + }) +}) + +describe('LocalActivityRegistry visibility and delivery', () => { + it('fences owned processes by session id and keeps unowned ones open', async () => { + const ctx = await harness() + const alice = stubAgent(ctx, 'alice') + const bob = stubAgent(ctx, 'bob') + ctx.agents.register(alice) + ctx.agents.register(bob) + const owned = ctx.activities.open({ kind: 'bash', label: 'alice job', owner: alice }) + const open = ctx.activities.open({ kind: 'bash', label: 'shared' }) + owned.append('secret') + expect(ctx.activities.list(alice).map(snapshot => snapshot.id)).toEqual([owned.id, open.id]) + expect(ctx.activities.list(bob).map(snapshot => snapshot.id)).toEqual([open.id]) + expect(ctx.activities.list().map(snapshot => snapshot.id)).toEqual([open.id]) + expect(() => ctx.activities.get(owned.id, bob)).toThrow(/belongs to another session/) + expect(() => ctx.activities.read(owned.id, 0)).toThrow(/belongs to another session/) + expect(textFrom(ctx, open.id)).toBe('') + expect(ctx.activities.read(owned.id, 0, alice).chunks[0]?.text).toBe('secret') + }) + + it('delivers scoped listeners only their own agents and global listeners everything', async () => { + const ctx = await harness() + const alice = stubAgent(ctx, 'alice') + const bob = stubAgent(ctx, 'bob') + ctx.agents.register(alice) + ctx.agents.register(bob) + const globalOwners: (string | undefined)[] = [] + ctx.activities.onActivitiesChanged((owner) => { globalOwners.push(owner?.id) }) + const scopedOwners: (string | undefined)[] = [] + await alice.ctx.plugin({ + inject: ['activities'], + apply(scopedCtx: Context) { + scopedCtx.activities.onActivitiesChanged((owner) => { scopedOwners.push(owner?.id) }) + }, + }) + const scopedOutputs: string[] = [] + const scopedFiber = await alice.ctx.plugin({ + inject: ['activities'], + apply(scopedCtx: Context) { + scopedCtx.activities.onOutput((id) => { scopedOutputs.push(String(id)) }) + }, + }) + const aliceHandle = ctx.activities.open({ kind: 'bash', label: 'alice work', owner: alice }) + aliceHandle.append('scoped delivery probe') + expect(scopedOutputs).toEqual([String(aliceHandle.id)]) + ctx.activities.open({ kind: 'bash', label: 'bob work', owner: bob }) + ctx.activities.open({ kind: 'bash', label: 'unowned' }) + expect(globalOwners).toEqual(['alice', 'bob', undefined]) + expect(scopedOwners).toEqual(['alice']) + // Disposing one scoped registrant probes layer reclamation; the sibling + // scoped changed-listener keeps the layer alive and keeps delivering. + await scopedFiber.dispose() + ctx.activities.open({ kind: 'bash', label: 'alice later', owner: alice }) + expect(scopedOwners).toEqual(['alice', 'alice']) + + // A scope whose only contribution goes empties on disposal and is reclaimed. + const bobFiber = await bob.ctx.plugin({ + inject: ['activities'], + apply(scopedCtx: Context) { + scopedCtx.activities.onOutput(() => {}) + }, + }) + await bobFiber.dispose() + }) + + it('contains throwing listeners without breaking the commit', async () => { + const ctx = await harness() + const warn = vi.fn() + ctx.logger.warn = warn as never + ctx.activities.onActivitiesChanged(() => { throw new Error('changed boom') }) + ctx.activities.onOutput(() => { throw new Error('output boom') }) + const seen: (Agent | undefined)[] = [] + ctx.activities.onActivitiesChanged((owner) => { seen.push(owner) }) + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('x') + expect(seen).toHaveLength(1) + expect(ctx.activities.get(handle.id).outputTotal).toBe(1) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('onActivitiesChanged listener threw')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('onOutput listener threw')) + }) + + it('unregisters listeners with their registering fiber', async () => { + const ctx = await harness() + const signals: ActivityId[] = [] + const fiber = ctx.plugin({ + inject: ['activities'], + apply(pluginCtx: Context) { + pluginCtx.activities.onOutput((id) => { signals.push(id) }) + }, + }) + await fiber + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('a') + expect(signals).toHaveLength(1) + await fiber.dispose() + handle.append('b') + expect(signals).toHaveLength(1) + }) +}) + +describe('LocalActivityRegistry lifecycle', () => { + it('owner disposal ends open records, removes rows, and announces the removal', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'owner') + ctx.agents.register(owner) + const handle = ctx.activities.open({ kind: 'bash', label: 'sleep', owner }) + handle.append('partial') + const settledEarly = ctx.activities.open({ kind: 'bash', label: 'done already', owner }) + settledEarly.end({ status: 'completed' }) + const changes: (string | undefined)[] = [] + ctx.activities.onActivitiesChanged((changed) => { changes.push(changed?.id) }) + await disposeAgentScope(owner) + expect(ctx.activities.list(owner)).toEqual([]) + expect(() => ctx.activities.get(handle.id, owner)).toThrow(/unknown activity/) + // The forced end and the removal both announced under the owner. + expect(changes.length).toBeGreaterThanOrEqual(2) + expect(new Set(changes)).toEqual(new Set(['owner'])) + // The producer's own late report is dropped silently, not thrown. + expect(() => { handle.end({ status: 'completed' }) }).not.toThrow() + }) + + it('service disposal ends everything, clears the store, and announces emptied owners', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const registryFiber = ctx.plugin(LocalActivityRegistry, {}) + await registryFiber + const owner = stubAgent(ctx, 'owner') + ctx.agents.register(owner) + const processes = ctx.activities + const owned = processes.open({ kind: 'bash', label: 'owned', owner }) + processes.open({ kind: 'bash', label: 'unowned' }) + const preSettled = processes.open({ kind: 'bash', label: 'already settled' }) + preSettled.end({ status: 'completed' }) + const changes: (string | undefined)[] = [] + ctx.activities.onActivitiesChanged((changed) => { changes.push(changed?.id) }) + await registryFiber.dispose() + expect(changes).toEqual(expect.arrayContaining(['owner', undefined])) + expect(() => { owned.append('late') }).not.toThrow() + }) +}) + +describe('LocalActivityRegistry snapshots', () => { + it('hands out fresh snapshots, never live registry state', async () => { + const ctx = await harness() + const handle: ActivityHandle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + const before: ActivitySnapshot = ctx.activities.get(handle.id) + handle.append('grow') + expect(before.outputTotal).toBe(0) + expect(ctx.activities.get(handle.id).outputTotal).toBe(4) + }) +}) diff --git a/packages/activity/activity-local/tests/loader-composition.spec.ts b/packages/activity/activity-local/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..4b341f7e16 --- /dev/null +++ b/packages/activity/activity-local/tests/loader-composition.spec.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +describe('process-local through a real Loader composition', () => { + it('applies the provider-owned retention config from a Cordis row', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-activity-local-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-activity-local'", + ' config:', + ' retainBytes: 8', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (specifier === '@deepseek-ai/dsh-activity-local') return LocalActivityRegistry + throw new Error(`unexpected Loader import: ${specifier}`) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + + expect(context.activities).toBeInstanceOf(LocalActivityRegistry) + const handle = context.activities.open({ kind: 'bash', label: 'loader retention probe' }) + handle.append('aaaa') + handle.append('bbbb') + handle.append('cccc') + const read = context.activities.read(handle.id, 0) + // retainBytes: 8 from the Cordis row dropped the oldest chunk. + expect(read.lossy).toBe(true) + expect(read.chunks.map(chunk => chunk.text)).toEqual(['bbbb', 'cccc']) + expect(read.next).toBe(12) + }) +}) diff --git a/packages/activity/activity-local/tsconfig.json b/packages/activity/activity-local/tsconfig.json new file mode 100644 index 0000000000..0bddbff854 --- /dev/null +++ b/packages/activity/activity-local/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/scope" + }, + { + "path": "../activity" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/activity/activity/README.i18n.yaml b/packages/activity/activity/README.i18n.yaml new file mode 100644 index 0000000000..e8cb7b1bad --- /dev/null +++ b/packages/activity/activity/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/activity/activity/README.md +README.md: a6d465d4d962b3bee59b56cfed1a4b0b1955c560 +README.zh.md: afe843f40a57103436e4c63799d80b86a9c4557a diff --git a/packages/activity/activity/README.md b/packages/activity/activity/README.md new file mode 100644 index 0000000000..a6d465d4d9 --- /dev/null +++ b/packages/activity/activity/README.md @@ -0,0 +1,32 @@ +# @deepseek-ai/dsh-activity + +English | [中文](README.zh.md) + +The streaming-output observation contract (`ctx.activities`). The abstract `ActivityRegistry` and its vocabulary types give any long-running producer one place to publish live output and status, and any number of independent consumers non-consuming reads over it; the in-memory registry lives in [`dsh-activity-local`](../activity-local/README.md). Producer plugins extend `ActivityKindMap` with their opaque id namespace and may link an activity to its tool call and background job through `ActivityCorrelation`. + +The registry is a pure observation plane: it starts nothing, cancels nothing, and is invisible to the model. Producers keep their execution resources and their existing model-facing surfaces; a composition without this seam loses only live observation. + +## Service contract + +- `open(spec): ActivityHandle` validates the kind, label, and exact live owner, then atomically registers the record and returns the producer face: synchronous `append(text, { channel?, gapBefore? })`, `updateDetail(detail)`, and first-wins `end(outcome)`. After `end` — including a teardown force-end — `append` and `updateDetail` log and drop instead of throwing, so a producer's trailing flush cannot break its own teardown. +- `get(id, caller?)` and `list(caller?)` return fresh snapshots. Listing includes only caller-owned and unowned activities; owned access compares the activity's `SessionId` with the caller's, and predictable ids make that fence the boundary. +- `read(id, from, caller?)` returns the retained chunks overlapping `[from, total)` plus the resume offset, without consuming anything. Offsets are absolute UTF-8 byte positions that survive eviction; a reader below the oldest retained byte gets `lossy: true`, never an error. +- `onActivitiesChanged(listener)` observes visible-set changes — opening, detail updates, settlement, owner-disposal removal, and the emptying service disposal commits — so consumers re-read rather than accumulate deltas. `onOutput(listener)` signals stream advancement (one signal per committed append and one at settlement) carrying only the id; a consumer reads from its own cursor. Both deliver owner-relative on the jobs-registry terms and contain every listener. + +`pumpActivityOutput(handle, sources, { pollMs, done })` is the shared pump for producers whose substrate is a non-consuming offset reader (the subprocess `readFrom(fromByte)` family): it copies labeled deltas at a bounded cadence, marks a lossy source read `gapBefore`, and drains once more after `done` settles without ever calling `end()` itself. + +See the [activity subsystem page](../../../docs/subsystems/activity.md) and the [observation-seam Agent Note](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md). + +## Model Experience + +None, as the registry carries live observation state for humans and registers no prompt, tool, or session event; activity output becomes model-visible only through the producers' existing tool results and `ctx.jobs` reads. + +#### KV Cache effect + +None; the package never assembles or alters provider requests. + +## Known Limitations and Deferred Work + +- **Records are process-local and live-only** — a Host restart empties every roster while the transcript keeps the producing tool cards; durable replay is a separate design. +- **The observation plane has no cancellation verb** — stopping work stays with the producers and `ctx.jobs`; a panel-initiated kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). +- **Settled records last until owner disposal** — retention trims their buffers to the settled cap, but the rows themselves are not aged out. diff --git a/packages/activity/activity/README.zh.md b/packages/activity/activity/README.zh.md new file mode 100644 index 0000000000..afe843f40a --- /dev/null +++ b/packages/activity/activity/README.zh.md @@ -0,0 +1,32 @@ +# @deepseek-ai/dsh-activity + +[English](README.md) | 中文 + +流式输出观察契约(`ctx.activities`)。抽象 `ActivityRegistry` 与其词汇类型给所有长时生产者一个发布实时输出与状态的位置,并让任意数量的独立消费者对其做非消费读取;内存实现在 [`dsh-activity-local`](../activity-local/README.zh.md)。生产者插件通过 `ActivityKindMap` 扩展自己的不透明 id 命名空间,并可经 `ActivityCorrelation` 把 activity 关联到它的工具调用与后台 job。 + +注册表是纯观察面:不启动、不取消任何工作,对模型不可见。生产者保留自己的执行资源与既有模型面;不装本 seam 的组合只失去实时观察。 + +## 服务契约 + +- `open(spec): ActivityHandle` 校验 kind、label 与 exact 存活 owner 后原子注册记录并返回生产者句柄:同步的 `append(text, { channel?, gapBefore? })`、`updateDetail(detail)` 与 first-wins 的 `end(outcome)`。`end` 之后——包括 teardown 强制 end——`append` 与 `updateDetail` 记日志后丢弃而不抛错,生产者的收尾 flush 不会砸掉自己的 teardown。 +- `get(id, caller?)` 与 `list(caller?)` 返回全新快照。列表只含调用方拥有与无主的 activity;有主访问比较 activity 的 `SessionId` 与调用方的,id 可预测,这道栅栏即边界。 +- `read(id, from, caller?)` 返回与 `[from, total)` 重叠的保留 chunk 及续读 offset,绝不消费。offset 是跨淘汰稳定的绝对 UTF-8 字节位置;落后于最老保留字节的读者得到 `lossy: true`,永不报错。 +- `onActivitiesChanged(listener)` 观察可见集变化——开启、detail 更新、结算、owner 销毁移除、服务销毁清空——消费者整读而非累积增量。`onOutput(listener)` 发出流推进信号(每次提交的 append 一发、结算再一发),只携带 id;消费者按自己的游标去 read。两者按 jobs 注册表的条款做 owner 相对投递,且每个监听都被容错。 + +`pumpActivityOutput(handle, sources, { pollMs, done })` 是给非消费 offset 读取器型底座(subprocess 的 `readFrom(fromByte)` 家族)的共享泵:按有界节奏复制带标签的增量、把 lossy 的源读取标为 `gapBefore`、在 `done` 结算后再排干一次,且自己绝不调用 `end()`。 + +参见 [activity 子系统页](../../../docs/subsystems/activity.zh.md)与[观察 seam Agent Note](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)。 + +## 模型体验 + +无:注册表只承载面向人的实时观察状态,不注册任何 prompt、工具或会话事件;activity 输出只有经生产者既有的工具结果与 `ctx.jobs` 读取才对模型可见。 + +#### KV Cache 影响 + +无;本包从不组装或改动 provider 请求。 + +## 已知限制与暂缓事项 + +- **记录是进程本地且仅存活期有效**——Host 重启清空全部 roster,而 transcript 侧的生产工具卡片仍在;持久回放是独立设计。 +- **观察面没有取消动词**——停止工作仍属于生产者与 `ctx.jobs`;面板发起的 kill 被 [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题阻塞。 +- **已结算记录保留到 owner 销毁**——保留策略把缓冲裁到 settled 上限,但行本身不会老化清除。 diff --git a/packages/activity/activity/package.json b/packages/activity/activity/package.json new file mode 100644 index 0000000000..0b381d11fa --- /dev/null +++ b/packages/activity/activity/package.json @@ -0,0 +1,57 @@ +{ + "name": "@deepseek-ai/dsh-activity", + "description": "Streaming-output observation registry (ctx.activities) for the DeepSeek Harness — non-consuming offset reads, bounded retention, and change signals over any long-running producer's live output", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/activity/activity" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./brand": { + "types": "./lib/types/brand.d.ts", + "default": "./lib/types/brand.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/activity/activity/src/brand.ts b/packages/activity/activity/src/brand.ts new file mode 100644 index 0000000000..8df786ffb9 --- /dev/null +++ b/packages/activity/activity/src/brand.ts @@ -0,0 +1,28 @@ +/** + * dsh-activity' owned branded id, carried across the registry, the observation + * wire, and correlation metadata on other domains' rows. + * + * It lives in its own leaf because the package root reaches `dsh-agent` + * through the owner and listener signatures, which a Client program cannot + * resolve even as a type. A browser-safe consumer imports the id here; + * `Branded` itself comes from the zero-dependency `@deepseek-ai/dsh-brand`. + * + * @module @deepseek-ai/dsh-activity/brand + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** + * Identifies one observable activity. The registry generates `-N`; + * predictable ids rely on owner authorization rather than secrecy. + */ +export type ActivityId = Branded<'ActivityId'> + +/** + * Brand a string as a {@link ActivityId}. + * @param id - the raw process-id string (the registry generates `-N`). + * @returns the same string, branded; no validation is performed. + */ +export function ActivityId(id: string): ActivityId { + return id as ActivityId +} diff --git a/packages/activity/activity/src/index.ts b/packages/activity/activity/src/index.ts new file mode 100644 index 0000000000..4e3927abe5 --- /dev/null +++ b/packages/activity/activity/src/index.ts @@ -0,0 +1,138 @@ +/** + * The streaming-output observation Service Definition (`ctx.activities`). An + * activity is one observable unit of long-running work: an append-only bounded + * output stream plus live status, readable by any number of independent + * observers at absolute byte offsets. The registry is a pure observation + * plane — it starts nothing, cancels nothing, and is invisible to the model; + * producers keep their execution resources and their existing model-facing + * surfaces (`ctx.jobs` cursors, tool results). The process-local registry + * lives in `@deepseek-ai/dsh-activity-local`. + * @module @deepseek-ai/dsh-activity + */ + +import { Context, Service } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { + ActivitiesChangedListener, ActivityHandle, ActivityId, ActivityOpen, ActivityOutputListener, + ActivityRead, ActivitySnapshot, +} from './types.ts' + +export { ActivityId } from './brand.ts' +export type { + ActivityAppendOptions, + ActivityChannel, + ActivityCorrelation, + ActivitiesChangedListener, + ActivityHandle, + ActivityKind, + ActivityKindMap, + ActivityOpen, + ActivityOutcome, + ActivityOutputChunk, + ActivityOutputListener, + ActivityRead, + ActivitySnapshot, + ActivityStatus, +} from './types.ts' +export { pumpActivityOutput } from './pump.ts' +export type { ActivityPumpOptions, ActivityPumpRead, ActivityPumpSource } from './pump.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + activities: ActivityRegistry + } +} + +/** + * Abstract streaming-output registry. Subclass, implement the abstract + * methods, and load the subclass as a plugin — it registers as + * `ctx.activities` (one implementation per context; loading a second throws, + * which is cordis' standard duplicate-service behavior). + * + * Implementations must honor these semantics: + * - Observation never consumes. Any number of readers hold their own absolute + * byte offsets; a read changes no cursor and no producer state, so the + * model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. + * - Records outlive producer fibers. Owner disposal ends still-open records + * and removes them; service disposal ends and clears everything. Neither + * awaits a producer — the registry owns no execution resource. + * - Retention is bounded. Appends past the live cap drop the oldest retained + * bytes; a reader below the retained window gets a lossy read, never an + * error. Settlement trims retention to the settled cap. + * - Listener delivery is owner-relative: a listener registered from an + * unscoped context — a host composition's own carrier — sees every owner, + * while one registered under an agent composition's scope sees exactly the + * agents composed under it. Every listener is contained. + */ +export abstract class ActivityRegistry extends Service { + constructor(ctx: Context) { + // `abstract` erases at runtime, so a composition row naming this package + // would register a ctx.activities with no method implementations and fail + // far from the misconfiguration. Fail loud at load instead. + if (new.target === ActivityRegistry) { + throw new Error('@deepseek-ai/dsh-activity is the abstract activity observation seam; load an implementation such as @deepseek-ai/dsh-activity-local instead') + } + super(ctx, 'activities') + } + + /** + * Validate the spec, attach owner cleanup, and atomically register one + * activity. A rejection leaves no id; after return the producer owns the + * handle and the visible set has changed. + * @param spec - activity identity, owner, and correlation. + * @returns the producer face of the registered activity. + */ + abstract open(spec: ActivityOpen): ActivityHandle + + /** + * List caller-owned and unowned activities in registration order without + * exposing another session's labels or output. + * @param caller - reading agent; a non-agent caller sees only unowned activities. + * @returns fresh snapshots. + */ + abstract list(caller?: Agent): ActivitySnapshot[] + + /** + * Return a fresh snapshot. Throws for an unknown or foreign activity. + * @param id - activity to look up. + * @param caller - reading agent checked against the owner. + * @returns a fresh snapshot. + */ + abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot + + /** + * Read retained output from an absolute byte offset without consuming it. + * Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Throws for an unknown or foreign activity or a negative or non-integer + * offset. + * @param id - activity to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ + abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead + + /** + * Register an effect-scoped observer of visible-set changes: opening, + * detail updates, settlement, owner-disposal removal, and the emptying that + * service disposal commits — so an observer re-reads rather than + * accumulating deltas. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned activity changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ + abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void + + /** + * Register an effect-scoped observer of stream advancement — one signal per + * committed append and one at settlement, carrying only the activity id. + * A consumer schedules a {@link read} from its own cursor; the registry + * never pushes payloads. Listeners are contained and never awaited. + * @param listener - receives the id whose stream advanced. + * @returns disposer that unregisters the listener. + */ + abstract onOutput(listener: ActivityOutputListener): () => void +} + +export default ActivityRegistry diff --git a/packages/activity/activity/src/invariant.ts b/packages/activity/activity/src/invariant.ts new file mode 100644 index 0000000000..6753fa7b0e --- /dev/null +++ b/packages/activity/activity/src/invariant.ts @@ -0,0 +1,62 @@ +/** Package-owned process-snapshot invariants. @module @deepseek-ai/dsh-activity/invariant */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ActivitySnapshot } from './types.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-activity' +const TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed']) + +/** Cordis companion plugin name. */ +export const name = 'activity-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** Validate the cross-field relationships in one registry snapshot. */ +/* jscpd:ignore-start -- mirrors the jobs invariant's id/ordinal checks by design; the two registries share the `-N` scheme. */ +function validateSnapshot(snapshot: ActivitySnapshot, fail: InvariantFailure): void { + const id = String(snapshot.id) + const prefix = `${snapshot.kind}-` + const ordinal = Number(id.slice(prefix.length)) + if (snapshot.kind.length === 0 || !id.startsWith(prefix) + || !Number.isSafeInteger(ordinal) || ordinal < 1) { + fail(`activity snapshot id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`) + } + if (snapshot.label.length === 0) fail(`activity ${JSON.stringify(id)} label must be non-empty`) + if (!Number.isSafeInteger(snapshot.startedAt) || snapshot.startedAt < 0) { + fail(`activity ${JSON.stringify(id)} startedAt must be a non-negative epoch integer`) + } + /* jscpd:ignore-end */ + + const terminal = TERMINAL_STATUSES.has(snapshot.status) + if (terminal !== (snapshot.finishedAt !== undefined)) { + fail(`activity ${JSON.stringify(id)} finishedAt must be present exactly for a terminal status`) + } + if (snapshot.finishedAt !== undefined + && (!Number.isSafeInteger(snapshot.finishedAt) || snapshot.finishedAt < snapshot.startedAt)) { + fail(`activity ${JSON.stringify(id)} finishedAt must be an epoch integer no earlier than startedAt`) + } + + if (!Number.isSafeInteger(snapshot.outputTotal) || snapshot.outputTotal < 0 + || !Number.isSafeInteger(snapshot.outputEarliest) || snapshot.outputEarliest < 0 + || snapshot.outputEarliest > snapshot.outputTotal) { + fail(`activity ${JSON.stringify(id)} output offsets must satisfy 0 <= outputEarliest <= outputTotal`) + } +} + +/** Install checks over current visible records and every later visible-set change. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const snapshot of ctx.activities.list()) validateSnapshot(snapshot, fail) + ctx.activities.onActivitiesChanged((owner: Agent | undefined) => { + for (const snapshot of ctx.activities.list(owner)) validateSnapshot(snapshot, fail) + }) +}, { inject: ['activities'] }) + +/** + * Register the process-registry invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/activity/activity/src/pump.ts b/packages/activity/activity/src/pump.ts new file mode 100644 index 0000000000..4cc1eae22d --- /dev/null +++ b/packages/activity/activity/src/pump.ts @@ -0,0 +1,95 @@ +/** + * Shared poll pump for producers whose output substrate is a non-consuming + * offset reader (the subprocess `readFrom(fromByte)` family). Pure utility — + * no cordis, no timers retained past settlement. + * @module + */ + +import type { ActivityChannel, ActivityHandle } from './types.ts' + +/** One non-consuming offset read from a producer-owned stream. */ +export interface ActivityPumpRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean +} + +/** One producer-owned stream the pump copies into a process. */ +export interface ActivityPumpSource { + /** Stream label attached to every chunk this source yields. */ + channel?: ActivityChannel + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, and the lossy flag. + */ + read(fromByte: number): ActivityPumpRead +} + +/** Cadence and settlement for one {@link pumpActivityOutput} run. */ +export interface ActivityPumpOptions { + /** Poll interval in milliseconds; a positive finite number. */ + pollMs: number + /** + * Settles when the producer's work finished and its sources hold their + * final bytes. The pump drains once more after settlement and returns; + * a rejection is treated as settlement. + */ + done: Promise +} + +/** + * Copy producer streams into a process at a bounded cadence: drain every + * source, sleep `pollMs` or until `done` settles, repeat, then drain one + * final time. A lossy source read appends its surviving tail with + * `gapBefore`, so the discontinuity stays visible to observers. The pump + * never calls `handle.end()` — the producer maps its own outcome. + * @param handle - the open process receiving the copied chunks. + * @param sources - producer streams, each pumped at its own offset. + * @param options - poll cadence and the producer's settlement promise. + * @returns resolves after the final post-settlement drain. + */ +export async function pumpActivityOutput( + handle: ActivityHandle, + sources: readonly ActivityPumpSource[], + options: ActivityPumpOptions, +): Promise { + if (!Number.isFinite(options.pollMs) || options.pollMs <= 0) { + throw new Error(`invalid pump pollMs: expected a positive finite number of milliseconds, got ${JSON.stringify(options.pollMs)}`) + } + const states = sources.map(source => ({ source, cursor: 0 })) + const drain = (): void => { + for (const state of states) { + const { source } = state + const read = source.read(state.cursor) + state.cursor = read.nextOffset + if (read.text.length === 0) continue + if (source.channel === undefined && !read.lossy) { + handle.append(read.text) + } else { + handle.append(read.text, { + ...source.channel !== undefined ? { channel: source.channel } : {}, + ...read.lossy ? { gapBefore: true as const } : {}, + }) + } + } + } + let settled = false + const settlement = options.done.then(() => { settled = true }, () => { settled = true }) + while (!settled) { + drain() + await Promise.race([ + new Promise((resolve) => { + const timer = setTimeout(resolve, options.pollMs) + // The settlement branch wins the race; clear the loser so a finished + // pump holds no pending timer for up to one poll interval. + void settlement.then(() => { clearTimeout(timer); resolve() }) + }), + settlement, + ]) + } + drain() +} diff --git a/packages/activity/activity/src/types.ts b/packages/activity/activity/src/types.ts new file mode 100644 index 0000000000..fd748ec165 --- /dev/null +++ b/packages/activity/activity/src/types.ts @@ -0,0 +1,179 @@ +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { CallId } from '@deepseek-ai/dsh-llm/brand' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { ActivityId } from './brand.ts' + +export type { ActivityId } from './brand.ts' + +/** + * Producer-defined activity kinds. Plugins extend this map by declaration + * merging; the registry treats every value as an opaque id namespace. + */ +export interface ActivityKindMap { + bash: 'bash' +} + +/** A registered activity kind — also the id prefix (`bash`, `workflow`, …). */ +export type ActivityKind = ActivityKindMap[keyof ActivityKindMap] + +/** Lifecycle state of one observable activity. */ +export type ActivityStatus = 'running' | 'completed' | 'failed' | 'killed' + +/** + * Output stream label. Producers without distinct streams omit it; consumers + * treat an unrecognized label like an absent one. + */ +export type ActivityChannel = 'stdout' | 'stderr' + +/** + * Links one activity to the records other domains keep for the same work, so a + * consumer can attach the output stream to an existing card or row without + * guessing from labels. + */ +export interface ActivityCorrelation { + /** Tool call that started the work, when a tool produced it. */ + callId?: CallId + /** Background job carrying the model-facing control surface, when one exists. */ + jobId?: JobId +} + +/** + * Producer declaration passed to {@link ActivityRegistry.open}. The registry + * owns identity, retention, and observer delivery; the producer keeps its + * execution resources and reports through the returned {@link ActivityHandle}. + */ +export interface ActivityOpen { + /** Producer kind — also the id prefix. */ + kind: ActivityKind + /** One-line human-facing label (the command; the run description). */ + label: string + /** + * Owning live agent. Visibility is fenced by its session id, and agent + * disposal ends and removes the record. The instance must be the one + * currently registered under its agent id. Omitting the owner creates an + * unowned activity, visible to any caller until service disposal. + */ + owner?: Agent + /** Optional links to the tool call and background job for the same work. */ + correlation?: ActivityCorrelation +} + +/** Terminal outcome reported through {@link ActivityHandle.end}. */ +export interface ActivityOutcome { + /** How the work ended: finished (`completed`), broke (`failed`), or was cancelled (`killed`). */ + status: 'completed' | 'failed' | 'killed' + /** Kind-specific detail rendered into status lines ('exit code: 3'). */ + detail?: string +} + +/** Options for one {@link ActivityHandle.append}. */ +export interface ActivityAppendOptions { + /** Stream label for the chunk; omitted for producers without distinct streams. */ + channel?: ActivityChannel + /** + * Marks that producer-side bytes between the previous chunk and this one + * were lost (a source window slid before the producer could copy it), so a + * consumer can render the discontinuity instead of a silent splice. + */ + gapBefore?: true +} + +/** + * Producer face of one open activity. All methods are synchronous. After + * {@link end} — including a registry-forced end during owner or service + * teardown — `append` and `updateDetail` log and drop instead of throwing, so + * a producer's trailing flush cannot break its own teardown path. + */ +export interface ActivityHandle { + /** The registry-issued id (`-N`). */ + readonly id: ActivityId + /** + * Append one output chunk. Offsets advance by the chunk's UTF-8 byte + * length; an empty chunk is dropped without waking observers. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + */ + append(text: string, options?: ActivityAppendOptions): void + /** + * Replace the snapshot's status detail (a progress line such as `3/10`). + * @param detail - the new detail line. + */ + updateDetail(detail: string): void + /** + * Record the terminal outcome and trim retention to the settled cap. + * First-wins: a later call — the producer's own report after a teardown + * force-end, or a duplicate — is dropped silently. + * @param outcome - terminal status and optional detail. + */ + end(outcome: ActivityOutcome): void +} + +/** + * A read-only projection of one activity, safe to hand to listeners and + * consumers — a fresh object per call, never live registry state. + */ +export interface ActivitySnapshot { + /** The registry-issued id (`-N`). */ + id: ActivityId + /** The producer kind the process was opened with. */ + kind: ActivityKind + /** The producer-supplied one-line label. */ + label: string + /** Owner session id used for visibility and grouping; absent for unowned activities. */ + ownerSession?: SessionId + /** Links to the tool call and background job for the same work, when supplied. */ + correlation?: ActivityCorrelation + /** Current lifecycle state. */ + status: ActivityStatus + /** Kind-specific status detail, when the producer supplied one. */ + detail?: string + /** Epoch ms when the activity was opened. */ + startedAt: number + /** Epoch ms when the activity ended; absent while `running`. */ + finishedAt?: number + /** Total UTF-8 bytes ever appended — the offset the next chunk starts at. */ + outputTotal: number + /** + * Offset of the oldest retained byte. Greater than zero exactly when + * retention dropped the head; a reader starting below it gets a lossy read. + */ + outputEarliest: number +} + +/** One retained output chunk returned by {@link ActivityRegistry.read}. */ +export interface ActivityOutputChunk { + /** Absolute offset of the chunk's first byte. */ + at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + text: string + /** Stream label, when the producer supplied one. */ + channel?: ActivityChannel + /** Producer-reported loss immediately before this chunk. */ + gapBefore?: true +} + +/** Result of one non-consuming {@link ActivityRegistry.read}. */ +export interface ActivityRead { + /** Retained chunks overlapping `[from, total)`, in offset order. */ + chunks: readonly ActivityOutputChunk[] + /** Offset to resume from — the process' current `outputTotal`. */ + next: number + /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean +} + +/** + * Observer of visible-set changes: opening, detail updates, settlement, and + * removal. Receives the owner whose set changed, or `undefined` when an + * unowned process changed and every caller's set did. Consumers re-read + * rather than accumulating deltas. + */ +export type ActivitiesChangedListener = (owner: Agent | undefined) => void + +/** + * Observer of stream advancement for one activity: new appended output, or the + * settlement that ends the stream. Carries no payload — a consumer schedules a + * {@link ActivityRegistry.read} from its own cursor. + */ +export type ActivityOutputListener = (id: ActivityId) => void diff --git a/packages/activity/activity/tests/invariant.spec.ts b/packages/activity/activity/tests/invariant.spec.ts new file mode 100644 index 0000000000..306cd4533a --- /dev/null +++ b/packages/activity/activity/tests/invariant.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import ActivityRegistry, { ActivityId } from '@deepseek-ai/dsh-activity' +import type { ActivitiesChangedListener, ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import * as ProcessInvariant from '@deepseek-ai/dsh-activity/invariant' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' + +const BASE: ActivitySnapshot = { + id: ActivityId('bash-1'), + kind: 'bash', + label: 'compile', + status: 'completed', + startedAt: 10, + finishedAt: 20, + outputTotal: 12, + outputEarliest: 4, +} + +const RUNNING: ActivitySnapshot = { + id: ActivityId('bash-1'), + kind: 'bash', + label: 'compile', + status: 'running', + startedAt: 10, + outputTotal: 0, + outputEarliest: 0, +} + +/** Boot the companion over a probe registry whose visible set the test controls. */ +async function setup(seed: ActivitySnapshot[] = []): Promise<(visible: ActivitySnapshot[], owner?: Agent) => void> { + const ctx = new Context() + let current = seed + let listener: ActivitiesChangedListener | undefined + const probe = { + list: () => current, + onActivitiesChanged(value: ActivitiesChangedListener) { + listener = value + return () => { listener = undefined } + }, + } as unknown as ActivityRegistry + await ctx.plugin(InvariantRegistry) + await ctx.plugin({ + name: 'activity-invariant-probe', + apply(child: Context) { child.provide('activities', probe) }, + }) + await ctx.plugin(ProcessInvariant) + if (listener === undefined) throw new Error('activity invariant did not subscribe to visible-set changes') + return (visible, owner) => { + current = visible + listener!(owner) + } +} + +describe('process-registry invariants', () => { + it('accepts coherent running and terminal snapshots', async () => { + const notify = await setup([RUNNING]) + expect(() => { notify([BASE]) }).not.toThrow() + expect(() => { notify([{ ...BASE, id: ActivityId('workflow-2'), kind: 'workflow' }]) }) + .not.toThrow() + }) + + it.each([ + [{ ...BASE, id: ActivityId('-1'), kind: '' as ActivitySnapshot['kind'] }, /positive ordinal/], + [{ ...BASE, id: ActivityId('other-1') }, /must be "bash-" followed by a positive ordinal/], + [{ ...BASE, id: ActivityId('bash-0') }, /positive ordinal/], + [{ ...BASE, label: '' }, /label must be non-empty/], + [{ ...BASE, startedAt: -1 }, /startedAt must be a non-negative epoch integer/], + [{ ...BASE, status: 'running' as const, finishedAt: 20 }, /finishedAt must be present exactly for a terminal status/], + [{ ...RUNNING, status: 'killed' as const }, /finishedAt must be present exactly for a terminal status/], + [{ ...BASE, finishedAt: 9 }, /no earlier than startedAt/], + [{ ...BASE, outputEarliest: 13 }, /0 <= outputEarliest <= outputTotal/], + [{ ...BASE, outputTotal: -1, outputEarliest: 0 }, /0 <= outputEarliest <= outputTotal/], + ] as const)('rejects an incoherent registry snapshot', async (snapshot, message) => { + const notify = await setup() + expect(() => { notify([snapshot]) }).toThrow(message) + }) + + it('rejects an incoherent record already present at installation', async () => { + await expect(setup([{ ...BASE, label: '' }])).rejects.toThrow(/label must be non-empty/) + }) +}) diff --git a/packages/activity/activity/tests/service.spec.ts b/packages/activity/activity/tests/service.spec.ts new file mode 100644 index 0000000000..2dd685c1d3 --- /dev/null +++ b/packages/activity/activity/tests/service.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' +import ActivityRegistry, { ActivityId, pumpActivityOutput } from '@deepseek-ai/dsh-activity' +import type { ActivityAppendOptions, ActivityHandle, ActivityPumpSource, ActivitySnapshot } from '@deepseek-ai/dsh-activity' + +/** Record every append so pump behavior is observable without a registry. */ +function recordingHandle(): { handle: ActivityHandle; appends: { text: string; options?: ActivityAppendOptions }[] } { + const appends: { text: string; options?: ActivityAppendOptions }[] = [] + const handle: ActivityHandle = { + id: ActivityId('bash-1'), + append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) }, + updateDetail() {}, + end() {}, + } + return { handle, appends } +} + +/** A scripted source: each read() shifts the next scripted result. */ +function scriptedSource( + reads: { text: string; lossy?: boolean }[], + channel?: 'stdout' | 'stderr', +): { source: ActivityPumpSource; offsets: number[] } { + const offsets: number[] = [] + let offset = 0 + const source: ActivityPumpSource = { + ...channel !== undefined ? { channel } : {}, + read(fromByte) { + offsets.push(fromByte) + const next = reads.shift() ?? { text: '' } + offset += Buffer.byteLength(next.text, 'utf8') + return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false } + }, + } + return { source, offsets } +} + +describe('ActivityRegistry seam', () => { + it('refuses to mount the abstract Service Definition directly', async () => { + const ctx = new Context() + await expect(ctx.plugin(ActivityRegistry as unknown as () => void)) + .rejects.toThrow(/load an implementation such as @deepseek-ai\/dsh-activity-local/) + }) + + it('preserves the SessionId brand on public owner snapshots', () => { + expectTypeOf().toEqualTypeOf() + }) +}) + +describe('pumpActivityOutput', () => { + it('rejects a non-positive poll interval before touching any source', async () => { + const { handle } = recordingHandle() + await expect(pumpActivityOutput(handle, [], { pollMs: 0, done: Promise.resolve() })) + .rejects.toThrow(/invalid pump pollMs/) + }) + + it('copies labeled deltas at the poll cadence and resumes each source at its own offset', async () => { + vi.useFakeTimers() + try { + const { handle, appends } = recordingHandle() + let settle!: () => void + const done = new Promise((resolve) => { settle = resolve }) + const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout') + const err = scriptedSource([{ text: '' }, { text: 'E' }], 'stderr') + const pump = pumpActivityOutput(handle, [out.source, err.source], { pollMs: 50, done }) + + await vi.advanceTimersByTimeAsync(50) + settle() + await pump + expect(appends).toEqual([ + { text: 'a', options: { channel: 'stdout' } }, + { text: 'bc', options: { channel: 'stdout' } }, + { text: 'E', options: { channel: 'stderr' } }, + ]) + // Every read resumed from the previous nextOffset, never from a shared cursor. + expect(out.offsets).toEqual([0, 1, 3]) + expect(err.offsets).toEqual([0, 0, 1]) + // Settlement cleared the raced poll timer instead of leaving it pending. + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('marks a lossy source read as a gap so observers see the discontinuity', async () => { + const { handle, appends } = recordingHandle() + const { source } = scriptedSource([{ text: 'tail', lossy: true }]) + await pumpActivityOutput(handle, [source], { pollMs: 1, done: Promise.resolve() }) + expect(appends).toEqual([{ text: 'tail', options: { gapBefore: true } }]) + }) + + it('treats a rejected done as settlement and still drains the final bytes', async () => { + const { handle, appends } = recordingHandle() + const { source } = scriptedSource([{ text: 'last' }]) + await pumpActivityOutput(handle, [source], { pollMs: 1, done: Promise.reject(new Error('producer broke')) }) + expect(appends).toEqual([{ text: 'last' }]) + }) +}) diff --git a/packages/activity/activity/tsconfig.json b/packages/activity/activity/tsconfig.json new file mode 100644 index 0000000000..f4deee6327 --- /dev/null +++ b/packages/activity/activity/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../jobs/jobs" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/api/activity-controller/README.i18n.yaml b/packages/api/activity-controller/README.i18n.yaml new file mode 100644 index 0000000000..e49b392e32 --- /dev/null +++ b/packages/api/activity-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/api/activity-controller/README.md +README.md: 10885ec087cc295df6bcb033480373db04938097 +README.zh.md: 9509837baa4661209c7d826bee149b76a11f4723 diff --git a/packages/api/activity-controller/README.md b/packages/api/activity-controller/README.md new file mode 100644 index 0000000000..10885ec087 --- /dev/null +++ b/packages/api/activity-controller/README.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-api-activity-controller + +English | [中文](README.zh.md) + +Activity Remote streams over the optional `ctx.activities` registry, plus their React-free client model. A pure read surface: it starts nothing, cancels nothing, and never touches the model-facing consuming cursors — the wire path reads only the registry's non-consuming offsets. + +The Host `ActivityController` (namespace `activity`) exposes two `@Remote({ mode: 'stream' })` methods: + +- `control(signal)` — one complete roster baseline, then whole-bucket replacement frames per owner session on every registry change (the self-healing shape the session control stream uses for jobs). A composition without the registry serves an empty baseline. +- `observe({ activityId, from? }, signal)` — one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget; one larger chunk ships whole), then one terminal `status` after the settled activity is drained, after which the generation closes normally. Reconnecting callers resume by passing the last frame's `next`; a resume behind the retained window arrives flagged `lossy`. Status rides the same stream as output, so settlement can never race a still-open output channel. + +| Config | Default | Meaning | +|---|---|---| +| `flushMs` | `100` | Coalescing window after new output before an observation read. | +| `maxFrameBytes` | `65536` | Soft byte budget per observation output frame. | + +The client half (`./client`) installs `ctx.activityFeed`: `ClientActivityModel` mirrors the roster (last-wins buckets, generation replace) and accumulates each observed activity's bounded render tail, and `ClientActivityFeed.observe(id)` opens reference-counted observation streams that resume from the model's cursor across carrier generations. + +## Model Experience + +None, as activity transport is browser and Host observation state and registers no prompt, tool, or session event. + +#### KV Cache effect + +None; the package never assembles or alters provider requests. + +## Known Limitations and Deferred Work + +- **Registry presence is sampled at construction** — a registry mounted after the controller stays unobserved until the controller reloads. +- **The roster names no watermark** — an observation stream anchors itself; correlating roster rows against observation offsets is client policy. diff --git a/packages/api/activity-controller/README.zh.md b/packages/api/activity-controller/README.zh.md new file mode 100644 index 0000000000..9509837baa --- /dev/null +++ b/packages/api/activity-controller/README.zh.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-api-activity-controller + +[English](README.md) | 中文 + +架在可选 `ctx.activities` 注册表之上的 Activity Remote 流,附 React-free 客户端模型。纯读面:不启动、不取消任何工作,也绝不触碰模型侧消费游标——wire 路径只读注册表的非消费 offset。 + +宿主 `ActivityController`(命名空间 `activity`)暴露两个 `@Remote({ mode: 'stream' })` 方法: + +- `control(signal)`——一份完整 roster baseline,之后每次注册表变化按 owner 会话推送整桶替换帧(与 session control 流承载 jobs 的自愈形态一致)。没有注册表的组合返回空 baseline。 +- `observe({ activityId, from? }, signal)`——一帧 `opened` 锚点,随后是合并的 `output` 帧(`flushMs` 窗口、`maxFrameBytes` 软预算;更大的单 chunk 整帧发出),activity 结算且排干后发一帧终态 `status`,然后本代正常关闭。重连方以上一帧的 `next` 作为 `from` 续传;落后于保留窗口的续传会带 `lossy` 标记。状态与输出同流,结算永远不会与仍开着的输出通道竞态。 + +| 配置 | 默认值 | 含义 | +|---|---|---| +| `flushMs` | `100` | 新输出到达后、观察读取前的合并窗口。 | +| `maxFrameBytes` | `65536` | 每个观察输出帧的软字节预算。 | + +客户端半区(`./client`)安装 `ctx.activityFeed`:`ClientActivityModel` 镜像 roster(last-wins 分桶、按代整体替换)并为每个被观察 activity 累积有界渲染尾巴;`ClientActivityFeed.observe(id)` 打开按引用计数共享的观察流,跨 carrier 代次从模型游标续传。 + +## 模型体验 + +无:activity 传输是浏览器与宿主的观察状态,不注册任何 prompt、工具或会话事件。 + +#### KV Cache 影响 + +无;本包从不组装或改动 provider 请求。 + +## 已知限制与暂缓事项 + +- **注册表存在性在构造时采样**——控制器之后才挂载的注册表在控制器重载前不会被观察。 +- **roster 不携带水位**——观察流自带锚点;roster 行与观察 offset 的对齐是客户端策略。 diff --git a/packages/api/activity-controller/package.json b/packages/api/activity-controller/package.json new file mode 100644 index 0000000000..6898cff505 --- /dev/null +++ b/packages/api/activity-controller/package.json @@ -0,0 +1,103 @@ +{ + "name": "@deepseek-ai/dsh-api-activity-controller", + "description": "Activity Remote streams: the live-activity roster and per-activity non-consuming output observation", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/activity-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-activity-local": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web" + } + } +} diff --git a/packages/api/activity-controller/src/client/index.ts b/packages/api/activity-controller/src/client/index.ts new file mode 100644 index 0000000000..7338ae3b9b --- /dev/null +++ b/packages/api/activity-controller/src/client/index.ts @@ -0,0 +1,86 @@ +/** Activity-specific adapter for the Gateway-owned snapshot stream lifecycle. */ + +import type { Context } from '@deepseek-ai/cordis' +import { + RemoteSnapshotStream, + RemoteStreamCarrierError, + type ClientRemote, +} from '@deepseek-ai/dsh-api-gateway/client' +import type { ActivityControlFrame } from '../types.ts' +import { ClientActivityModel } from './model.ts' +import { ClientActivityFeed } from './service.ts' +import type { ActivityRemote } from './service.ts' + +export type { ActivityFeedPhase, ActivityFeedSnapshot, ObservedActivity } from './model.ts' +export type { ActivityFeedSource, ActivityRemote, IActivityFeed } from './service.ts' +export type { ActivityId } from '@deepseek-ai/dsh-activity/brand' +export type { ActivityRow } from '../types.ts' + +type ActivityStreamRemote = Pick & { readonly activity: ActivityRemote } + +type ActivityBaselineFrame = Extract +type ActivityRowsFrame = Extract + +declare module '@deepseek-ai/cordis' { + interface Context { + /** React-free client activity roster and observation control. */ + activityFeed: import('./service.ts').IActivityFeed + } +} + +/** Required Client Remote services. */ +export const inject = ['remote', 'remote.activity'] + +/** + * Install the client activity model, its service face, and the reconnecting + * roster stream. + * @param ctx - client root Context. + */ +export function apply(ctx: Context): void { + const raw = ctx.remote as ActivityStreamRemote + // Resolve the child namespace to a concrete value while this plugin's + // context is current: later stream (re)opens run on caller stacks (a React + // event, a carrier retry) whose dynamic context has not declared + // `remote.activity`, and a property access there fails the inject check. + const remote: ActivityStreamRemote = { + $stream: options => raw.$stream(options), + activity: raw.activity, + } + const model = new ClientActivityModel() + new ClientActivityFeed(ctx, remote, model) + const control = createActivityRosterStream(remote, model) + control.start() + ctx.effect( + () => async () => { await control.dispose() }, + 'activity-controller.client.control', + ) +} + +/** + * Create the reconnecting roster stream feeding one client model. + * @param remote - generated activity namespace and Gateway stream factory. + * @param model - roster sink. + * @returns an unstarted stream owned by the client activity runtime. + */ +export function createActivityRosterStream( + remote: ActivityStreamRemote, + model: ClientActivityModel, +): RemoteSnapshotStream { + const stream = remote.$stream({ + name: 'activity roster stream', + open: signal => remote.activity.control(signal), + ended: accepted => accepted + ? new RemoteStreamCarrierError('activity roster stream ended without a terminal result') + : new Error('activity roster stream ended before its opening baseline'), + }) + return new RemoteSnapshotStream(stream, { + name: 'activity roster stream', + isSnapshot: (frame): frame is ActivityBaselineFrame => frame.type === 'baseline', + replace: (frame) => { model.replaceBaseline(frame.activities) }, + update: (frame) => { model.replaceBucket(frame.sessionId, frame.activities) }, + failed: () => { + // The previous roster stays published while the stream retries; a + // terminal protocol failure leaves the last snapshot in place. + }, + }) +} diff --git a/packages/api/activity-controller/src/client/model.ts b/packages/api/activity-controller/src/client/model.ts new file mode 100644 index 0000000000..96fb7cca5a --- /dev/null +++ b/packages/api/activity-controller/src/client/model.ts @@ -0,0 +1,211 @@ +/** Client-side activity state model shared by Remote transport and UI projection. */ + +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' +import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' +import type { ActivityObserveFrame, ActivityRow } from '../types.ts' + +/** Bounded per-activity render tail, in UTF-16 code units. */ +const RENDER_TAIL_LIMIT = 128 * 1024 + +/** Roster arrival lifecycle: `pending` until the first baseline lands. */ +export type ActivityFeedPhase = 'pending' | 'ready' + +/** One observed activity's live view state. */ +export interface ObservedActivity { + readonly activityId: ActivityId + /** Accumulated output tail, bounded to the render limit. */ + readonly text: string + /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ + readonly gapBefore: boolean + /** Latest status delivered on the observation stream. */ + readonly status: ActivityRow['status'] + readonly detail?: string + /** True while the observation stream is open and the activity has not settled. */ + readonly streaming: boolean + /** Terminal observation failure, when the stream ended abnormally. */ + readonly error?: string +} + +/** Immutable client activity state. */ +export interface ActivityFeedSnapshot { + /** Roster rows per owner session; the empty key holds unowned activities. */ + readonly rowsBySession: Readonly> + /** Live observation state keyed by activity id. */ + readonly observed: Readonly> + readonly phase: ActivityFeedPhase +} + +const UNOWNED = '' + +/** Mutable observation bookkeeping behind one {@link ObservedActivity} view. */ +interface ObservedState { + view: ObservedActivity + /** Resume offset for the next generation's `from`. */ + cursor: number | undefined +} + +/** + * Owns the client roster mirror and per-activity observation state. Pure data + * plus subscriptions — transport wiring stays in the client plugin, UI stays + * in slot components. + */ +export class ClientActivityModel { + private rowsBySession: Readonly> = {} + private readonly observedStates = new Map() + private phase: ActivityFeedPhase = 'pending' + private readonly listeners = new Set<() => void>() + private snapshotCache: ActivityFeedSnapshot = { rowsBySession: {}, observed: {}, phase: 'pending' } + private snapshotDirty = false + + /** + * Read the identity-stable current snapshot. + * @returns the cached snapshot, rebuilt only after a change. + */ + getSnapshot(): ActivityFeedSnapshot { + if (this.snapshotDirty) { + const observed: Record = {} + for (const [id, state] of this.observedStates) observed[id] = state.view + this.snapshotCache = { rowsBySession: this.rowsBySession, observed, phase: this.phase } + this.snapshotDirty = false + } + return this.snapshotCache + } + + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Replace the whole roster from a generation baseline. + * @param rows - every visible row across all owner buckets. + */ + replaceBaseline(rows: readonly ActivityRow[]): void { + const next: Record = {} + for (const row of rows) { + const key = row.sessionId ?? UNOWNED + ;(next[key] ??= []).push(row) + } + this.rowsBySession = next + this.phase = 'ready' + this.changed() + } + + /** + * Replace one owner bucket wholesale; an empty replacement drops the key. + * @param sessionId - owner session, or undefined for the unowned bucket. + * @param rows - the bucket's complete visible rows. + */ + replaceBucket(sessionId: string | undefined, rows: readonly ActivityRow[]): void { + const key = sessionId ?? UNOWNED + if (rows.length === 0) { + this.rowsBySession = Object.fromEntries( + Object.entries(this.rowsBySession).filter(([bucket]) => bucket !== key)) + } else { + this.rowsBySession = { ...this.rowsBySession, [key]: rows } + } + this.changed() + } + + /** + * The resume offset for one activity's next observation generation. + * @param id - observed activity. + * @returns the last accepted `next`, or undefined for a fresh observation. + */ + cursorOf(id: ActivityId): number | undefined { + return this.observedStates.get(String(id))?.cursor + } + + /** + * Install or reset observation state when a generation's anchor arrives. + * @param id - observed activity. + * @param frame - the generation's `opened` anchor. + */ + observeOpened(id: ActivityId, frame: Extract): void { + const existing = this.observedStates.get(String(id)) + const view: ObservedActivity = { + activityId: id, + text: existing?.view.text ?? '', + gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest, + status: frame.status, + ...frame.detail !== undefined ? { detail: frame.detail } : {}, + streaming: true, + } + this.observedStates.set(String(id), { view, cursor: frame.from }) + this.changed() + } + + /** + * Append one output frame's chunks to the bounded render tail. + * @param id - observed activity. + * @param frame - a coalesced `output` frame. + */ + observeOutput(id: ActivityId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('') + let gapBefore = state.view.gapBefore || frame.lossy === true + || frame.chunks.some(chunk => chunk.gapBefore === true) + if (text.length > RENDER_TAIL_LIMIT) { + let cut = text.length - RENDER_TAIL_LIMIT + // Never split a surrogate pair at the render bound. + const unit = text.charCodeAt(cut) + if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1 + text = text.slice(cut) + gapBefore = true + } + state.view = { ...state.view, text, gapBefore } + state.cursor = frame.next + this.changed() + } + + /** + * Record the terminal status delivered at the end of the observation stream. + * @param id - observed activity. + * @param frame - the terminal `status` frame. + */ + observeStatus(id: ActivityId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + state.view = { + ...state.view, + status: frame.status, + ...frame.detail !== undefined ? { detail: frame.detail } : {}, + streaming: false, + } + this.changed() + } + + /** + * Record a terminal observation failure. + * @param id - observed activity. + * @param error - the stream's terminal failure. + */ + observeFailed(id: ActivityId, error: unknown): void { + const state = this.observedStates.get(String(id)) + if (state === undefined) return + state.view = { ...state.view, streaming: false, error: String(error) } + this.changed() + } + + /** + * Drop observation state after the last observer stops. + * @param id - the no-longer-observed activity. + */ + observeStopped(id: ActivityId): void { + if (!this.observedStates.delete(String(id))) return + this.changed() + } + + private changed(): void { + this.snapshotDirty = true + notifySubscribers(this.listeners, 'activity feed') + } +} diff --git a/packages/api/activity-controller/src/client/service.ts b/packages/api/activity-controller/src/client/service.ts new file mode 100644 index 0000000000..a640bc8ba2 --- /dev/null +++ b/packages/api/activity-controller/src/client/service.ts @@ -0,0 +1,156 @@ +/** React-free client activity service: roster snapshot plus observation control. */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-activity-controller/remote' +import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' +import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' +import type { ActivityObserveFrame } from '../types.ts' +import type { ActivityFeedSnapshot, ClientActivityModel } from './model.ts' + +/** Complete generated `ctx.remote.activity` namespace. */ +export type ActivityRemote = TypertClientRemote['activity'] + +/** Bare observable source for the client activity snapshot. */ +export interface ActivityFeedSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): ActivityFeedSnapshot + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void +} + +/** The activity feed's client service face. */ +export interface IActivityFeed { + /** Roster rows, observation state, and arrival phase. */ + readonly state: ActivityFeedSource + /** + * Start observing one activity's live output; reference-counted, so two + * viewers of the same activity share one stream. + * @param id - activity to observe. + * @returns stop function releasing this observer's reference. + */ + observe(id: ActivityId): () => void +} + +/** One reference-counted observation stream. */ +interface ObservationEntry { + refs: number + stopped: boolean + dispose: () => Promise +} + +/** Remote face the observation runner drives. */ +type ActivityStreamRemote = Pick & { readonly activity: ActivityRemote } + +/** Owns the bare activity snapshot and per-activity observation streams. */ +export class ClientActivityFeed extends Service implements IActivityFeed { + readonly state: ActivityFeedSource + private readonly entries = new Map() + + /** + * @param ctx - client root Context. + * @param remote - generated activity namespace plus the Gateway stream factory. + * @param model - shared client activity model. + */ + constructor( + ctx: Context, + private readonly remote: ActivityStreamRemote, + private readonly model: ClientActivityModel, + ) { + super(ctx, 'activityFeed') + this.state = model + ctx.effect(() => () => { + const open = [...this.entries.values()] + this.entries.clear() + for (const entry of open) { + entry.stopped = true + void entry.dispose() + } + }, 'activity-controller.client.observations') + } + + observe(id: ActivityId): () => void { + const key = String(id) + const existing = this.entries.get(key) + if (existing !== undefined && !existing.stopped) { + existing.refs += 1 + return this.releaser(key) + } + const entry = this.startObservation(id) + this.entries.set(key, entry) + return this.releaser(key) + } + + private releaser(key: string): () => void { + let released = false + return () => { + if (released) return + released = true + const entry = this.entries.get(key) + if (entry === undefined) return + entry.refs -= 1 + if (entry.refs > 0) return + this.entries.delete(key) + entry.stopped = true + void entry.dispose().then(() => { + // The key is the stringified branded id this releaser was minted for. + this.model.observeStopped(key as ActivityId) + }) + } + } + + private startObservation(id: ActivityId): ObservationEntry { + const name = `activity observation ${String(id)}` + const stream = this.remote.$stream({ + name, + open: (signal) => { + const from = this.model.cursorOf(id) + return this.remote.activity.observe( + { activityId: id, ...from !== undefined ? { from } : {} }, + signal, + ) + }, + // A premature end after the anchor is retryable (a Host reload closes the + // generation); resuming from the cursor loses nothing. An end before the + // anchor is terminal. + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${name} ended before settlement`) + : new Error(`${name} ended before its anchor`), + }) + const entry: ObservationEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + } + void (async () => { + try { + for await (const item of stream) { + const frame = item.value + if (frame.type === 'opened') { + this.model.observeOpened(id, frame) + item.accept() + continue + } + if (frame.type === 'output') { + this.model.observeOutput(id, frame) + continue + } + // Terminal status: leave the loop before the generation end is + // classified, then close the stream for good. + this.model.observeStatus(id, frame) + break + } + } catch (error) { + if (!entry.stopped) this.model.observeFailed(id, error) + } finally { + entry.stopped = true + void entry.dispose() + } + })() + return entry + } +} diff --git a/packages/api/activity-controller/src/feed.ts b/packages/api/activity-controller/src/feed.ts new file mode 100644 index 0000000000..34091dad8d --- /dev/null +++ b/packages/api/activity-controller/src/feed.ts @@ -0,0 +1,184 @@ +/** Reconnect-safe activity roster producer over the optional `ctx.activities` registry. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ActivityRegistry, ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import type { ActivityControlFrame, ActivityRow } from './types.ts' + +/** + * Project one registry snapshot into its browser-safe roster row. + * @param snapshot - fresh registry snapshot. + * @returns detached wire row for Remote consumers. + */ +export function activityRow(snapshot: ActivitySnapshot): ActivityRow { + return { + id: snapshot.id, + kind: snapshot.kind, + label: snapshot.label, + ...snapshot.ownerSession !== undefined ? { sessionId: snapshot.ownerSession } : {}, + ...snapshot.correlation !== undefined + ? { + correlation: { + ...snapshot.correlation.callId !== undefined ? { callId: snapshot.correlation.callId } : {}, + ...snapshot.correlation.jobId !== undefined ? { jobId: snapshot.correlation.jobId } : {}, + }, + } + : {}, + status: snapshot.status, + ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, + startedAt: snapshot.startedAt, + ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, + outputTotal: snapshot.outputTotal, + } +} + +/** One owner bucket: the exact live owner used for fenced reads plus its rows. */ +interface OwnerBucket { + /** Exact registered Agent; undefined for the unowned bucket. */ + readonly owner: Agent | undefined + rows: readonly ActivityRow[] +} + +const UNOWNED = '' + +/** + * Owns registry observation, the owner-bucketed roster mirror, and all active + * control-stream generations. The mirror also answers `ownerOf`, which + * observation streams use to read owned activities through the visibility + * fence with the exact owner the registry announced. + */ +export class ActivityFeed { + private readonly followers = new Set() + private readonly buckets = new Map() + private readonly registry: ActivityRegistry | undefined + + /** @param ctx - Host context; the activity registry stays optional. */ + constructor(ctx: Context) { + this.registry = ctx.get('activities') + if (this.registry === undefined) return + const registry = this.registry + this.refresh(undefined) + for (const agent of ctx.get('agents')?.list() ?? []) this.refresh(agent) + registry.onActivitiesChanged((owner) => { + this.refresh(owner) + const bucket = this.buckets.get(owner?.id ?? UNOWNED) + this.publish({ + type: 'rows', + ...owner !== undefined ? { sessionId: owner.id } : {}, + activities: bucket?.rows ?? [], + }) + }) + ctx.effect(() => () => { + for (const follower of this.followers) follower.close() + this.followers.clear() + }, 'activity-controller.feed') + } + + /** + * Read the complete current roster synchronously. + * @returns every visible row across all owner buckets. + */ + baseline(): readonly ActivityRow[] { + return [...this.buckets.values()].flatMap(bucket => bucket.rows) + } + + /** + * Resolve the exact owner the registry announced for one activity, for + * fenced reads. `undefined` covers both unowned activities and ids this + * mirror has never seen — the registry itself settles unknown ids. + * @param id - activity id to resolve. + * @returns the exact live owner, or undefined. + */ + ownerOf(id: string): Agent | undefined { + for (const bucket of this.buckets.values()) { + if (bucket.rows.some(row => String(row.id) === id)) return bucket.owner + } + return undefined + } + + /** + * Open one generation beginning with a complete baseline. + * @param signal - generation cancellation. + * @returns baseline followed by whole-bucket replacement frames. + */ + async *follow(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + const follower = new ActivityFollower() + this.followers.add(follower) + try { + yield { type: 'baseline', activities: this.baseline() } + yield* follower.read(signal) + } finally { + this.followers.delete(follower) + follower.close() + } + } + + /** Re-read one owner's visible rows into its bucket; an emptied bucket is dropped. */ + private refresh(owner: Agent | undefined): void { + /* v8 ignore next -- refresh is only reachable through a live registry's change feed. */ + if (this.registry === undefined) return + const rows = this.registry.list(owner) + .filter(snapshot => owner === undefined + ? snapshot.ownerSession === undefined + : snapshot.ownerSession === owner.id) + .map(activityRow) + const key = owner?.id ?? UNOWNED + if (rows.length === 0) { + this.buckets.delete(key) + return + } + this.buckets.set(key, { owner, rows }) + } + + private publish(frame: Exclude): void { + for (const follower of this.followers) follower.push(frame) + } +} + +/* jscpd:ignore-start -- mirrors WorkspaceFeed's follower fan-out by design; both serve baseline-plus-increment Remote streams. */ +class ActivityFollower { + private readonly frames: ActivityControlFrame[] = [] + private waiting: (() => void) | undefined + private closed = false + + push(frame: ActivityControlFrame): void { + /* v8 ignore next -- closed followers are removed before later publication can reach them. */ + if (this.closed) return + this.frames.push(frame) + this.waiting?.() + } + + close(): void { + if (this.closed) return + this.closed = true + this.waiting?.() + } + + async *read(signal: AbortSignal): AsyncIterable { + while (!this.closed && !signal.aborted) { + const frame = this.frames.shift() + if (frame !== undefined) { + yield frame + continue + } + await this.wait(signal) + } + } + + private wait(signal: AbortSignal): Promise { + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + /* v8 ignore next -- one read owns the sole installed wait callback. */ + if (this.waiting === finish) this.waiting = undefined + resolve() + } + this.waiting = finish + signal.addEventListener('abort', finish, { once: true }) + /* v8 ignore next -- native signals and the private queue cannot change during this synchronous setup. */ + if (signal.aborted || this.closed || this.frames.length > 0) finish() + }) + } +} +/* jscpd:ignore-end */ diff --git a/packages/api/activity-controller/src/index.ts b/packages/api/activity-controller/src/index.ts new file mode 100644 index 0000000000..a2d62c4a33 --- /dev/null +++ b/packages/api/activity-controller/src/index.ts @@ -0,0 +1,104 @@ +/** + * Host Activity Remote owner: the live-activity roster stream and per-activity + * output observation. A pure read surface over the optional `ctx.activities` + * registry — it starts nothing, cancels nothing, and never touches the + * model-facing consuming cursors. Compositions without the registry serve an + * empty roster and refuse observation with a stable error. + */ + +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-activity' +import { ActivityFeed } from './feed.ts' +import { observeActivity } from './observe.ts' +import type { ActivityControlFrame, ActivityObserveFrame, ActivityObserveRequest } from './types.ts' + +export type * from './types.ts' + +/** Default coalescing window between observation reads, in milliseconds. */ +const DEFAULT_FLUSH_MS = 100 + +/** Default soft byte budget per observation output frame. */ +const DEFAULT_MAX_FRAME_BYTES = 64 * 1024 + +/** Configuration for the Activity Remote owner. */ +export interface Config { + /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ + flushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + maxFrameBytes?: number +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host Activity read API and Remote namespace owner. */ + activityController: ActivityController + } +} + +/** Host service backing the generated `ctx.remote.activity` namespace. */ +export class ActivityController extends TypertRemoteService { + static inject = ['typert'] + + static Config: z = z.object({ + flushMs: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_FLUSH_MS), + maxFrameBytes: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_MAX_FRAME_BYTES), + }) + + private readonly feed: ActivityFeed + private readonly flushMs: number + private readonly maxFrameBytes: number + + /** + * @param ctx - Host context; the activity registry stays optional. + * @param config - validated coalescing and framing bounds. + */ + constructor(ctx: Context, config: Config) { + super(ctx, 'activityController', { namespace: 'activity' }) + this.flushMs = (config as Required).flushMs + this.maxFrameBytes = (config as Required).maxFrameBytes + this.feed = new ActivityFeed(ctx) + } + + /** + * Stream a complete roster baseline followed by whole-bucket replacements. + * @param signal - generation cancellation. + * @returns baseline followed by per-owner roster replacement frames. + */ + @Remote({ mode: 'stream' }) + control(signal: AbortSignal): AsyncIterable { + return this.feed.follow(signal) + } + + /** + * Stream one activity's retained output from an absolute byte offset, then + * its terminal status once settled and drained. Non-consuming: the + * model-facing cursors never observe these reads. + * @param request - target activity and optional resume offset. + * @param signal - generation cancellation. + * @returns anchor, coalesced output frames, and the terminal status. + */ + @Remote({ mode: 'stream' }) + observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable { + const registry = this.ctx.get('activities') + if (registry === undefined) { + throw new Error('activity observation unavailable: no activity registry is loaded (load @deepseek-ai/dsh-activity-local)') + } + return observeActivity(registry, request, { + flushMs: this.flushMs, + maxFrameBytes: this.maxFrameBytes, + ownerOf: id => this.feed.ownerOf(id), + }, signal) + } +} + +export default ActivityController diff --git a/packages/api/activity-controller/src/invariant.ts b/packages/api/activity-controller/src/invariant.ts new file mode 100644 index 0000000000..f85e7460b8 --- /dev/null +++ b/packages/api/activity-controller/src/invariant.ts @@ -0,0 +1,23 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-activity-controller/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-api-activity-controller' + +/** Cordis companion plugin name. */ +export const name = 'api-activity-controller-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: `@deepseek-ai/dsh-activity/invariant` owns snapshot relations; every roster + * generation is a full projection and observation reads are validated by the registry itself. + */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/api/activity-controller/src/observe.ts b/packages/api/activity-controller/src/observe.ts new file mode 100644 index 0000000000..f793b11496 --- /dev/null +++ b/packages/api/activity-controller/src/observe.ts @@ -0,0 +1,178 @@ +/** Per-activity observation generations: anchor, coalesced output, terminal status. */ + +import type { ActivityRegistry, ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import { ActivityId } from '@deepseek-ai/dsh-activity/brand' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ActivityObserveFrame, ActivityObserveRequest, ActivityWireChunk } from './types.ts' + +/** Cadence and framing bounds for one observation generation. */ +export interface ObserveOptions { + /** Coalescing window after a wake before reading, in milliseconds. */ + readonly flushMs: number + /** + * Soft byte budget per `output` frame. Accumulated chunks flush once the + * budget is met; one chunk larger than the budget ships whole (the + * registry's retention cap is the hard bound on any single chunk). + */ + readonly maxFrameBytes: number + /** Resolve the exact owner for fenced reads (the roster mirror's `ownerOf`). */ + readonly ownerOf: (id: string) => Agent | undefined +} + +function isTerminal(status: ActivitySnapshot['status']): boolean { + return status !== 'running' +} + +/** Wake-flag waiter: a wake between waits is never lost. */ +class OutputWaiter { + private dirty = false + private resolve: (() => void) | undefined + + wake(): void { + this.dirty = true + this.resolve?.() + } + + /** + * Resolve on the next wake, immediately when one already arrived, or on abort. + * @param signal - generation cancellation. + */ + wait(signal: AbortSignal): Promise { + if (this.dirty || signal.aborted) { + this.dirty = false + return Promise.resolve() + } + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + /* v8 ignore next -- one wait owns the sole installed resolver. */ + if (this.resolve === finish) this.resolve = undefined + this.dirty = false + resolve() + } + this.resolve = finish + signal.addEventListener('abort', finish, { once: true }) + }) + } +} + +function sleep(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + const timer = setTimeout(done, ms) + function done(): void { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + signal.addEventListener('abort', done, { once: true }) + }) +} + +/** + * Stream one activity's retained output from an absolute offset: one `opened` + * anchor, coalesced `output` frames as the stream advances, then one terminal + * `status` after the settled activity is drained, after which the generation + * closes normally. Reads are non-consuming; reconnecting callers resume by + * passing the last frame's `next` as `from`. + * @param registry - the live activity registry. + * @param request - target activity and optional resume offset. + * @param options - cadence, framing bounds, and owner resolution. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the observation frame sequence for one generation. + */ +export async function* observeActivity( + registry: ActivityRegistry, + request: ActivityObserveRequest, + options: ObserveOptions, + signal: AbortSignal, +): AsyncIterable { + if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) { + throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`) + } + signal.throwIfAborted() + const id = ActivityId(String(request.activityId)) + const waiter = new OutputWaiter() + // Subscribe before the first read so an append between the anchor read and + // the wait cannot be missed. + const unsubscribe = registry.onOutput((changed) => { + if (changed === id) waiter.wake() + }) + try { + const caller = options.ownerOf(String(id)) + let snapshot = registry.get(id, caller) + let cursor = request.from ?? snapshot.outputEarliest + yield { + type: 'opened', + activityId: id, + from: cursor, + earliest: snapshot.outputEarliest, + total: snapshot.outputTotal, + status: snapshot.status, + ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, + } + while (!signal.aborted) { + const read = registry.read(id, cursor, caller) + if (read.chunks.length > 0) { + yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes) + } + cursor = read.next + snapshot = registry.get(id, caller) + if (isTerminal(snapshot.status) && cursor >= snapshot.outputTotal) { + yield { + type: 'status', + status: snapshot.status, + ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, + // Settlement always stamps finishedAt; the guard only discharges the optional field type. + /* v8 ignore next */ + ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, + } + return + } + await waiter.wait(signal) + // Let a burst accumulate so producer chatter becomes bounded frames. + await sleep(options.flushMs, signal) + } + } finally { + unsubscribe() + } +} + +/** Split one read into frames along the soft per-frame byte budget. */ +function* outputFrames( + chunks: readonly ActivityWireChunk[], + next: number, + lossy: boolean, + maxFrameBytes: number, +): Iterable { + let batch: ActivityWireChunk[] = [] + let batchBytes = 0 + let flaggedLossy = lossy + for (const chunk of chunks) { + batch.push(chunk) + batchBytes += Buffer.byteLength(chunk.text, 'utf8') + if (batchBytes >= maxFrameBytes) { + const last = batch[batch.length - 1] + /* v8 ignore start -- a non-empty batch always has a last chunk; the arm only discharges noUncheckedIndexedAccess. */ + const end = last === undefined ? next : last.at + Buffer.byteLength(last.text, 'utf8') + /* v8 ignore stop */ + yield { + type: 'output', + chunks: batch, + next: end, + ...flaggedLossy ? { lossy: true as const } : {}, + } + flaggedLossy = false + batch = [] + batchBytes = 0 + } + } + if (batch.length > 0 || flaggedLossy) { + yield { + type: 'output', + chunks: batch, + next, + ...flaggedLossy ? { lossy: true as const } : {}, + } + } +} diff --git a/packages/api/activity-controller/src/types.ts b/packages/api/activity-controller/src/types.ts new file mode 100644 index 0000000000..2116d4477b --- /dev/null +++ b/packages/api/activity-controller/src/types.ts @@ -0,0 +1,104 @@ +/** Browser-safe roster and observation vocabulary for the Activity Remote. */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { CallId } from '@deepseek-ai/dsh-llm/brand' + +export type { ActivityId } from '@deepseek-ai/dsh-activity/brand' + +/** Lifecycle state carried on the wire; mirrors the registry's status union. */ +export type ActivityRowStatus = 'running' | 'completed' | 'failed' | 'killed' + +/** Links one roster row to the tool call and background job for the same work. */ +export interface ActivityRowCorrelation { + /** Tool call that started the work, when a tool produced it. */ + readonly callId?: CallId + /** Background job carrying the model-facing control surface, when one exists. */ + readonly jobId?: JobId +} + +/** + * Browser-safe activity row. `kind` is `string` on the wire because the kind + * map is merge-extensible by producer plugins; presentation falls through a + * documented default for an unrecognized kind. + */ +export interface ActivityRow { + readonly id: ActivityId + readonly kind: string + readonly label: string + /** Owning session for grouping; absent for an unowned activity, visible to every session. */ + readonly sessionId?: SessionId + readonly correlation?: ActivityRowCorrelation + readonly status: ActivityRowStatus + readonly detail?: string + readonly startedAt: number + readonly finishedAt?: number + /** Total UTF-8 bytes the activity has produced so far. */ + readonly outputTotal: number +} + +/** + * Roster stream frames. Each generation starts with exactly one complete + * baseline; later frames replace one owner bucket wholesale (absent + * `sessionId` = the unowned bucket), so start, settlement, removal, and a + * second browser tab all converge through one authoritative value. + */ +export type ActivityControlFrame = + | { readonly type: 'baseline'; readonly activities: readonly ActivityRow[] } + | { readonly type: 'rows'; readonly sessionId?: SessionId; readonly activities: readonly ActivityRow[] } + +/** Opens one observation stream over a single activity's retained output. */ +export interface ActivityObserveRequest { + readonly activityId: ActivityId + /** + * Absolute byte offset to resume from — a previous frame's `next`. Omitted + * starts at the oldest retained byte. + */ + readonly from?: number +} + +/** One retained output chunk on the wire. `channel` widens to string like `kind`. */ +export interface ActivityWireChunk { + /** Absolute offset of the chunk's first byte. */ + readonly at: number + readonly text: string + readonly channel?: string + /** Bytes immediately before this chunk were lost at the producer or to retention. */ + readonly gapBefore?: true +} + +/** + * Observation stream frames: one `opened` anchor, then coalesced `output` + * batches, then — once the activity has settled and its retained output is + * drained — one terminal `status`, after which the stream closes normally. + * Status rides the same stream as output so settlement can never race a + * still-open output channel. + */ +export type ActivityObserveFrame = + | { + readonly type: 'opened' + readonly activityId: ActivityId + /** Offset the first `output` frame continues from. */ + readonly from: number + /** Oldest retained byte at open time; `from < earliest` means the head is gone. */ + readonly earliest: number + /** Total bytes produced at open time. */ + readonly total: number + readonly status: ActivityRowStatus + readonly detail?: string + } + | { + readonly type: 'output' + readonly chunks: readonly ActivityWireChunk[] + /** Offset to resume from after this frame. */ + readonly next: number + /** Bytes between the requested offset and `chunks` were already evicted. */ + readonly lossy?: true + } + | { + readonly type: 'status' + readonly status: ActivityRowStatus + readonly detail?: string + readonly finishedAt?: number + } diff --git a/packages/api/activity-controller/tests/activity-controller.host.spec.ts b/packages/api/activity-controller/tests/activity-controller.host.spec.ts new file mode 100644 index 0000000000..757a0af115 --- /dev/null +++ b/packages/api/activity-controller/tests/activity-controller.host.spec.ts @@ -0,0 +1,306 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import { ActivityId } from '@deepseek-ai/dsh-activity' +import { ActivityFeed } from '../src/feed.ts' +import { observeActivity } from '../src/observe.ts' +import type { ObserveOptions } from '../src/observe.ts' +import type { ActivityControlFrame, ActivityObserveFrame } from '../src/types.ts' + +async function harness(withRegistry = true) { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + if (withRegistry) await ctx.plugin(LocalActivityRegistry, { retainBytes: 64, settledRetainBytes: 64 }) + return ctx +} + +function registerAgent(ctx: Context, rawId: string): Agent { + const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(rawId) + const session = Session.create(id) + const agent = { + id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scopeFiber.ctx, + } as unknown as Agent + ctx.agents.register(agent) + return agent +} + +/** Read frames until `count` arrive or the generator finishes, then abort. */ +async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const frames: Frame[] = [] + const iterator = iterable[Symbol.asyncIterator]() + try { + while (frames.length < count) { + const step = await iterator.next() + if (step.done) return frames + frames.push(step.value) + } + } finally { + abort.abort() + await iterator.next().catch(() => undefined) + } + return frames +} + +const OBSERVE: Omit = { flushMs: 5, maxFrameBytes: 1024 } + +describe('ActivityFeed roster', () => { + it('serves an empty baseline without the registry', async () => { + const ctx = await harness(false) + const feed = new ActivityFeed(ctx) + expect(feed.baseline()).toEqual([]) + }) + + it('projects owned and unowned rows into the baseline with correlation and no live objects', async () => { + const ctx = await harness() + const owner = registerAgent(ctx, 'alice') + ctx.activities.open({ kind: 'bash', label: 'unowned build' }) + ctx.activities.open({ + kind: 'bash', + label: 'owned build', + owner, + correlation: { jobId: 'bash-7' as never }, + }) + const feed = new ActivityFeed(ctx) + const rows = feed.baseline() + expect(rows).toHaveLength(2) + const owned = rows.find(row => row.sessionId !== undefined) + expect(owned?.sessionId).toBe('alice') + expect(owned?.correlation).toEqual({ jobId: 'bash-7' }) + expect(owned?.status).toBe('running') + for (const row of rows) { + expect(Object.keys(row)).not.toContain('owner') + expect(row.outputTotal).toBe(0) + } + }) + + it('pushes whole-bucket frames for open, detail, settlement, and removal', async () => { + const ctx = await harness() + const owner = registerAgent(ctx, 'alice') + const feed = new ActivityFeed(ctx) + const abort = new AbortController() + const frames = collect(feed.follow(abort.signal), 4, abort) + await new Promise(resolve => setTimeout(resolve, 0)) + const handle = ctx.activities.open({ kind: 'bash', label: 'work', owner }) + handle.updateDetail('halfway') + handle.end({ status: 'completed', detail: 'exit code: 0' }) + const seen = await frames + expect(seen[0]).toEqual({ type: 'baseline', activities: [] }) + const rows = seen.slice(1) as Extract[] + expect(rows.every(frame => frame.sessionId === 'alice')).toBe(true) + expect(rows[0]?.activities[0]?.status).toBe('running') + expect(rows[1]?.activities[0]?.detail).toBe('halfway') + expect(rows[2]?.activities[0]?.status).toBe('completed') + expect(feed.ownerOf(String(rows[0]!.activities[0]!.id))).toBe(owner) + }) + + it('projects callId-only correlation and serves rosters without an agent registry', async () => { + const bare = new Context() + await bare.plugin(LocalActivityRegistry) + bare.activities.open({ kind: 'bash', label: 'lone', correlation: { callId: 'call-1' as never } }) + const feed = new ActivityFeed(bare) + expect(feed.baseline()[0]?.correlation).toEqual({ callId: 'call-1' }) + }) + + it('pushes an empty bucket frame when owner disposal removes the last row', async () => { + const ctx = await harness() + const owner = registerAgent(ctx, 'alice') + const feed = new ActivityFeed(ctx) + ctx.activities.open({ kind: 'bash', label: 'doomed', owner }) + const abort = new AbortController() + const frames = collect(feed.follow(abort.signal), 3, abort) + await new Promise(resolve => setTimeout(resolve, 0)) + await (owner.ctx as unknown as { fiber: { dispose(): Promise } }).fiber?.dispose?.() + // Owner-disposal removal reaches the feed as an emptied bucket. + await new Promise(resolve => setTimeout(resolve, 10)) + abort.abort() + const seen = await frames + const last = seen.at(-1) as Extract | undefined + if (last !== undefined && last.type === 'rows') expect(Array.isArray(last.activities)).toBe(true) + }) + + it('resolves undefined for unowned and unknown activities', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'shared' }) + expect(feed.ownerOf(String(handle.id))).toBeUndefined() + expect(feed.ownerOf('bash-999')).toBeUndefined() + }) +}) + +describe('observeActivity', () => { + async function observed(ctx: Context, feed: ActivityFeed, request: { activityId: ActivityId; from?: number }) { + const abort = new AbortController() + const frames: ActivityObserveFrame[] = [] + const stream = observeActivity( + ctx.activities, + request, + { ...OBSERVE, ownerOf: id => feed.ownerOf(id) }, + abort.signal, + ) + const done = (async () => { + for await (const frame of stream) frames.push(frame) + })() + return { abort, frames, done } + } + + it('anchors, streams coalesced output, and closes with the terminal status', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('early ') + const { frames, done } = await observed(ctx, feed, { activityId: handle.id }) + await new Promise(resolve => setTimeout(resolve, 20)) + handle.append('live', { channel: 'stderr' }) + await new Promise(resolve => setTimeout(resolve, 20)) + handle.end({ status: 'completed', detail: 'exit code: 0' }) + await done + + expect(frames[0]).toMatchObject({ type: 'opened', from: 0, earliest: 0, total: 6, status: 'running' }) + const outputs = frames.filter(frame => frame.type === 'output') + const text = outputs.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') + expect(text).toBe('early live') + expect(outputs.at(-1)?.next).toBe(10) + expect(outputs.flatMap(frame => frame.chunks).find(chunk => chunk.text === 'live')?.channel).toBe('stderr') + expect(frames.at(-1)).toMatchObject({ type: 'status', status: 'completed', detail: 'exit code: 0' }) + }) + + it('resumes from an explicit offset and flags an evicted resume lossy', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'spam' }) + handle.append('a'.repeat(60)) + handle.append('b'.repeat(60)) + handle.end({ status: 'completed' }) + + const fresh = await observed(ctx, feed, { activityId: handle.id, from: 0 }) + await fresh.done + const output = fresh.frames.find(frame => frame.type === 'output') + expect(output?.lossy).toBe(true) + expect(output?.chunks.map(chunk => chunk.text).join('')).toBe('b'.repeat(60)) + + const resumed = await observed(ctx, feed, { activityId: handle.id, from: 120 }) + await resumed.done + expect(resumed.frames.some(frame => frame.type === 'output')).toBe(false) + expect(resumed.frames.at(-1)?.type).toBe('status') + }) + + it('splits oversized reads along the frame budget', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const registry = ctx.activities + const handle = registry.open({ kind: 'bash', label: 'wide' }) + const abort = new AbortController() + handle.append('x'.repeat(30)) + handle.append('y'.repeat(30)) + handle.end({ status: 'completed' }) + const frames = await collect( + observeActivity(registry, { activityId: handle.id }, { + flushMs: 5, + maxFrameBytes: 30, + ownerOf: id => feed.ownerOf(id), + }, abort.signal), + 5, + abort, + ) + const outputs = frames.filter(frame => frame.type === 'output') + expect(outputs).toHaveLength(2) + expect(outputs[0]?.next).toBe(30) + expect(outputs[1]?.next).toBe(60) + }) + + it('carries the lossy flag on the first frame of a split evicted read', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const registry = ctx.activities + const handle = registry.open({ kind: 'bash', label: 'evicted split' }) + handle.append('a'.repeat(40)) + handle.append('b'.repeat(40)) + handle.append('c'.repeat(40)) + handle.end({ status: 'completed' }) + const abort = new AbortController() + const frames = await collect( + observeActivity(registry, { activityId: handle.id, from: 0 }, { + flushMs: 5, + maxFrameBytes: 40, + ownerOf: id => feed.ownerOf(id), + }, abort.signal), + 5, + abort, + ) + const outputs = frames.filter(frame => frame.type === 'output') + expect(outputs[0]?.lossy).toBe(true) + expect(outputs[1]?.lossy).toBeUndefined() + }) + + it('reads owned activities through the roster owner and rejects bad offsets and unknown ids', async () => { + const ctx = await harness() + const owner = registerAgent(ctx, 'alice') + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'secret', owner }) + handle.append('classified') + handle.end({ status: 'completed' }) + const { frames, done } = await observed(ctx, feed, { activityId: handle.id }) + await done + expect(frames.some(frame => frame.type === 'output' + && frame.chunks.some(chunk => chunk.text === 'classified'))).toBe(true) + + const abort = new AbortController() + await expect(collect( + observeActivity(ctx.activities, { activityId: handle.id, from: -1 }, { + ...OBSERVE, + ownerOf: id => feed.ownerOf(id), + }, abort.signal), + 1, + abort, + )).rejects.toThrow(/invalid observe offset/) + + const abortUnknown = new AbortController() + await expect(collect( + observeActivity(ctx.activities, { activityId: ActivityId('bash-999') }, { + ...OBSERVE, + ownerOf: id => feed.ownerOf(id), + }, abortUnknown.signal), + 1, + abortUnknown, + )).rejects.toThrow(/unknown activity/) + }) + + it('ignores output signals for other activities while waiting', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const watched = ctx.activities.open({ kind: 'bash', label: 'watched' }) + const noisy = ctx.activities.open({ kind: 'bash', label: 'noisy' }) + const { abort, frames, done } = await observed(ctx, feed, { activityId: watched.id }) + await new Promise(resolve => setTimeout(resolve, 15)) + noisy.append('unrelated') + await new Promise(resolve => setTimeout(resolve, 15)) + watched.end({ status: 'completed' }) + await done + abort.abort() + expect(frames.filter(frame => frame.type === 'output')).toEqual([]) + }) + + it('stops cleanly on abort while waiting for output', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'idle' }) + const { abort, frames, done } = await observed(ctx, feed, { activityId: handle.id }) + await new Promise(resolve => setTimeout(resolve, 20)) + abort.abort() + await done + expect(frames[0]?.type).toBe('opened') + expect(frames.at(-1)?.type).not.toBe('status') + }) +}) diff --git a/packages/api/activity-controller/tests/client-apply.client.spec.ts b/packages/api/activity-controller/tests/client-apply.client.spec.ts new file mode 100644 index 0000000000..231703ba13 --- /dev/null +++ b/packages/api/activity-controller/tests/client-apply.client.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' +import { apply, createActivityRosterStream } from '../src/client/index.ts' +import { ClientActivityModel } from '../src/client/model.ts' +import type { ActivityControlFrame, ActivityId, ActivityRow } from '../src/types.ts' + +function row(): ActivityRow { + return { + id: 'bash-1' as ActivityId, + kind: 'bash', + label: 'build', + status: 'running', + startedAt: 1, + outputTotal: 0, + } +} + +/** One scripted roster stream: frames pushed by the test, never reopened. */ +class FakeStream { + disposed = false + private readonly frames: ActivityControlFrame[] = [] + private waiter: (() => void) | undefined + private failure: Error | undefined + + push(frame: ActivityControlFrame): void { + this.frames.push(frame) + this.waiter?.() + } + + poison(error: Error): void { + this.failure = error + this.waiter?.() + } + + dispose(): Promise { + this.disposed = true + this.waiter?.() + return Promise.resolve() + } + + async *[Symbol.asyncIterator]() { + while (!this.disposed) { + if (this.failure !== undefined) throw this.failure + const frame = this.frames.shift() + if (frame === undefined) { + await new Promise((resolve) => { this.waiter = resolve }) + continue + } + yield { generation: 1, value: frame, signal: new AbortController().signal, accept: () => {} } + } + } +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) + +describe('activity-controller client plugin', () => { + it('installs ctx.activityFeed and folds roster frames from the control stream', async () => { + const ctx = new Context() + const streams: { options: { open: (signal: AbortSignal) => unknown; ended: (accepted: boolean) => Error }; stream: FakeStream }[] = [] + const controlCalls: AbortSignal[] = [] + ctx.provide('remote', { + $stream: (options: never) => { + const stream = new FakeStream() + streams.push({ options, stream }) + return stream + }, + activity: { + control: (signal: AbortSignal) => { + controlCalls.push(signal) + return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } + }, + observe: () => { throw new Error('not exercised') }, + }, + } as never) + const fiber = ctx.plugin({ inject: ['remote'], apply }) + await fiber + + streams[0]!.stream.push({ type: 'baseline', activities: [row()] }) + streams[0]!.stream.push({ type: 'rows', activities: [] }) + await tick() + const snapshot = ctx.activityFeed.state.getSnapshot() + expect(snapshot.phase).toBe('ready') + expect(snapshot.rowsBySession['']).toBeUndefined() + + // The generation opener and end classification belong to the domain. + streams[0]!.options.open(new AbortController().signal) + expect(controlCalls).toHaveLength(1) + expect(streams[0]!.options.ended(true)).toBeInstanceOf(RemoteStreamCarrierError) + expect(streams[0]!.options.ended(false)).toBeInstanceOf(Error) + + // A terminal roster failure keeps the last snapshot in place. + streams[0]!.stream.poison(new Error('roster stream broke')) + await tick() + expect(ctx.activityFeed.state.getSnapshot().phase).toBe('ready') + + await fiber.dispose() + expect(streams[0]!.stream.disposed).toBe(true) + }) + + it('keeps the last roster on a terminal stream failure', async () => { + const model = new ClientActivityModel() + const remote = { + $stream: () => new FakeStream(), + activity: { control: () => { throw new Error('unused') }, observe: () => { throw new Error('unused') } }, + } + const stream = createActivityRosterStream(remote as never, model) + model.replaceBaseline([row()]) + stream.start() + await stream.dispose() + expect(model.getSnapshot().rowsBySession['']?.[0]?.label).toBe('build') + }) +}) diff --git a/packages/api/activity-controller/tests/client-model.client.spec.ts b/packages/api/activity-controller/tests/client-model.client.spec.ts new file mode 100644 index 0000000000..59ec9067c5 --- /dev/null +++ b/packages/api/activity-controller/tests/client-model.client.spec.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { ClientActivityModel } from '../src/client/model.ts' +import type { ActivityId, ActivityRow } from '../src/types.ts' + +function row(over: Partial = {}): ActivityRow { + return { + id: 'bash-1' as ActivityId, + kind: 'bash', + label: 'build', + sessionId: 'alice' as ActivityRow['sessionId'], + status: 'running', + startedAt: 1, + outputTotal: 0, + ...over, + } as ActivityRow +} + +/** A row with no owner session, as the unowned bucket carries them. */ +function unownedRow(over: Partial = {}): ActivityRow { + const { sessionId: _drop, ...rest } = row(over) + return rest +} + +const ID = 'bash-1' as ActivityId + +function opened(model: ClientActivityModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void { + model.observeOpened(ID, { + type: 'opened', + activityId: ID, + from: 0, + earliest: 0, + total: 0, + status: 'running', + ...over, + }) +} + +describe('ClientActivityModel roster', () => { + it('folds baselines into per-session buckets and reports readiness', () => { + const model = new ClientActivityModel() + expect(model.getSnapshot().phase).toBe('pending') + model.replaceBaseline([row(), unownedRow({ id: 'workflow-1' as ActivityId })]) + const snapshot = model.getSnapshot() + expect(snapshot.phase).toBe('ready') + expect(snapshot.rowsBySession['alice']).toHaveLength(1) + expect(snapshot.rowsBySession['']).toHaveLength(1) + }) + + it('replaces buckets wholesale and drops emptied keys', () => { + const model = new ClientActivityModel() + model.replaceBaseline([row()]) + model.replaceBucket('alice', [row({ status: 'completed', finishedAt: 2 })]) + expect(model.getSnapshot().rowsBySession['alice']?.[0]?.status).toBe('completed') + model.replaceBucket('alice', []) + expect('alice' in model.getSnapshot().rowsBySession).toBe(false) + model.replaceBucket(undefined, [unownedRow()]) + expect(model.getSnapshot().rowsBySession['']).toHaveLength(1) + }) + + it('keeps snapshot identity stable between changes and notifies subscribers', () => { + const model = new ClientActivityModel() + let notified = 0 + const unsubscribe = model.subscribe(() => { notified += 1 }) + const before = model.getSnapshot() + expect(model.getSnapshot()).toBe(before) + model.replaceBaseline([row()]) + expect(notified).toBe(1) + expect(model.getSnapshot()).not.toBe(before) + unsubscribe() + model.replaceBucket('alice', []) + expect(notified).toBe(1) + }) +}) + +describe('ClientActivityModel observation', () => { + it('accumulates output, advances the resume cursor, and settles on status', () => { + const model = new ClientActivityModel() + opened(model) + expect(model.cursorOf(ID)).toBe(0) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'a' }, { at: 1, text: 'b' }], next: 2 }) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 2, text: 'c' }], next: 3 }) + expect(model.cursorOf(ID)).toBe(3) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text).toBe('abc') + expect(view?.streaming).toBe(true) + model.observeStatus(ID, { type: 'status', status: 'completed', detail: 'exit code: 0' }) + const settled = model.getSnapshot().observed[String(ID)] + expect(settled?.streaming).toBe(false) + expect(settled?.status).toBe('completed') + expect(settled?.detail).toBe('exit code: 0') + }) + + it('marks gaps from lossy frames, gap chunks, and a resume behind the retained head', () => { + const model = new ClientActivityModel() + opened(model, { from: 4, earliest: 8, total: 10 }) + expect(model.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + const clean = new ClientActivityModel() + opened(clean) + clean.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'x', gapBefore: true }], next: 1 }) + expect(clean.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + const lossy = new ClientActivityModel() + opened(lossy) + lossy.observeOutput(ID, { type: 'output', chunks: [], next: 5, lossy: true }) + expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + }) + + it('bounds the render tail without splitting a surrogate pair', () => { + const model = new ClientActivityModel() + opened(model) + const emoji = '😀'.repeat((64 * 1024) + 8) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: emoji }], next: emoji.length * 2 }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.gapBefore).toBe(true) + expect(view!.text.length).toBeLessThanOrEqual(128 * 1024) + // The bound landed between pairs: the surviving text still round-trips. + expect(/^(?:😀)+$/u.test(view!.text)).toBe(true) + }) + + it('carries the anchor detail and trims a plain-ASCII tail without a boundary shift', () => { + const model = new ClientActivityModel() + model.observeOpened(ID, { + type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running', detail: 'exit soon', + }) + expect(model.getSnapshot().observed[String(ID)]?.detail).toBe('exit soon') + const long = 'x'.repeat((128 * 1024) + 5) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: long }], next: long.length }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text.length).toBe(128 * 1024) + expect(view?.gapBefore).toBe(true) + }) + + it('advances the cut past a low surrogate landing exactly on the bound', () => { + const model = new ClientActivityModel() + opened(model) + // 'z' + one emoji + odd ASCII tail puts a low surrogate exactly at the cut index. + const text = 'z😀' + 'a'.repeat((128 * 1024) - 1) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text }], next: text.length }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text.length).toBe((128 * 1024) - 1) + expect(view?.text.startsWith('a')).toBe(true) + }) + + it('preserves accumulated text across a reconnect anchor and clears on stop', () => { + const model = new ClientActivityModel() + opened(model) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'kept' }], next: 4 }) + opened(model, { from: 4, earliest: 0, total: 4 }) + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('kept') + model.observeStopped(ID) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + expect(model.cursorOf(ID)).toBeUndefined() + // A second stop is inert. + model.observeStopped(ID) + }) + + it('records a terminal stream failure on the live view', () => { + const model = new ClientActivityModel() + opened(model) + model.observeFailed(ID, new Error('carrier gone')) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.streaming).toBe(false) + expect(view?.error).toContain('carrier gone') + // A failure for an untracked id is inert. + model.observeFailed('bash-9' as ActivityId, new Error('ignored')) + }) +}) diff --git a/packages/api/activity-controller/tests/client-service.client.spec.ts b/packages/api/activity-controller/tests/client-service.client.spec.ts new file mode 100644 index 0000000000..c27e66cdd0 --- /dev/null +++ b/packages/api/activity-controller/tests/client-service.client.spec.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { ClientActivityModel } from '../src/client/model.ts' +import { ClientActivityFeed } from '../src/client/service.ts' +import type { ActivityId, ActivityObserveFrame } from '../src/types.ts' + +const ID = 'bash-1' as ActivityId + +/** One scripted logical stream: frames are pushed by the test, never reopened. */ +class FakeStream { + disposed = false + /** Model a carrier whose disposal surfaces as an iterator throw. */ + throwOnDispose = false + private readonly frames: ActivityObserveFrame[] = [] + private waiter: (() => void) | undefined + private failure: Error | undefined + readonly accepts: number[] = [] + + push(frame: ActivityObserveFrame): void { + this.frames.push(frame) + this.waiter?.() + } + + poison(error: Error): void { + this.failure = error + this.waiter?.() + } + + dispose(): Promise { + if (this.throwOnDispose && this.failure === undefined) { + this.failure = new Error('carrier tore down') + } else { + this.disposed = true + } + this.waiter?.() + return Promise.resolve() + } + + async *[Symbol.asyncIterator]() { + let generation = 1 + while (!this.disposed) { + if (this.failure !== undefined) throw this.failure + const frame = this.frames.shift() + if (frame === undefined) { + await new Promise((resolve) => { this.waiter = resolve }) + continue + } + yield { + generation, + value: frame, + signal: new AbortController().signal, + accept: () => { this.accepts.push(generation) }, + } + generation = 1 + } + } +} + +function bench() { + const ctx = new Context() + const model = new ClientActivityModel() + const streams: { options: { open: (signal: AbortSignal) => unknown }; stream: FakeStream }[] = [] + const observeCalls: unknown[] = [] + const remote = { + $stream: (options: { open: (signal: AbortSignal) => unknown }) => { + const stream = new FakeStream() + streams.push({ options, stream }) + return stream + }, + activity: { + observe: (request: unknown) => { + observeCalls.push(request) + return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } + }, + control: () => { throw new Error('not exercised') }, + }, + } + const feed = new ClientActivityFeed(ctx, remote as never, model) + return { ctx, model, feed, streams, observeCalls } +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) + +describe('ClientActivityFeed observation streams', () => { + it('feeds anchor, output, and terminal frames into the model, then closes', async () => { + const { model, feed, streams } = bench() + feed.observe(ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + stream.push({ type: 'output', chunks: [{ at: 0, text: 'hi' }], next: 2 }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('hi') + expect(stream.accepts).toHaveLength(1) + + stream.push({ type: 'status', status: 'completed' }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) + expect(stream.disposed).toBe(true) + }) + + it('shares one stream across observers and stops after the last release', async () => { + const { model, feed, streams } = bench() + const first = feed.observe(ID) + const second = feed.observe(ID) + expect(streams).toHaveLength(1) + streams[0]!.stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + + first() + first() + expect(streams[0]!.stream.disposed).toBe(false) + second() + await tick() + expect(streams[0]!.stream.disposed).toBe(true) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + }) + + it('opens a fresh stream for a re-observed activity and resumes from the model cursor', async () => { + const { feed, streams, observeCalls, model } = bench() + const stop = feed.observe(ID) + streams[0]!.stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + streams[0]!.stream.push({ type: 'output', chunks: [{ at: 0, text: 'abc' }], next: 3 }) + await tick() + // The generation opener reads the live cursor at open time. + streams[0]!.options.open(new AbortController().signal) + expect(observeCalls.at(-1)).toEqual({ activityId: ID, from: 3 }) + + stop() + await tick() + feed.observe(ID) + expect(streams).toHaveLength(2) + streams[1]!.options.open(new AbortController().signal) + // Stopped observation dropped the cursor: the fresh stream starts unanchored. + expect(observeCalls.at(-1)).toEqual({ activityId: ID }) + expect(model.cursorOf(ID)).toBeUndefined() + }) + + it('classifies a premature end as retryable only after the anchor', () => { + const { feed, streams } = bench() + feed.observe(ID) + const options = streams[0]!.options as unknown as { ended: (accepted: boolean) => Error } + expect(options.ended(true).name).toBe('RemoteStreamCarrierError') + expect(options.ended(false).name).toBe('Error') + }) + + it('records a consumer failure on the live view', async () => { + const { model, feed, streams } = bench() + feed.observe(ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + stream.poison(new Error('frame decode broke')) + await tick() + const view = model.getSnapshot().observed[String(ID)] + expect(view?.error).toContain('frame decode broke') + expect(view?.streaming).toBe(false) + }) + + it('treats a release after service disposal as inert and stays silent for post-stop failures', async () => { + const { ctx, model, feed, streams } = bench() + const stop = feed.observe(ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + // Poison wakes the consumer, but the synchronous stop lands first, so the + // failure arrives on an already-stopped entry and stays silent. + stream.poison(new Error('after stop')) + stop() + await tick() + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + + const second = feed.observe(ID) + await ctx.fiber.dispose() + // The disposal path already removed every entry; a live releaser whose + // entry is gone is inert. + second() + }) + + it('stays silent when the carrier teardown itself throws after a stop', async () => { + const { model, feed, streams } = bench() + const stop = feed.observe(ID) + const { stream } = streams[0]! + stream.throwOnDispose = true + stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + // The releaser marks the entry stopped, then its dispose surfaces as an + // iterator throw — which the consumer swallows for a stopped entry. + stop() + await tick() + expect(model.getSnapshot().observed[String(ID)]?.error).toBeUndefined() + }) + + it('records a terminal stream failure and service disposal closes live streams', async () => { + const { ctx, model, feed, streams } = bench() + feed.observe(ID) + const failing = streams[0]!.stream + failing.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + const iterator = failing[Symbol.asyncIterator]() + void iterator + // Simulate a terminal consumer failure by disposing the underlying stream: + // the consumer loop ends without a status frame and cleans up. + const spy = vi.spyOn(model, 'observeFailed') + await failing.dispose() + await tick() + + const other = 'bash-2' as ActivityId + feed.observe(other) + await ctx.fiber.dispose() + expect(streams.every(entry => entry.stream.disposed)).toBe(true) + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/api/activity-controller/tests/controller.host.spec.ts b/packages/api/activity-controller/tests/controller.host.spec.ts new file mode 100644 index 0000000000..4c3262f093 --- /dev/null +++ b/packages/api/activity-controller/tests/controller.host.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import { ActivityController } from '../src/index.ts' +import type { ActivityControlFrame, ActivityObserveFrame } from '../src/types.ts' + +async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const frames: Frame[] = [] + const iterator = iterable[Symbol.asyncIterator]() + try { + while (frames.length < count) { + const step = await iterator.next() + if (step.done) return frames + frames.push(step.value) + } + } finally { + abort.abort() + await iterator.next().catch(() => undefined) + } + return frames +} + +describe('ActivityController', () => { + it('closes live roster followers when the owning fiber unwinds', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalActivityRegistry) + const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) + const abort = new AbortController() + const iterator = controller.control(abort.signal)[Symbol.asyncIterator]() + expect((await iterator.next()).value).toMatchObject({ type: 'baseline' }) + const pending = iterator.next() + await ctx.fiber.dispose() + // The feed effect closed the follower, ending the generation cleanly. + expect((await pending).done).toBe(true) + }) + + it('coalesces bursts that land inside the flush window and carries the anchor detail', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalActivityRegistry) + const controller = new ActivityController(ctx, { flushMs: 30, maxFrameBytes: 1024 }) + const handle = ctx.activities.open({ kind: 'bash', label: 'burst' }) + handle.updateDetail('warming up') + const abort = new AbortController() + const frames: ActivityObserveFrame[] = [] + const done = (async () => { + for await (const frame of controller.observe({ activityId: handle.id }, abort.signal)) frames.push(frame) + })() + await new Promise(resolve => setTimeout(resolve, 10)) + handle.append('a') + await new Promise(resolve => setTimeout(resolve, 5)) + // Lands inside the flush sleep, so the waiter is already dirty at the next wait. + handle.append('b') + await new Promise(resolve => setTimeout(resolve, 80)) + handle.end({ status: 'completed' }) + await done + expect(frames[0]).toMatchObject({ type: 'opened', detail: 'warming up' }) + const text = frames.filter(frame => frame.type === 'output') + .flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') + expect(text).toBe('ab') + }) + + it('serves an empty baseline and refuses observation without the registry', async () => { + const ctx = new Context() + const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) + const abort = new AbortController() + const frames = await collect(controller.control(abort.signal), 1, abort) + expect(frames).toEqual([{ type: 'baseline', activities: [] }]) + expect(() => controller.observe({ activityId: 'bash-1' as never }, new AbortController().signal)) + .toThrow(/activity observation unavailable/) + }) + + it('streams the roster and one activity end to end over a live registry', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalActivityRegistry) + const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) + + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('hi') + handle.end({ status: 'completed', detail: 'exit code: 0' }) + + const rosterAbort = new AbortController() + const roster = await collect(controller.control(rosterAbort.signal), 1, rosterAbort) + expect(roster[0]).toMatchObject({ type: 'baseline' }) + expect((roster[0] as Extract).activities[0]?.label).toBe('echo') + + const observeAbort = new AbortController() + const observed = await collect( + controller.observe({ activityId: handle.id }, observeAbort.signal), + 3, + observeAbort, + ) + expect(observed.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) + }) +}) diff --git a/packages/api/activity-controller/tsconfig.client.json b/packages/api/activity-controller/tsconfig.client.json new file mode 100644 index 0000000000..d2934629b4 --- /dev/null +++ b/packages/api/activity-controller/tsconfig.client.json @@ -0,0 +1,46 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/model.ts", + "src/client/service.ts", + "src/types.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../gateway/tsconfig.client.json" + }, + { + "path": "../../client/connection/tsconfig.client.json" + }, + { + "path": "../../client/store" + }, + { + "path": "../../activity/activity" + }, + { + "path": "../../core/session" + }, + { + "path": "../../jobs/jobs" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../typert/protocol" + } + ] +} diff --git a/packages/api/activity-controller/tsconfig.host.json b/packages/api/activity-controller/tsconfig.host.json new file mode 100644 index 0000000000..4193d47109 --- /dev/null +++ b/packages/api/activity-controller/tsconfig.host.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/invariant.ts", + "src/types.ts", + "src/feed.ts", + "src/observe.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../activity/activity" }, + { "path": "../../core/agent" }, + { "path": "../../core/session" }, + { "path": "../../jobs/jobs" }, + { "path": "../../llm/llm" }, + { "path": "../../runtime-diagnostics/invariants" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/activity-controller/tsconfig.json b/packages/api/activity-controller/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/activity-controller/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/activity-controller/tsdown.config.ts b/packages/api/activity-controller/tsdown.config.ts new file mode 100644 index 0000000000..0552d1b1cf --- /dev/null +++ b/packages/api/activity-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-activity-controller', + ['lib/types/index.js', 'lib/types/invariant.js'], + { hostPhase: true }, +) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 0724012e77..05c8dbe032 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -61,6 +61,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", @@ -82,6 +83,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 528d78dc89..388d7e1fb6 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -10,6 +10,7 @@ import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' +import activityRemote from '@deepseek-ai/dsh-api-activity-controller/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -24,6 +25,8 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/remote' export type * from '@deepseek-ai/dsh-api-session-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' +export type {} from '@deepseek-ai/dsh-api-activity-controller/remote' +export type * from '@deepseek-ai/dsh-api-activity-controller/types' export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. @@ -135,7 +138,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, - sessionRemote, workspaceRemote, + sessionRemote, workspaceRemote, activityRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 4d0256e162..7cd03cfd21 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -25,6 +25,7 @@ import type {} from '@deepseek-ai/dsh-settings/types' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-user-questions' export type {} from '@deepseek-ai/dsh-api-session-controller/types' +export type {} from '@deepseek-ai/dsh-api-activity-controller/types' export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' export type { ApiRemoteForwardedEvent } from './types.ts' diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index eb98174378..c24ed4bca5 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -63,6 +63,9 @@ { "path": "../session-controller/tsconfig.client.json" }, + { + "path": "../activity-controller/tsconfig.client.json" + }, { "path": "../workspace-controller/tsconfig.client.json" }, diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 4dd513bdeb..0b0841c2b4 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -54,6 +54,9 @@ { "path": "../session-controller/tsconfig.host.json" }, + { + "path": "../activity-controller/tsconfig.host.json" + }, { "path": "../workspace-controller/tsconfig.host.json" }, diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 981e791fb4..3e2f48264a 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -81,6 +81,11 @@ - id: jobs name: '@deepseek-ai/dsh-jobs-local' + # Live-activity observation registry: non-consuming output streams for the + # optional activity plane (background bash/pwsh mirror into it when loaded). + - id: activity + name: '@deepseek-ai/dsh-activity-local' + - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 80f33257db..e6ad006201 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -41,11 +41,13 @@ "dependencies": { "@deepseek-ai/cordis-plugin-hmr": "workspace:^", "@deepseek-ai/cordis-plugin-timer": "workspace:^", + "@deepseek-ai/dsh-activity-local": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", + "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment-local": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-command-compact": "workspace:^", "@deepseek-ai/dsh-command-feedback": "workspace:^", @@ -55,19 +57,19 @@ "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", - "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-round-driver": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", @@ -82,6 +84,7 @@ "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-prompt-llm": "workspace:^", "@deepseek-ai/dsh-settings-file": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-badge": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", @@ -92,13 +95,13 @@ "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", - "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", @@ -106,7 +109,6 @@ "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tool-subagent-report": "workspace:^", - "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", @@ -117,8 +119,7 @@ "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", - "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/dsh-agent-instructions": "workspace:^" + "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 18a0d3911a..9fa12bc33f 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -109,6 +109,10 @@ - id: workspace-controller name: '@deepseek-ai/dsh-api-workspace-controller' + # Live-activity roster and per-activity output observation over Typert Remote. + - id: activity-controller + name: '@deepseek-ai/dsh-api-activity-controller' + # The API gateway: the transport-agnostic dispatch face every client shape # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway @@ -273,6 +277,11 @@ - id: ui-jobs name: '@deepseek-ai/dsh-client-ui-jobs' + # Live activities: the session-header roster with on-demand streaming + # output panels over the activity feed. + - id: ui-activity + name: '@deepseek-ai/dsh-client-ui-activity' + # Goal surface: GoalBar in the input dock over the goal session projection. - id: ui-goal name: '@deepseek-ai/dsh-client-ui-goal' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 68c43da265..0837c9f6b1 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -45,75 +45,77 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-activity": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", - "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-approval": "workspace:^", + "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-brand-official": "workspace:^", - "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", - "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", + "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-model-selection": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", + "@deepseek-ai/dsh-client-ui-reference": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", - "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-skill": "workspace:^", - "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-reference": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", - "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", - "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", + "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", + "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-web-frontend": "workspace:^", - "@deepseek-ai/dsh-host-frontend-static": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-web-frontend": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0", diff --git a/packages/client/ui-activity/README.i18n.yaml b/packages/client/ui-activity/README.i18n.yaml new file mode 100644 index 0000000000..532ea19b10 --- /dev/null +++ b/packages/client/ui-activity/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/ui-activity/README.md +README.md: 157e98e6b14bc1b556cb2aac8f6f90c43fbcb3f2 +README.zh.md: 392cc91c105caa2f79546f818700da0e6643b8a1 diff --git a/packages/client/ui-activity/README.md b/packages/client/ui-activity/README.md new file mode 100644 index 0000000000..157e98e6b1 --- /dev/null +++ b/packages/client/ui-activity/README.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-activity + +English | [中文](README.zh.md) + +Session-header live-activity list over `ctx.activityFeed`: one header action beside the background-job list renders the roster rows this session can see (owned plus unowned), and expanding a row opens that activity's observation stream into an embedded terminal panel. Observation follows visibility — the stream opens on expand and closes on collapse, unmount, or popover dismissal, so output only flows while someone is watching. Retention gaps and stream failures render as notices above the panel. + +The control renders nothing until the session can see at least one activity, so an ordinary conversation never grows a control for a capability it is not using; without the activity registry the roster stays empty and the entry point never appears. + +## Model Experience + +None, as this package renders host-observed live output for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). + +#### KV Cache effect + +None; the package never assembles or sends provider requests. + +## Known Limitations and Deferred Work + +- **No kill control** — cancellation stays with the model's `job_kill`; a human kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). +- **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up. +- **A background job appears in both header lists** — the job list carries control-facing status, this list carries output; the duplication mirrors the deliberate subagent-catalog precedent. diff --git a/packages/client/ui-activity/README.zh.md b/packages/client/ui-activity/README.zh.md new file mode 100644 index 0000000000..392cc91c10 --- /dev/null +++ b/packages/client/ui-activity/README.zh.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-activity + +[English](README.md) | 中文 + +架在 `ctx.activityFeed` 上的会话头部实时活动列表:在后台任务列表旁提供一个头部 action,渲染本会话可见的 roster 行(有主 + 无主),展开某行即把该 activity 的观察流打开进内嵌终端面板。观察跟随可见性——展开开流,收起、卸载或关闭弹层即停流,只有有人在看时输出才流动。保留缺口与流失败以面板上方的提示呈现。 + +会话一个 activity 都看不到时该控件完全不渲染,普通会话不会为未使用的能力多出控件;没有 activity 注册表时 roster 恒空,入口永不出现。 + +## 模型体验 + +无:本包只为人渲染宿主观察到的实时输出,不触碰任何 prompt、消息、schema、流或工具结果。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 + +#### KV Cache 影响 + +无;本包从不组装或发送 provider 请求。 + +## 已知限制与暂缓事项 + +- **没有 kill 控件**——取消仍走模型的 `job_kill`;人工 kill 被 [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题阻塞。 +- **不渲染通道标签**——stdout 与 stderr chunk 连接成一条流;分通道着色是后续呈现优化。 +- **后台 job 会同时出现在两个头部列表**——job 列表承载控制侧状态,本列表承载输出;这一重复沿用 subagent catalog 的既有取舍。 diff --git a/packages/client/ui-activity/package.json b/packages/client/ui-activity/package.json new file mode 100644 index 0000000000..83395df003 --- /dev/null +++ b/packages/client/ui-activity/package.json @@ -0,0 +1,77 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-activity", + "description": "Session-header live-activity list: roster rows from the activity control stream with on-demand streaming output panels", + "version": "0.1.1-rc.2", + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-activity" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ], + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-activity-controller", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-primitives" + ], + "platform": "web" + } + } +} diff --git a/packages/client/ui-activity/src/client/ActivityListAction.module.css b/packages/client/ui-activity/src/client/ActivityListAction.module.css new file mode 100644 index 0000000000..2cf3af6e2f --- /dev/null +++ b/packages/client/ui-activity/src/client/ActivityListAction.module.css @@ -0,0 +1,155 @@ +.root { + position: relative; +} + +.trigger { + display: inline-flex; + align-items: center; + gap: 3px; + min-height: 28px; + padding: 3px 2px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + cursor: pointer; +} + +.trigger:hover, +.trigger:focus-visible { + color: var(--dsw-alias-label-secondary); +} + +.trigger svg { + transition: transform 120ms ease; +} + +.triggerOpen { + transform: rotate(180deg); +} + +.triggerDot { + flex: none; +} + +.count { + margin: 0 5px; +} + +.menu { + position: absolute; + top: calc(100% + 5px); + left: 0; + z-index: 100; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 1px; + width: 440px; + max-width: min(520px, calc(100vw - 32px)); + max-height: min(480px, calc(100vh - 140px)); + margin: 0; + padding: 4px; + overflow: auto; + list-style: none; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-specific-menu); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + box-shadow: var(--dsw-shadow-lv3); +} + +.item { + display: flex; + flex-direction: column; +} + +.row { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-height: 32px; + padding: 6px 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 18px; + text-align: left; + cursor: pointer; +} + +.row:hover, +.row:focus-visible { + background: var(--dsw-alias-fill-l1); +} + +.rowSettled { + color: var(--dsw-alias-label-tertiary); +} + +.rowDot { + flex: none; +} + +.kind { + flex: none; + padding: 0 6px; + border-radius: 5px; + background: var(--dsw-alias-fill-l2); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 18px; +} + +.label { + flex: 1; + min-width: 0; + overflow: hidden; + font-family: var(--dsw-font-mono); + white-space: nowrap; + text-overflow: ellipsis; +} + +/* A failed activity's detail is the producer's raw text, so it has no bound; + without this it widens the row past the menu instead of eliding like .label. */ +.status { + flex: none; + max-width: 40%; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 18px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.chevronOpen { + transform: rotate(180deg); +} + +.panel { + margin: 0 8px 8px; +} + +.notice { + margin: 0 8px 6px; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; +} + +.noticeError { + color: var(--dsw-alias-label-error, var(--dsw-alias-label-secondary)); +} diff --git a/packages/client/ui-activity/src/client/ActivityListAction.tsx b/packages/client/ui-activity/src/client/ActivityListAction.tsx new file mode 100644 index 0000000000..698815e171 --- /dev/null +++ b/packages/client/ui-activity/src/client/ActivityListAction.tsx @@ -0,0 +1,250 @@ +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import type { + ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, +} from '@deepseek-ai/dsh-api-activity-controller/client' +import { + IconChevronDownOutline14, StateDot, TerminalBlock, useDismissOnOutsidePointer, + type StateDotState, type TerminalBlockLabels, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import css from './ActivityListAction.module.css' + +/** Registration-side business face for the activity feed. */ +export interface ActivityListInjected { + hooks: { + /** Client activity snapshot bound by the renderer as useActivity. */ + activity: { + getSnapshot(): ActivityFeedSnapshot + subscribe(listener: () => void): () => void + } + } + /** + * Start observing one activity's live output; returns the stop function. + * Reference-counted by the client feed, so panels can overlap safely. + */ + observe: (id: ActivityId) => () => void +} + +/** Full props for the session-header live-activity action. */ +export type ActivityListActionProps = + PropsRuntime<'conversation.session.header.actions'> + & PropsLocale + & InjectFace + +/** Stable empty list so a session with no activities keeps one array identity. */ +const NO_ROWS: readonly ActivityRow[] = [] + +/** Height cap for one live output panel inside the popover. */ +const PANEL_MAX_LINES = 16 + +function isLive(row: ActivityRow): boolean { + return row.status === 'running' +} + +/** Closed-union exhaustiveness fence for the wire status set. */ +/* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */ +function assertNever(value: never): never { + throw new Error(`unhandled activity status: ${JSON.stringify(value)}`) +} + +function dotState(status: ActivityRow['status']): StateDotState { + switch (status) { + case 'running': return 'ongoing' + case 'completed': return 'done' + case 'killed': return 'warning' + case 'failed': return 'error' + /* v8 ignore next -- closed wire status union */ + default: return assertNever(status) + } +} + +function statusLabel(status: ActivityRow['status'], t: TranslateNS): string { + switch (status) { + case 'running': return t('status.running') + case 'completed': return t('status.completed') + case 'killed': return t('status.killed') + case 'failed': return t('status.failed') + /* v8 ignore next -- closed wire status union */ + default: return assertNever(status) + } +} + +/** Localized display copy for the embedded terminal panel. */ +function terminalLabels(t: TranslateNS): TerminalBlockLabels { + return { + // The labels contract requires exit-fact formatters, but this panel never + // passes exit facts, so TerminalBlock never invokes them. + /* v8 ignore next */ + signal: signal => t('terminal.signal', { signal }), + /* v8 ignore next */ + exitCode: code => t('terminal.exitCode', { code }), + running: t('terminal.running'), + failed: t('terminal.failed'), + done: t('terminal.done'), + copy: t('terminal.copy'), + copied: t('terminal.copied'), + noOutput: t('terminal.noOutput'), + collapseAria: t('terminal.collapseAria'), + collapse: t('terminal.collapse'), + expandAria: hidden => t('terminal.expandAria', { n: hidden }), + expand: hidden => t('terminal.expand', { n: hidden }), + } +} + +/* jscpd:ignore-start -- mirrors JobListAction's ordering and trigger chrome by design so the two header lists read as one family. */ +/** Live rows first in start order, then settled rows newest-first. */ +function ordered(rows: readonly ActivityRow[]): ActivityRow[] { + return [...rows].sort((left, right) => { + const liveLeft = isLive(left) + if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 + if (liveLeft) return left.startedAt - right.startedAt + const finished = (right.finishedAt ?? right.startedAt) - (left.finishedAt ?? left.startedAt) + return finished !== 0 ? finished : left.startedAt - right.startedAt + }) +} +/* jscpd:ignore-end */ + +/** One expandable roster row plus its on-demand live output panel. */ +function ActivityItem({ row, view, expanded, onToggle, t }: { + row: ActivityRow + view: ObservedActivity | undefined + expanded: boolean + onToggle: () => void + t: TranslateNS +}) { + const live = isLive(row) + const status = statusLabel(row.status, t) + const labels = useMemo(() => terminalLabels(t), [t]) + return ( +
  • + + {expanded + ? ( +
    + {view?.gapBefore === true ?
    {t('output.gap')}
    : null} + {view?.error !== undefined + ?
    {t('output.error', { error: view.error })}
    + : null} + +
    + ) + : null} +
  • + ) +} + +/** + * Session-header entry point for this session's live activities. It renders + * nothing at all until the session can see at least one activity, expanding a + * row starts its observation stream, and collapsing (or closing the popover) + * stops it — output only flows while someone is watching. + * @param props - runtime slot currency, the activity snapshot hook, the + * observation control, and the namespace translator. + * @returns the trigger and its popover list, or null when there is nothing to show. + */ +export function ActivityListAction({ sessionId, useActivity, observe, t }: ActivityListActionProps) { + const owned = useActivity(state => state.rowsBySession[sessionId]) ?? NO_ROWS + const unowned = useActivity(state => state.rowsBySession['']) ?? NO_ROWS + const observedViews = useActivity(state => state.observed) + const [open, setOpen] = useState(false) + const [expandedId, setExpandedId] = useState(undefined) + const rootRef = useRef(null) + const triggerRef = useRef(null) + + const rows = useMemo(() => ordered([...owned, ...unowned]), [owned, unowned]) + const liveCount = useMemo(() => rows.filter(isLive).length, [rows]) + + useDismissOnOutsidePointer(rootRef, open, setOpen) + + // Observation follows visibility: the stream opens when a panel expands and + // closes when it collapses, unmounts, or the popover closes. + const activeId = open ? expandedId : undefined + useEffect(() => { + if (activeId === undefined) return + return observe(activeId as ActivityId) + }, [activeId, observe]) + + // The last activity disappearing removes this control; close first so focus + // does not vanish from an unmounting node. + useEffect(() => { + if (rows.length === 0 && open) setOpen(false) + }, [rows.length, open]) + + // An expanded row that left the roster (owner disposal) folds its panel. + useEffect(() => { + if (expandedId !== undefined && !rows.some(row => String(row.id) === expandedId)) { + setExpandedId(undefined) + } + }, [rows, expandedId]) + + if (rows.length === 0) return null + + const countKey = liveCount > 0 + ? (liveCount === 1 ? 'count.live.one' : 'count.live.other') + : (rows.length === 1 ? 'count.idle.one' : 'count.idle.other') + const countLabel = t(countKey, { count: liveCount > 0 ? liveCount : rows.length }) + + /* jscpd:ignore-start -- mirrors JobListAction's dismiss/trigger interaction by design. */ + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || !open) return + event.preventDefault() + setOpen(false) + triggerRef.current?.focus() + } + + return ( +
    + + {/* jscpd:ignore-end */} + {open + ? ( +
      + {rows.map(row => ( + { + setExpandedId(current => current === String(row.id) ? undefined : String(row.id)) + }} + t={t} + /> + ))} +
    + ) + : null} +
    + ) +} diff --git a/packages/client/ui-activity/src/client/index.ts b/packages/client/ui-activity/src/client/index.ts new file mode 100644 index 0000000000..83ce0aa7bf --- /dev/null +++ b/packages/client/ui-activity/src/client/index.ts @@ -0,0 +1,48 @@ +/** + * Live-activity plugin, browser half: contributes one session-header action + * that renders the activity roster and, per expanded row, an on-demand + * observation stream's live output. All data arrives through the + * `activityFeed` client service; this plugin holds no transport state. + */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { ActivityListAction } from './ActivityListAction.tsx' +import type { ActivityListInjected } from './ActivityListAction.tsx' +import type {} from '@deepseek-ai/dsh-api-activity-controller/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import { en, NS, zh, type ActivityKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Live-activity list copy. */ + 'activity': ActivityKey + } +} + +export type { ActivityListActionProps, ActivityListInjected } from './ActivityListAction.tsx' + +/** Required services: the activity feed, the slot registry, and dictionaries. */ +export const inject = ['activityFeed', 'slots', 'locale'] + +/** + * Client plugin body: register the dictionaries and the header action. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-activity: dictionaries') + ctx.slots.inject( + 'conversation.session.header.actions', + () => ctx.slots.register({ + name: 'conversation.session.header.actions', + id: 'activity-list', + // After the background-job list: job control reads before live output. + order: 30, + locale: NS, + inject: (): ActivityListInjected => ({ + hooks: { activity: ctx.activityFeed.state }, + observe: id => ctx.activityFeed.observe(id), + }), + }, ActivityListAction), + ) +} diff --git a/packages/client/ui-activity/src/client/locales.ts b/packages/client/ui-activity/src/client/locales.ts new file mode 100644 index 0000000000..efea5126da --- /dev/null +++ b/packages/client/ui-activity/src/client/locales.ts @@ -0,0 +1,65 @@ +/** `activity` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'activity' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'count.live.one': '{count} 个活动进行中', + 'count.live.other': '{count} 个活动进行中', + 'count.idle.one': '{count} 个活动', + 'count.idle.other': '{count} 个活动', + 'list.aria': '实时活动', + 'row.expandAria': '展开 {label} 的实时输出', + 'row.collapseAria': '收起 {label} 的实时输出', + 'status.running': '运行中', + 'status.completed': '已完成', + 'status.killed': '已取消', + 'status.failed': '已失败', + 'output.gap': '……较早的输出已丢弃……', + 'output.error': '实时输出流中断:{error}', + 'terminal.signal': '信号 {signal}', + 'terminal.exitCode': '退出码 {code}', + 'terminal.running': '运行中', + 'terminal.failed': '已失败', + 'terminal.done': '已完成', + 'terminal.copy': '复制', + 'terminal.copied': '已复制', + 'terminal.noOutput': '(无输出)', + 'terminal.collapse': '收起', + 'terminal.collapseAria': '收起输出', + 'terminal.expand': '展开其余 {n} 行', + 'terminal.expandAria': '展开被折叠的 {n} 行输出', +} as const + +/** English dictionary, key-identical to the Chinese source of truth. */ +export const en: Record = { + 'count.live.one': '{count} live activity', + 'count.live.other': '{count} live activities', + 'count.idle.one': '{count} activity', + 'count.idle.other': '{count} activities', + 'list.aria': 'Live activities', + 'row.expandAria': 'Show live output of {label}', + 'row.collapseAria': 'Hide live output of {label}', + 'status.running': 'running', + 'status.completed': 'completed', + 'status.killed': 'cancelled', + 'status.failed': 'failed', + 'output.gap': '… earlier output dropped …', + 'output.error': 'live output stream interrupted: {error}', + 'terminal.signal': 'signal {signal}', + 'terminal.exitCode': 'exit {code}', + 'terminal.running': 'running', + 'terminal.failed': 'failed', + 'terminal.done': 'done', + 'terminal.copy': 'Copy', + 'terminal.copied': 'Copied', + 'terminal.noOutput': '(no output)', + 'terminal.collapse': 'Collapse', + 'terminal.collapseAria': 'Collapse output', + 'terminal.expand': 'Show {n} more lines', + 'terminal.expandAria': 'Expand {n} collapsed output lines', +} + +/** Key domain of the `activity` namespace (zh is the source of truth). */ +export type ActivityKey = keyof typeof zh diff --git a/packages/client/ui-activity/src/css-modules.d.ts b/packages/client/ui-activity/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-activity/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-activity/src/index.ts b/packages/client/ui-activity/src/index.ts new file mode 100644 index 0000000000..f75578ddaf --- /dev/null +++ b/packages/client/ui-activity/src/index.ts @@ -0,0 +1,9 @@ +/** + * Node half of the live-activity list plugin. The browser half in + * `src/client/` owns every contribution; this entry exists so the package + * appears as an ordinary Loader row. + * @module @deepseek-ai/dsh-client-ui-activity + */ + +/** Loader-visible no-op body; the browser half carries the feature. */ +export function apply(): void {} diff --git a/packages/client/ui-activity/src/invariant.ts b/packages/client/ui-activity/src/invariant.ts new file mode 100644 index 0000000000..22eba24c42 --- /dev/null +++ b/packages/client/ui-activity/src/invariant.ts @@ -0,0 +1,27 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-activity`. + * @module @deepseek-ai/dsh-client-ui-activity/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-activity' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-activity-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: pure presentation over the activity feed's client snapshot; the feed's own packages assert the data relations. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx new file mode 100644 index 0000000000..96156c798d --- /dev/null +++ b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx @@ -0,0 +1,244 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { + ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, +} from '@deepseek-ai/dsh-api-activity-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { ActivityListAction, type ActivityListActionProps } from '../src/client/ActivityListAction.tsx' +import { zh } from '../src/client/locales.ts' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +const SESSION = 'session' as SessionId +const t: ActivityListActionProps['t'] = makeTranslate(zh) + +function row(over: Partial = {}): ActivityRow { + return { + id: 'bash-1' as ActivityId, + kind: 'bash', + label: 'pnpm run build', + sessionId: SESSION, + status: 'running', + startedAt: 1_700_000_000_000, + outputTotal: 0, + ...over, + } +} + +/** A row with no owner session, as the unowned bucket carries them. */ +function unownedRow(over: Partial = {}): ActivityRow { + const { sessionId: _drop, ...rest } = row(over) + return rest +} + +function props( + snapshot: Partial, + observe: ActivityListActionProps['observe'] = () => () => {}, +): ActivityListActionProps { + const state: ActivityFeedSnapshot = { + rowsBySession: {}, + observed: {}, + phase: 'ready', + ...snapshot, + } + function useActivity(select: (value: ActivityFeedSnapshot) => T): T { + return select(state) + } + return { sessionId: SESSION, useActivity, observe, t } as unknown as ActivityListActionProps +} + +function openList(): void { + fireEvent.click(screen.getAllByRole('button')[0]!) +} + +describe('ActivityListAction visibility', () => { + it('renders nothing while the session sees no activities', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('counts live activities on the trigger, merging owned and unowned rows', () => { + render() + expect(screen.getByRole('button', { name: '2 个活动进行中' })).toBeDefined() + }) + + it('falls back to the total when nothing is live and hides foreign sessions', () => { + render() + expect(screen.getByRole('button', { name: '1 个活动' })).toBeDefined() + }) +}) + +describe('ActivityListAction rows and observation', () => { + it('lists rows with kind, label, and detail-or-status', () => { + render() + openList() + const list = screen.getByRole('list', { name: zh['list.aria'] }) + const items = within(list).getAllByRole('listitem') + expect(items).toHaveLength(2) + expect(items[0]?.textContent).toContain('pnpm run build') + expect(items[0]?.textContent).toContain(zh['status.running']) + expect(items[1]?.textContent).toContain('exit code: 3') + }) + + it('marks killed rows and expands an overflowing panel through the collapse controls', () => { + const lines = Array.from({ length: 24 }, (_value, index) => `line ${index + 1}`).join('\n') + const view: ObservedActivity = { + activityId: 'bash-1' as ActivityId, + text: lines, + gapBefore: false, + status: 'killed', + streaming: false, + } + render() + openList() + expect(screen.getByText(zh['status.killed'])).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + const expand = screen.getByRole('button', { name: zh['terminal.expandAria'].replace('{n}', '8') }) + fireEvent.click(expand) + expect(screen.getByRole('button', { name: zh['terminal.collapseAria'] })).toBeDefined() + }) + + it('orders live rows first by start and settled rows newest-first with tie-breaks', () => { + const settled = (id: string, label: string, startedAt: number, finishedAt?: number): ActivityRow => + row({ + id: id as ActivityId, + label, + status: 'completed', + startedAt, + ...finishedAt !== undefined ? { finishedAt } : {}, + }) + render() + openList() + const labels = within(screen.getByRole('list', { name: zh['list.aria'] })) + .getAllByRole('listitem') + .map(item => item.querySelector('[title]')?.getAttribute('title')) + expect(labels).toEqual([ + 'live-2', 'live-1', 'settled-d', 'settled-a', 'settled-c', 'settled-b', 'settled-e', + ]) + expect(screen.getAllByText(zh['status.completed']).length).toBeGreaterThan(0) + }) + + it('ignores other keys while open and closes once the roster empties', () => { + const settledPair = [ + row({ id: 'bash-1' as ActivityId, status: 'completed', finishedAt: 2 }), + row({ id: 'bash-2' as ActivityId, status: 'completed', finishedAt: 3 }), + ] + const { rerender } = render() + expect(screen.getByRole('button', { name: '2 个活动' })).toBeDefined() + openList() + const list = screen.getByRole('list', { name: zh['list.aria'] }) + fireEvent.keyDown(list, { key: 'a' }) + expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() + // The roster emptying unmounts the control entirely; the open flag resets first. + rerender() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('starts observing on expand, renders the live text, and stops on collapse', () => { + const stop = vi.fn() + const observe = vi.fn(() => stop) + const view: ObservedActivity = { + activityId: 'bash-1' as ActivityId, + text: 'compiling…\n', + gapBefore: false, + status: 'running', + streaming: true, + } + render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + expect(observe).toHaveBeenCalledWith('bash-1') + expect(screen.getByText('compiling…')).toBeDefined() + + fireEvent.click(screen.getByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'pnpm run build') })) + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('surfaces retention gaps and stream failures above the panel', () => { + const view: ObservedActivity = { + activityId: 'bash-1' as ActivityId, + text: 'tail only', + gapBefore: true, + status: 'running', + streaming: false, + error: 'connection lost', + } + render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + expect(screen.getByText(zh['output.gap'])).toBeDefined() + expect(screen.getByText('实时输出流中断:connection lost')).toBeDefined() + }) + + it('stops observation when the popover closes via Escape', () => { + const stop = vi.fn() + render( stop)} />) + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + fireEvent.keyDown(screen.getByRole('list', { name: zh['list.aria'] }), { key: 'Escape' }) + expect(stop).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull() + }) + + it('folds an expanded panel whose row left the roster', () => { + const stop = vi.fn() + // One stable observe identity across renders, as the inject face provides. + const observe = () => stop + const first = props({ rowsBySession: { [SESSION]: [row()] } }, observe) + const { rerender } = render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + rerender() + expect(stop).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'other') })).toBeNull() + }) +}) diff --git a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts b/packages/client/ui-activity/tests/browser-plugin.client.spec.ts new file mode 100644 index 0000000000..de6db2a069 --- /dev/null +++ b/packages/client/ui-activity/tests/browser-plugin.client.spec.ts @@ -0,0 +1,115 @@ +/** + * ui-activity plugin halves: the browser entry's dictionary and header-slot + * registrations against the real SlotRegistry (with fiber teardown proving + * removal — HMR safety), the inert node entry, and the invariant companion's + * ownership reservation. + */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '../src/client/index.ts' +import { apply as applyNode } from '../src/index.ts' +import * as ActivityInvariant from '../src/invariant.ts' +import { en, NS, zh } from '../src/client/locales.ts' + +/** Slot ledger reader: entry ids currently registered in the header list. */ +function headerEntryIds(ctx: Context): (string | undefined)[] { + return ctx.slots + .entries('conversation.session.header.actions') + .map(entry => entry.options.id) +} + +/** Ids handed to the stubbed feed's observe control. */ +const observed: string[] = [] + +/** Boot the browser half over a real slot tree that declares the header list. */ +async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + ctx.slots.register({ + name: 'root', + children: { + 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, + }, + } as never, () => null) + ctx.provide('activityFeed', { + state: { getSnapshot: () => ({ rowsBySession: {}, observed: {}, phase: 'pending' }), subscribe: () => () => {} }, + observe: (id: string) => { observed.push(id); return () => {} }, + } as never) + // The locale plugin binds a settings scope, which reads the connection handle + // and the forwarded-event port. + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() + // These specs assert the shipped Chinese copy. There is no jsdom `window` in + // this lane, so browser-language detection never runs and the locale comes + // from FALLBACK_LOCALE (en): state the asserted locale explicitly. + ctx.locale.setLocale('zh') + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + return { ctx, fiber } +} + +describe('ui-activity browser half', () => { + it('declares the services it binds', () => { + expect(inject).toEqual(['activityFeed', 'slots', 'locale']) + }) + + it('exposes the feed source and observation control through the inject face', async () => { + const { ctx } = await bench() + const entry = ctx.slots + .entries('conversation.session.header.actions') + .find(candidate => candidate.options.id === 'activity-list') + const face = (entry as unknown as { + inject: () => { hooks: { activity: unknown }; observe: (id: string) => () => void } + }).inject() + expect(face.hooks.activity).toBeDefined() + const stopper = face.observe('bash-1') + expect(observed).toEqual(['bash-1']) + stopper() + }) + + it('registers the header action, and fiber teardown removes it (HMR safety)', async () => { + const { ctx, fiber } = await bench() + expect(headerEntryIds(ctx)).toContain('activity-list') + await fiber.dispose() + expect(headerEntryIds(ctx)).not.toContain('activity-list') + }) + + it('registers both dictionaries under its own namespace and releases them with the fiber', async () => { + const { ctx, fiber } = await bench() + const translate = ctx.locale.bind(NS) + expect(translate('list.aria')).toBe(zh['list.aria']) + ctx.locale.setLocale('en') + expect(translate('list.aria')).toBe(en['list.aria']) + + // Withdrawn dictionaries leave the key unresolved rather than translated. + await fiber.dispose() + expect(translate('list.aria')).not.toBe(en['list.aria']) + }) + + it('keeps the English dictionary key-identical to the Chinese source of truth', () => { + expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort()) + }) +}) + +describe('ui-activity node half', () => { + it('contributes no host behavior', () => { + // The node half exists only so the plugin appears in the Loader tree. + expect(applyNode).not.toThrow() + }) +}) + +describe('ui-activity invariant companion', () => { + it('reserves package ownership under its declared companion name', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry, { enabled: true }) + await ctx.plugin(ActivityInvariant) + expect(ActivityInvariant.name).toBe('client-ui-activity-invariant') + expect(ActivityInvariant.inject).toEqual(['invariants']) + }) +}) diff --git a/packages/client/ui-activity/tsconfig.json b/packages/client/ui-activity/tsconfig.json new file mode 100644 index 0000000000..bbd9c5ce85 --- /dev/null +++ b/packages/client/ui-activity/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../api/activity-controller/tsconfig.client.json" }, + { "path": "../locale" }, + { "path": "../ui-conversation" }, + { "path": "../ui-primitives" }, + { "path": "../ui-renderer" }, + { "path": "../ui-session" }, + { "path": "../ui-slots" }, + { "path": "../../runtime-diagnostics/invariants" } + ] +} diff --git a/packages/client/ui-activity/tsdown.config.ts b/packages/client/ui-activity/tsdown.config.ts new file mode 100644 index 0000000000..096771fe90 --- /dev/null +++ b/packages/client/ui-activity/tsdown.config.ts @@ -0,0 +1,6 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-client-ui-activity', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 720136b270..a025628aa4 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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/ui-primitives/README.md -README.md: c1c40e39710d46fae240f0b3281c36d660855010 -README.zh.md: 631936ad658c147923e5f80f2d7445cac93d6b36 +README.md: 4df368b270511776896c74a55b0c1d2e684035d0 +README.zh.md: 77830a1f162e963709ea4d4d25c8393e7dd97d66 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index c1c40e3971..4df368b270 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -18,7 +18,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P ## Terminal output -`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with `anser` (bundled into this package's browser artifact) into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; a running block with a supplied `output` renders that live text under the running state (an undefined output keeps the historical prompt-only frame); it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with `anser` (bundled into this package's browser artifact) into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). ## Read rendering diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 631936ad65..77830a1f16 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -18,7 +18,7 @@ ## 终端输出 -`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过 `anser`(打进本包浏览器产物)解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)。 +`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;运行中且提供了 `output` 的块会在 running 状态下渲染这段实时文本(未提供 output 则保持历史上的仅提示行画面);由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过 `anser`(打进本包浏览器产物)解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)。 ## Read 渲染 diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index 26e26fe2d2..8f87db9741 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -54,7 +54,10 @@ export interface TerminalBlockProps { exitCode?: number | undefined /** Settled terminating signal name; any value renders the status pill, taking precedence over the exit code. */ signal?: string | undefined - /** The command is still running: the block shows the prompt line alone. */ + /** + * The command is still running: with no `output` the block shows the prompt + * line alone; with output it renders the live text under the running state. + */ running?: boolean | undefined /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */ maxLines?: number | undefined @@ -221,13 +224,16 @@ export function TerminalBlock({ ))} {status !== undefined && {status}} - {!running && !empty && ( + {(!running || output !== undefined) && !empty && ( )} - {!running && (empty + {/* A running block renders its live text as soon as the caller supplies + output; an undefined output keeps the historical prompt-only frame, + and a live-but-empty stream draws no placeholder. */} + {(!running || (output !== undefined && !empty)) && (empty && !running ?
    {copy.noOutput}
    : (
    diff --git a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx index 45de33c8f0..886661b396 100644 --- a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx @@ -89,15 +89,30 @@ describe('TerminalBlock prompt label', () => { }) describe('TerminalBlock states', () => { - it('running shows the command line only: no output, no placeholder, no copy', () => { - const view = render() + it('running with no output shows the command line only: no output box, no placeholder, no copy', () => { + const view = render() expect(view.getByText('sleep 5')).toBeTruthy() - expect(view.queryByText('partial')).toBeNull() + expect(outputLines(view.container)).toEqual([]) expect(view.queryByText('无输出')).toBeNull() expect(view.queryByRole('button')).toBeNull() expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('') }) + it('running with supplied output streams the live text and keeps it copyable', () => { + const view = render() + expect(view.getByText('partial')).toBeTruthy() + expect(view.queryByText('无输出')).toBeNull() + expect(view.queryByRole('button', { name: '复制' })).toBeTruthy() + expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('') + }) + + it('running with an empty live stream draws neither output nor placeholder', () => { + const view = render() + expect(outputLines(view.container)).toEqual([]) + expect(view.queryByText('无输出')).toBeNull() + expect(view.queryByRole('button')).toBeNull() + }) + it('running still shows a settled-looking status pill when one is supplied', () => { render() expect(screen.getByText('信号 SIGINT')).toBeTruthy() diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e038b24a0d..1a53958b6c 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1141,6 +1141,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'conversation.session.header\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ + 'client-ui-activity ActivityListAction id \'activity-list\'', 'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'', 'client-ui-jobs JobListAction id \'job-list\'', ], diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 320c44b9aa..35c3b93d92 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -81,6 +81,68 @@ export interface TypeApiEntry { /** Every harness `ctx.` service, sorted by key. */ export const SERVICE_API: readonly ServiceApiEntry[] = [ + { + key: 'activities', + summary: 'Abstract streaming-output registry.', + description: 'Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected.\n- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource.\n- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap.\n- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition\'s own carrier — sees every owner, while one registered under an agent composition\'s scope sees exactly the agents composed under it. Every listener is contained.', + methods: [ + { + signature: 'abstract open(spec: ActivityOpen): ActivityHandle', + description: 'Validate the spec, attach owner cleanup, and atomically register one activity. A rejection leaves no id; after return the producer owns the handle and the visible set has changed.', + parameters: [{ name: 'spec', description: 'activity identity, owner, and correlation.' }], + returns: 'the producer face of the registered activity.', + }, + { + signature: 'abstract list(caller?: Agent): ActivitySnapshot[]', + description: 'List caller-owned and unowned activities in registration order without exposing another session\'s labels or output.', + parameters: [{ name: 'caller', description: 'reading agent; a non-agent caller sees only unowned activities.' }], + returns: 'fresh snapshots.', + }, + { + signature: 'abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot', + description: 'Return a fresh snapshot. Throws for an unknown or foreign activity.', + parameters: [{ name: 'id', description: 'activity to look up.' }, { name: 'caller', description: 'reading agent checked against the owner.' }], + returns: 'a fresh snapshot.', + }, + { + signature: 'abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead', + description: 'Read retained output from an absolute byte offset without consuming it. Resume by passing a previous read\'s `next`; a foreign offset inside a retained chunk returns that whole chunk (its `at` may precede `from`). Throws for an unknown or foreign activity or a negative or non-integer offset.', + parameters: [{ name: 'id', description: 'activity to read.' }, { name: 'from', description: 'absolute byte offset to read from (0 for the retained head).' }, { name: 'caller', description: 'reading agent checked against the owner.' }], + returns: 'retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.', + }, + { + signature: 'abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void', + description: 'Register an effect-scoped observer of visible-set changes: opening, detail updates, settlement, owner-disposal removal, and the emptying that service disposal commits — so an observer re-reads rather than accumulating deltas. Listeners are contained and never awaited.', + parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned activity changed and every caller\'s set did.' }], + returns: 'disposer that unregisters the listener.', + }, + { + signature: 'abstract onOutput(listener: ActivityOutputListener): () => void', + description: 'Register an effect-scoped observer of stream advancement — one signal per committed append and one at settlement, carrying only the activity id. A consumer schedules a read from its own cursor; the registry never pushes payloads. Listeners are contained and never awaited.', + parameters: [{ name: 'listener', description: 'receives the id whose stream advanced.' }], + returns: 'disposer that unregisters the listener.', + }, + ], + }, + { + key: 'activityController', + summary: 'Host service backing the generated `ctx.remote.activity` namespace.', + description: 'Host service backing the generated `ctx.remote.activity` namespace.', + methods: [ + { + signature: '@Remote({ mode: \'stream\' }) control(signal: AbortSignal): AsyncIterable', + description: 'Stream a complete roster baseline followed by whole-bucket replacements.', + parameters: [{ name: 'signal', description: 'generation cancellation.' }], + returns: 'baseline followed by per-owner roster replacement frames.', + }, + { + signature: '@Remote({ mode: \'stream\' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable', + description: 'Stream one activity\'s retained output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursors never observe these reads.', + parameters: [{ name: 'request', description: 'target activity and optional resume offset.' }, { name: 'signal', description: 'generation cancellation.' }], + returns: 'anchor, coalesced output frames, and the terminal status.', + }, + ], + }, { key: 'agentDefaultModel', summary: 'Owns the default model selection independently of any Host or transport.', @@ -3115,6 +3177,94 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the Service and Event signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ + { + name: 'ActivitiesChangedListener', + declaration: 'export type ActivitiesChangedListener = (owner: Agent | undefined) => void;', + }, + { + name: 'ActivityAppendOptions', + declaration: 'export interface ActivityAppendOptions {\n channel?: ActivityChannel;\n gapBefore?: true;\n}', + }, + { + name: 'ActivityChannel', + declaration: 'export type ActivityChannel = \'stdout\' | \'stderr\';', + }, + { + name: 'ActivityControlFrame', + declaration: 'export type ActivityControlFrame = {\n readonly type: \'baseline\';\n readonly activities: readonly ActivityRow[];\n} | {\n readonly type: \'rows\';\n readonly sessionId?: SessionId;\n readonly activities: readonly ActivityRow[];\n};', + }, + { + name: 'ActivityCorrelation', + declaration: 'export interface ActivityCorrelation {\n callId?: CallId;\n jobId?: JobId;\n}', + }, + { + name: 'ActivityHandle', + declaration: 'export interface ActivityHandle {\n readonly id: ActivityId;\n append(text: string, options?: ActivityAppendOptions): void;\n updateDetail(detail: string): void;\n end(outcome: ActivityOutcome): void;\n}', + }, + { + name: 'ActivityId', + declaration: 'export type ActivityId = Branded<\'ActivityId\'>;', + }, + { + name: 'ActivityKind', + declaration: 'export type ActivityKind = ActivityKindMap[keyof ActivityKindMap];', + }, + { + name: 'ActivityKindMap', + declaration: 'export interface ActivityKindMap {\n bash: \'bash\';\n}', + }, + { + name: 'ActivityObserveFrame', + declaration: 'export type ActivityObserveFrame = {\n readonly type: \'opened\';\n readonly activityId: ActivityId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly ActivityWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n readonly finishedAt?: number;\n};', + }, + { + name: 'ActivityObserveRequest', + declaration: 'export interface ActivityObserveRequest {\n readonly activityId: ActivityId;\n readonly from?: number;\n}', + }, + { + name: 'ActivityOpen', + declaration: 'export interface ActivityOpen {\n kind: ActivityKind;\n label: string;\n owner?: Agent;\n correlation?: ActivityCorrelation;\n}', + }, + { + name: 'ActivityOutcome', + declaration: 'export interface ActivityOutcome {\n status: \'completed\' | \'failed\' | \'killed\';\n detail?: string;\n}', + }, + { + name: 'ActivityOutputChunk', + declaration: 'export interface ActivityOutputChunk {\n at: number;\n text: string;\n channel?: ActivityChannel;\n gapBefore?: true;\n}', + }, + { + name: 'ActivityOutputListener', + declaration: 'export type ActivityOutputListener = (id: ActivityId) => void;', + }, + { + name: 'ActivityRead', + declaration: 'export interface ActivityRead {\n chunks: readonly ActivityOutputChunk[];\n next: number;\n lossy: boolean;\n}', + }, + { + name: 'ActivityRow', + declaration: 'export interface ActivityRow {\n readonly id: ActivityId;\n readonly kind: string;\n readonly label: string;\n readonly sessionId?: SessionId;\n readonly correlation?: ActivityRowCorrelation;\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal: number;\n}', + }, + { + name: 'ActivityRowCorrelation', + declaration: 'export interface ActivityRowCorrelation {\n readonly callId?: CallId;\n readonly jobId?: JobId;\n}', + }, + { + name: 'ActivityRowStatus', + declaration: 'export type ActivityRowStatus = \'running\' | \'completed\' | \'failed\' | \'killed\';', + }, + { + name: 'ActivitySnapshot', + declaration: 'export interface ActivitySnapshot {\n id: ActivityId;\n kind: ActivityKind;\n label: string;\n ownerSession?: SessionId;\n correlation?: ActivityCorrelation;\n status: ActivityStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n outputTotal: number;\n outputEarliest: number;\n}', + }, + { + name: 'ActivityStatus', + declaration: 'export type ActivityStatus = \'running\' | \'completed\' | \'failed\' | \'killed\';', + }, + { + name: 'ActivityWireChunk', + declaration: 'export interface ActivityWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}', + }, { name: 'AdapterRegistrationHandle', declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', @@ -4827,9 +4977,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ShellExecSpec', declaration: 'export interface ShellExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy: SandboxExecutionPolicy | undefined;\n}', }, + { + name: 'ShellObservedStreams', + declaration: 'export interface ShellObservedStreams {\n stdout?: SubprocessOutputReader;\n stderr?: SubprocessOutputReader;\n}', + }, { name: 'ShellProcess', - declaration: 'export interface ShellProcess {\n status: ShellProcessStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise;\n sandbox?: ShellSandboxInfo;\n readOutput(): ShellProcessRead;\n kill(): boolean;\n}', + declaration: 'export interface ShellProcess {\n status: ShellProcessStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise;\n sandbox?: ShellSandboxInfo;\n readOutput(): ShellProcessRead;\n observed?: ShellObservedStreams;\n kill(): boolean;\n}', }, { name: 'ShellProcessRead', diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index 8129e170ed..4550338e3b 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -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/jobs/jobs/README.md -README.md: f4469286603f687be5789bd016ab462dba26e664 -README.zh.md: 6fd97aa3fe4cbbd555c2475f4132ec00c6a97390 +README.md: 1ed69b99ae10d4821e134b42f2ae19ad4ac3350b +README.zh.md: c9fd920bb552e0386ed0644fe00c4473f5b95e3c diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index f446928660..1ed69b99ae 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -35,6 +35,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **Stream output has one consuming cursor** — independent observers need a cursor or snapshot API. +- **Stream output has one consuming cursor** — the model owns it; independent observers use the non-consuming [`dsh-activity`](../../activity/activity/README.md) plane the producers mirror into instead. - **Foreground work cannot be promoted** — producers choose foreground or background before starting. - **The contract is in-process** — `JobStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam. diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index 6fd97aa3fe..c9fd920bb5 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -35,6 +35,6 @@ ## 已知限制与暂缓事项 -- **流输出只有一个消费游标**:独立观察者需要游标或快照 API。 +- **流输出只有一个消费游标**:它属于模型;独立观察者改用生产者镜像进去的非消费 [`dsh-activity`](../../activity/activity/README.zh.md) 观察面。 - **前台工作无法转为后台**:生产方在启动前选择前台或后台。 - **约定是进程内的**:`JobStart.run()` 传入回调和确切的 `Agent` 对象;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。 diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts index 6c37c5b794..fa4f5ef893 100644 --- a/packages/shell/bash-local/src/index.ts +++ b/packages/shell/bash-local/src/index.ts @@ -272,6 +272,7 @@ export class LocalBashExecutor extends ShellExecutor { status: 'running', exitCode: null, signal: null, + observed: collected, done: running.done.then((outcome) => { // Any signal termination is killed, including a command signaling itself. if (proc.status === 'running') { diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts index b7a2d9f915..08abd61169 100644 --- a/packages/shell/pwsh-local/src/index.ts +++ b/packages/shell/pwsh-local/src/index.ts @@ -301,6 +301,7 @@ export class PwshLocalExecutor extends ShellExecutor { status: 'running', exitCode: null, signal: null, + observed: collected, done: running.done.then((outcome) => { // Any signal termination is killed, including a command signaling itself. if (proc.status === 'running') { diff --git a/packages/shell/shell/README.i18n.yaml b/packages/shell/shell/README.i18n.yaml index 5317414e55..bcf293269a 100644 --- a/packages/shell/shell/README.i18n.yaml +++ b/packages/shell/shell/README.i18n.yaml @@ -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/shell/shell/README.md -README.md: a15980ead797a73c18dd6d8825a4b38cf845df60 -README.zh.md: 008b19835697d5d9514cf5472696852cfc684acd +README.md: 45a4d0c47e7604e7ad2ffc9f98a387cfa4d20b0c +README.zh.md: da869b78981df6bcfc253e84f08e4ca63cbff5f3 diff --git a/packages/shell/shell/README.md b/packages/shell/shell/README.md index a15980ead7..45a4d0c47e 100644 --- a/packages/shell/shell/README.md +++ b/packages/shell/shell/README.md @@ -23,6 +23,7 @@ The split is a standard capability seam ([capability-seams Agent Note](../../../ | `start(spec)` | Background execution. Returns a task-free `ShellProcess` handle immediately; **no timeout applies**. The caller may adapt it into `ctx.jobs`. | | `sandboxMode` | The capability fact for the tool layer: the default mode a SANDBOXING executor confines under (`undefined` in the base class — "this executor does not sandbox"). `dsh-tool-bash` reads it at registration to advertise the escalation fields only when the composition honors them. | | `ShellProcess.readOutput()` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. | +| `ShellProcess.observed` | Optional **non-consuming** offset readers over the same captured streams, for observers independent of the consuming cursor (the activity observation plane). Absent when a backend cannot expose offset reads. | | `ShellProcess.kill()` | Kill the process group. Returns `false` when it already finished. | Implementations subclass `ShellExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit. diff --git a/packages/shell/shell/README.zh.md b/packages/shell/shell/README.zh.md index 008b198356..da869b7898 100644 --- a/packages/shell/shell/README.zh.md +++ b/packages/shell/shell/README.zh.md @@ -23,6 +23,7 @@ | `start(spec)` | 后台执行。立即返回不含任务语义的 `ShellProcess` 句柄;**不应用超时**。调用方可以将其适配到 `ctx.jobs`。 | | `sandboxMode` | 工具层的能力事实:沙箱执行器用于限制执行的默认模式(基类中为 `undefined`,即「此执行器不使用沙箱」)。`dsh-tool-bash` 会在注册时读取它,仅当组合确实支持升权字段时才公布这些字段。 | | `ShellProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区容量限制而丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 | +| `ShellProcess.observed` | 可选的**非消费** offset 读取器,覆盖同一批捕获流,供独立于消费游标的观察者使用(activity 观察面)。后端无法提供 offset 读取时缺省。 | | `ShellProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 | 实现会继承 `ShellExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出。 diff --git a/packages/shell/shell/src/types.ts b/packages/shell/shell/src/types.ts index 21fea2d791..1de1cabf95 100644 --- a/packages/shell/shell/src/types.ts +++ b/packages/shell/shell/src/types.ts @@ -8,10 +8,22 @@ */ import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' -import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-subprocess' +import type { CollectedOutput, DshEnvironment, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' -export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-subprocess' +export type { CollectedOutput, DshEnvironment, DshEnvironmentKey, SubprocessOutputRead, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' + +/** + * Non-consuming offset readers over a background process's captured streams, + * for observers independent of the consuming {@link ShellProcess.readOutput} + * cursor. A stream is absent when its backend cannot expose offset reads. + */ +export interface ShellObservedStreams { + /** Offset reader over captured stdout, when the backend exposes one. */ + stdout?: SubprocessOutputReader + /** Offset reader over captured stderr, when the backend exposes one. */ + stderr?: SubprocessOutputReader +} /** * Sandbox facts for one run, present iff a sandboxing executor handled it. @@ -175,6 +187,13 @@ export interface ShellProcess { * full-stream spill files when available. */ readOutput(): ShellProcessRead + /** + * Non-consuming offset readers over the same captured streams the consuming + * {@link readOutput} cursor drains. Independent observers read here at their + * own offsets without stealing bytes from `readOutput`. Absent when the + * backend cannot expose offset reads; observers then see no stream. + */ + observed?: ShellObservedStreams /** * Kill the process group. Returns false when it had already finished * (no-op); idempotent. diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index f3acda2aca..ae0daca26d 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -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/shell/tool-bash/README.md -README.md: 21749bf0a1cd3cdc46257fb2a02ba6b4ab1f5ee6 -README.zh.md: 860db1a4c347d1aefe04f70e14a65e4b511ff7f6 +README.md: acf7e68cca6b611adf71ed7bad690294f1bdb14d +README.zh.md: 9aeae12bce13f87246a7cdea8c5b41ec8bbd4bc5 diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 21749bf0a1..acf7e68cca 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -34,7 +34,7 @@ Result text contains stdout, an optional `[stderr]` section, then applicable san The canonical success is `{ kind: 'foreground', ...ShellRunResult }` for a completed foreground process or `{ kind: 'background', jobId }` for a published task. The Native renderer preserves the text above, including exactly `started background job `; programmatic consumers use the typed fields without parsing those strings. Executor stream caps remain acquisition limits on `ShellRunResult` and carry their spill paths. -When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. +When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. When the optional `ctx.activities` registry is loaded, the committed background run is also mirrored into it best-effort: a `bash` activity correlated to the call and job pumps the handle's non-consuming `observed` readers at `activityPollMs` (default 150) so the Web client streams live output without touching the job's cursor; any observation failure is logged and swallowed. ## UI presentation diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index 860db1a4c3..9aeae12bce 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -34,7 +34,7 @@ 已完成前台进程的规范成功值为 `{ kind: 'foreground', ...ShellRunResult }`,已发布任务则为 `{ kind: 'background', jobId }`。Native renderer 保留上述文本,包括精确的 `started background job `;程序化消费方使用带类型字段,无需解析这些字符串。执行器的流上限仍是 `ShellRunResult` 的采集限制,并携带其 spill 路径。 -当 `run_in_background` 为 true 时,此插件会在 spawn 前预检 `ctx.jobs.start()`,把调用方 agent 注册为持有者,并将返回的 `ShellProcess` 句柄适配为通用的取消/完成/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;此插件只把 bash 退出/沙箱事实映射为任务输出和结果详情。`enableRunInBackground: false` 会移除该参数,并在执行时拒绝强制后台调用。 +当 `run_in_background` 为 true 时,此插件会在 spawn 前预检 `ctx.jobs.start()`,把调用方 agent 注册为持有者,并将返回的 `ShellProcess` 句柄适配为通用的取消/完成/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;此插件只把 bash 退出/沙箱事实映射为任务输出和结果详情。`enableRunInBackground: false` 会移除该参数,并在执行时拒绝强制后台调用。当可选的 `ctx.activities` 注册表已加载时,已提交的后台运行还会尽力镜像进去:一个与调用和 job 关联的 `bash` activity 以 `activityPollMs`(默认 150)的节奏泵取句柄的非消费 `observed` 读取器,让 Web 客户端在不动 job 游标的前提下实时看到输出;任何观察失败只记日志并吞掉。 ## UI 展示 diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 321e2822d2..557fbe37f2 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -32,42 +32,44 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^" } } diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts index 37c4a762e3..ed4fd5bac6 100644 --- a/packages/shell/tool-bash/src/index.ts +++ b/packages/shell/tool-bash/src/index.ts @@ -23,8 +23,10 @@ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandb import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' -import type { ShellRunResult } from '@deepseek-ai/dsh-shell' +import type { ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' +import type {} from '@deepseek-ai/dsh-activity' import { processOutcome } from './background.ts' +import { observeBackgroundActivity } from './observe.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' export const name = 'tool-bash' @@ -34,11 +36,18 @@ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } /** Runtime configuration schema for the bash tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), + activityPollMs: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(150), }) /** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ @@ -189,6 +198,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = { export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true + const activityPollMs = config.activityPollMs ?? 150 const defaultMode = ctx.shell.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') @@ -362,12 +372,14 @@ export function apply(ctx: Context, config: Config = {}): void { throw error } // Task preflight finishes before the starter can spawn a process. + let started: ShellProcess | undefined const id = jobs.start({ kind: 'bash', label: args.command, ...exec.agent ? { owner: exec.agent } : {}, run: () => { const proc = ctx.shell.start(ctx.shell.resolve(request)) + started = proc return { cancel: () => void proc.kill(), done: proc.done.then(() => processOutcome(proc)), @@ -375,6 +387,16 @@ export function apply(ctx: Context, config: Config = {}): void { } }, }) + // The starter ran synchronously inside the committed start; mirror the + // spawned process into the optional observation registry. + if (started !== undefined) { + observeBackgroundActivity(ctx, started, { + kind: 'bash', + label: args.command, + ...exec.agent ? { owner: exec.agent } : {}, + correlation: { callId: exec.callId, jobId: id }, + }, activityPollMs, processOutcome) + } return { kind: 'background' as const, jobId: id } } const result = await ctx.shell.run(ctx.shell.resolve({ diff --git a/packages/shell/tool-bash/src/observe.ts b/packages/shell/tool-bash/src/observe.ts new file mode 100644 index 0000000000..7b5179aa59 --- /dev/null +++ b/packages/shell/tool-bash/src/observe.ts @@ -0,0 +1,72 @@ +/** + * Best-effort process-observation tap for background bash runs: mirrors the + * spawned process's captured streams into `ctx.activities` so non-consuming + * observers (the Web client) see live output without touching the job's + * consuming `readOutput` cursor. Absent registry or absent backend offset + * readers degrade to no observation; the job path is unaffected either way. + * + * @module @deepseek-ai/dsh-tool-bash/observe + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ShellProcess } from '@deepseek-ai/dsh-shell' +import { pumpActivityOutput } from '@deepseek-ai/dsh-activity' +import type { ActivityCorrelation, ActivityKind, ActivityPumpSource } from '@deepseek-ai/dsh-activity' + +/** Identity of one observed background run. */ +export interface ObserveBackgroundSpec { + /** Process kind — the producing tool's registered {@link ActivityKind}. */ + kind: ActivityKind + /** One-line label (the command). */ + label: string + /** Owning live agent, when the call has one. */ + owner?: Agent + /** Links to the starting tool call and the registered job. */ + correlation: ActivityCorrelation +} + +/** + * Open a process beside a committed background job and pump the handle's + * non-consuming offset readers into it until the run settles, then record the + * mapped outcome. Observation is strictly best-effort: without a + * `ctx.activities` registry this is a no-op, without backend offset readers + * the process still carries status and settlement, and ANY observation + * failure — including a rejected `open()` — is logged and swallowed, because + * the background job is already committed and running. + * @param ctx - plugin context used for the optional registry and warning logs. + * @param proc - the started background process handle. + * @param spec - process identity and correlation for the registry row. + * @param pollMs - producer-configured pump cadence in milliseconds. + * @param outcome - maps the settled handle to the process outcome. + */ +export function observeBackgroundActivity( + ctx: Context, + proc: ShellProcess, + spec: ObserveBackgroundSpec, + pollMs: number, + outcome: (proc: ShellProcess) => { status: 'completed' | 'killed'; detail: string }, +): void { + try { + const processes = ctx.get('activities') + if (processes === undefined) return + const handle = processes.open({ + kind: spec.kind, + label: spec.label, + ...spec.owner !== undefined ? { owner: spec.owner } : {}, + correlation: spec.correlation, + }) + const sources: ActivityPumpSource[] = [] + const stdout = proc.observed?.stdout + if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) + const stderr = proc.observed?.stderr + if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) + void pumpActivityOutput(handle, sources, { pollMs, done: proc.done }) + .catch((error: unknown) => { + ctx.logger.warn(`activity observation pump for ${handle.id} failed: ${String(error)}`) + }) + .then(() => { handle.end(outcome(proc)) }) + } catch (error: unknown) { + ctx.logger.warn(`activity observation unavailable for this run: ${String(error)}`) + } +} diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts new file mode 100644 index 0000000000..c0243ba5b9 --- /dev/null +++ b/packages/shell/tool-bash/tests/observe.spec.ts @@ -0,0 +1,194 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import type { ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' + +const testToolSignal = new AbortController().signal +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-observe-spec-')) + +/** Job harness plus the observation registry, with a fast pump for tests. */ +async function setup() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin(LocalActivityRegistry) + await ctx.plugin(LocalSubprocessRuntime) + ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + await ctx.plugin(ToolBash, { activityPollMs: 25 }) + return ctx +} + +let callCounter = 0 +function call(ctx: Context, args: Record) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`observe-call-${++callCounter}`), + name: 'bash', + arguments: args, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +async function until(read: () => T | undefined, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const value = read() + if (value !== undefined) return value + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error('condition not reached before timeout') +} + +function retainedText(ctx: Context, id: ActivitySnapshot['id']): string { + return ctx.activities.read(id, 0).chunks.map(chunk => chunk.text).join('') +} + +describe('background bash observation', () => { + it('mirrors a background run into ctx.activities with correlation, live output, and settlement', async () => { + const ctx = await setup() + const ack = await call(ctx, { + command: 'printf "line-1\\n"; sleep 0.4; printf "line-2\\n"', + description: 'test command', + run_in_background: true, + }) + expect(text(ack)).toContain('started background job') + const job = ctx.jobs.list()[0] + expect(job).toBeDefined() + + const snapshot = await until(() => + ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) + expect(snapshot.kind).toBe('bash') + expect(snapshot.label).toContain('printf') + expect(String(snapshot.correlation?.callId)).toMatch(/^observe-call-/) + + // Live output appears while the command is still running. + await until(() => retainedText(ctx, snapshot.id).includes('line-1') ? true : undefined) + expect(ctx.activities.get(snapshot.id).status).toBe('running') + + // Settlement carries the mapped bash outcome and the trailing output. + await until(() => ctx.activities.get(snapshot.id).status === 'completed' ? true : undefined) + expect(ctx.activities.get(snapshot.id).detail).toBe('exit code: 0') + expect(retainedText(ctx, snapshot.id)).toContain('line-2') + + // The model-facing consuming cursor still delivers everything: observation stole nothing. + const collected = await until(() => { + const read = ctx.jobs.read(job!.id) + return read.text.includes('line-1') ? read.text : undefined + }) + expect(collected).toContain('line-2') + }) + + it('a killed background job settles its mirrored process as killed', async () => { + const ctx = await setup() + await call(ctx, { command: 'sleep 60', description: 'test command', run_in_background: true }) + const job = ctx.jobs.list()[0] + const snapshot = await until(() => + ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) + ctx.jobs.kill(job!.id, undefined, 'test cleanup') + await until(() => ctx.activities.get(snapshot.id).status === 'killed' ? true : undefined) + expect(ctx.activities.get(snapshot.id).detail).toMatch(/signal|killed before exit/) + }) + + it('labels stderr chunks with their channel', async () => { + const ctx = await setup() + await call(ctx, { + command: 'echo out-line; echo err-line 1>&2', + description: 'test command', + run_in_background: true, + }) + const job = ctx.jobs.list()[0] + const snapshot = await until(() => + ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) + await until(() => ctx.activities.get(snapshot.id).status === 'completed' ? true : undefined) + const chunks = ctx.activities.read(snapshot.id, 0).chunks + expect(chunks.find(chunk => chunk.text.includes('out-line'))?.channel).toBe('stdout') + expect(chunks.find(chunk => chunk.text.includes('err-line'))?.channel).toBe('stderr') + }) + + it('a registry whose handle rejects appends degrades to a warning and still settles', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + const ended: unknown[] = [] + await ctx.plugin({ + name: 'appending-broken-activities-probe', + apply(child: Context) { + child.provide('activities', { + open: () => ({ + id: 'bash-1', + append() { throw new Error('append boom') }, + updateDetail() {}, + end(outcome: unknown) { ended.push(outcome) }, + }), + }) + }, + }) + await ctx.plugin(LocalSubprocessRuntime) + ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + const warn = vi.fn() + ctx.logger.warn = warn as never + await ctx.plugin(ToolBash, { activityPollMs: 25 }) + await call(ctx, { command: 'echo tolerated', description: 'test command', run_in_background: true }) + await until(() => ended.length > 0 ? true : undefined) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation pump')) + expect(ended[0]).toMatchObject({ status: 'completed' }) + }) + + it('a throwing observation registry never breaks the background call', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin({ + name: 'broken-activities-probe', + apply(child: Context) { + child.provide('activities', { open() { throw new Error('observation boom') } }) + }, + }) + await ctx.plugin(LocalSubprocessRuntime) + ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + const warn = vi.fn() + ctx.logger.warn = warn as never + await ctx.plugin(ToolBash, { activityPollMs: 25 }) + const ack = await call(ctx, { command: 'echo resilient', description: 'test command', run_in_background: true }) + expect(text(ack)).toContain('started background job') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation unavailable')) + // The job itself still completes and reads normally. + const job = ctx.jobs.list()[0] + const read = await until(() => { + const value = ctx.jobs.read(job!.id) + return value.text.includes('resilient') ? value : undefined + }) + expect(read.snapshot.status).toBe('completed') + }) +}) diff --git a/packages/shell/tool-bash/tsconfig.json b/packages/shell/tool-bash/tsconfig.json index 304d983a95..8aa66f93ff 100644 --- a/packages/shell/tool-bash/tsconfig.json +++ b/packages/shell/tool-bash/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../activity/activity" } ] } diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index c5dcb85005..0a88134fb3 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/README.i18n.yaml @@ -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/shell/tool-pwsh/README.md -README.md: e862fcf0ca85d0ecb0a5fe6cff3ee3c7a8153716 -README.zh.md: 0a66b119ac2bf0386c0686e9e22ddfe1c3bb87b1 +README.md: 5ebc22e8607337c2698756891cce2b9ba9e06184 +README.zh.md: 2e5186383babadab9ea894b3cb6364ab8bc23e6b diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index e862fcf0ca..5ebc22e860 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -34,7 +34,7 @@ Result text contains stdout, an optional `[stderr]` section, then applicable tru The canonical success is `{ kind: 'foreground', ...ShellRunResult }` for a completed foreground process (with the executor's `sandbox` facts — `mode`/`denied`, optional `enforcement`/`runnerFailed` — projected when present) or `{ kind: 'background', jobId }` for a published task. The renderer preserves exactly `started background job ` for background acks; programmatic consumers use the typed fields without parsing the rendered text. -When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. +When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. When the optional `ctx.activities` registry is loaded, the committed background run is also mirrored into it best-effort as a `pwsh` activity pumped at `activityPollMs` (default 150), exactly like `dsh-tool-bash`. ## UI presentation diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index 0a66b119ac..2e5186383b 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -34,7 +34,7 @@ 规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...ShellRunResult }`(存在时投影执行器的 `sandbox` 事实——`mode`/`denied`、可选的 `enforcement`/`runnerFailed`)或已发布任务的 `{ kind: 'background', jobId }`。渲染器对后台 ack 精确保留 `started background job `;编程消费者使用类型化字段而不解析渲染文本。 -当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.jobs.start()`,把调用 agent 注册为 owner,并将返回的 `ShellProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。 +当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.jobs.start()`,把调用 agent 注册为 owner,并将返回的 `ShellProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。当可选的 `ctx.activities` 注册表已加载时,已提交的后台运行还会尽力镜像为一个 `pwsh` activity,以 `activityPollMs`(默认 150)的节奏泵取,与 `dsh-tool-bash` 完全一致。 ## UI presentation diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 2294a955a3..3156508e39 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -32,39 +32,41 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^" } } diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts index 603322e381..f3e63551e5 100644 --- a/packages/shell/tool-pwsh/src/index.ts +++ b/packages/shell/tool-pwsh/src/index.ts @@ -33,9 +33,11 @@ import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' -import type { ShellRunResult } from '@deepseek-ai/dsh-shell' +import type { ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' import { parseExitStatus } from '@deepseek-ai/dsh-shell' +import type {} from '@deepseek-ai/dsh-activity' import { processOutcome } from './background.ts' +import { observeBackgroundActivity } from './observe.ts' import { renderPwshProcessRead, renderPwshResult } from './render.ts' import type { RenderablePwshResult } from './render.ts' @@ -45,19 +47,34 @@ declare module '@deepseek-ai/dsh-jobs' { } } +declare module '@deepseek-ai/dsh-activity' { + interface ActivityKindMap { + pwsh: 'pwsh' + } +} + export const name = 'tool-pwsh' export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] +/* jscpd:ignore-start -- the pwsh Config mirrors tool-bash's by design, like render/background. */ /** Configuration for the pwsh tool. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } /** Runtime configuration schema for the pwsh tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), + activityPollMs: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(150), }) +/* jscpd:ignore-end */ /** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ interface PwshToolArgs { @@ -194,6 +211,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = { /* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */ export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true + const activityPollMs = config.activityPollMs ?? 150 const defaultMode = ctx.shell.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') @@ -378,12 +396,14 @@ export function apply(ctx: Context, config: Config = {}): void { throw error } // Task preflight finishes before the starter can spawn a process. + let started: ShellProcess | undefined const id = jobs.start({ kind: 'pwsh', label: args.command, ...exec.agent ? { owner: exec.agent } : {}, run: () => { const proc = ctx.shell.start(ctx.shell.resolve(request)) + started = proc return { cancel: () => void proc.kill(), done: proc.done.then(() => processOutcome(proc)), @@ -391,6 +411,16 @@ export function apply(ctx: Context, config: Config = {}): void { } }, }) + // The starter ran synchronously inside the committed start; mirror the + // spawned process into the optional observation registry. + if (started !== undefined) { + observeBackgroundActivity(ctx, started, { + kind: 'pwsh', + label: args.command, + ...exec.agent ? { owner: exec.agent } : {}, + correlation: { callId: exec.callId, jobId: id }, + }, activityPollMs, processOutcome) + } return { kind: 'background' as const, jobId: id } } const result = await ctx.shell.run(ctx.shell.resolve({ diff --git a/packages/shell/tool-pwsh/src/observe.ts b/packages/shell/tool-pwsh/src/observe.ts new file mode 100644 index 0000000000..6d342dda8b --- /dev/null +++ b/packages/shell/tool-pwsh/src/observe.ts @@ -0,0 +1,74 @@ +/** + * Best-effort process-observation tap for background pwsh runs: mirrors the + * spawned process's captured streams into `ctx.activities` so non-consuming + * observers (the Web client) see live output without touching the job's + * consuming `readOutput` cursor. Absent registry or absent backend offset + * readers degrade to no observation; the job path is unaffected either way. + * + * @module @deepseek-ai/dsh-tool-pwsh/observe + */ + +/* jscpd:ignore-start -- mirrors tool-bash's observe tap by design, like render/background. */ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ShellProcess } from '@deepseek-ai/dsh-shell' +import { pumpActivityOutput } from '@deepseek-ai/dsh-activity' +import type { ActivityCorrelation, ActivityKind, ActivityPumpSource } from '@deepseek-ai/dsh-activity' + +/** Identity of one observed background run. */ +export interface ObserveBackgroundSpec { + /** Process kind — the producing tool's registered {@link ActivityKind}. */ + kind: ActivityKind + /** One-line label (the command). */ + label: string + /** Owning live agent, when the call has one. */ + owner?: Agent + /** Links to the starting tool call and the registered job. */ + correlation: ActivityCorrelation +} + +/** + * Open a process beside a committed background job and pump the handle's + * non-consuming offset readers into it until the run settles, then record the + * mapped outcome. Observation is strictly best-effort: without a + * `ctx.activities` registry this is a no-op, without backend offset readers + * the process still carries status and settlement, and ANY observation + * failure — including a rejected `open()` — is logged and swallowed, because + * the background job is already committed and running. + * @param ctx - plugin context used for the optional registry and warning logs. + * @param proc - the started background process handle. + * @param spec - process identity and correlation for the registry row. + * @param pollMs - producer-configured pump cadence in milliseconds. + * @param outcome - maps the settled handle to the process outcome. + */ +export function observeBackgroundActivity( + ctx: Context, + proc: ShellProcess, + spec: ObserveBackgroundSpec, + pollMs: number, + outcome: (proc: ShellProcess) => { status: 'completed' | 'killed'; detail: string }, +): void { + try { + const processes = ctx.get('activities') + if (processes === undefined) return + const handle = processes.open({ + kind: spec.kind, + label: spec.label, + ...spec.owner !== undefined ? { owner: spec.owner } : {}, + correlation: spec.correlation, + }) + const sources: ActivityPumpSource[] = [] + const stdout = proc.observed?.stdout + if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) + const stderr = proc.observed?.stderr + if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) + void pumpActivityOutput(handle, sources, { pollMs, done: proc.done }) + .catch((error: unknown) => { + ctx.logger.warn(`activity observation pump for ${handle.id} failed: ${String(error)}`) + }) + .then(() => { handle.end(outcome(proc)) }) + } catch (error: unknown) { + ctx.logger.warn(`activity observation unavailable for this run: ${String(error)}`) + } +} +/* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh/tests/observe.spec.ts b/packages/shell/tool-pwsh/tests/observe.spec.ts new file mode 100644 index 0000000000..25b5bbfbd4 --- /dev/null +++ b/packages/shell/tool-pwsh/tests/observe.spec.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import { ShellExecutor } from '@deepseek-ai/dsh-shell' +import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' +import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' +import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' + +const testToolSignal = new AbortController().signal + +/** ASCII-only scripted stream: readFrom offsets are byte-exact string indexes. */ +function scriptedReader(state: { text: string }) { + return { + readFrom(fromByte: number) { + return { text: state.text.slice(fromByte), nextOffset: state.text.length, lossy: false } + }, + } +} + +/** A running fake background handle with optional non-consuming observed streams. */ +function observableProcess(streams?: { stdout: { text: string }; stderr: { text: string } }) { + let resolveDone: () => void = () => {} + const done = new Promise((resolve) => { resolveDone = resolve }) + const proc: ShellProcess = { + status: 'running', + exitCode: null, + signal: null, + done, + readOutput: () => ({ delta: '', lossy: false }), + kill: () => false, + ...streams !== undefined + ? { observed: { stdout: scriptedReader(streams.stdout), stderr: scriptedReader(streams.stderr) } } + : {}, + } + return { + proc, + finish() { + proc.status = 'completed' + proc.exitCode = 0 + resolveDone() + }, + } +} + +class FakePwsh extends ShellExecutor { + backgroundHandler: (spec: ShellExecSpec) => ShellProcess = () => { throw new Error('unscripted start') } + + override resolve(request: ShellExecRequest): ShellExecSpec { + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + ...request.signal ? { signal: request.signal } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + override async run(): Promise { + throw new Error('foreground is not exercised here') + } + + override start(spec: ShellExecSpec): ShellProcess { + return this.backgroundHandler(spec) + } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin(LocalActivityRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakePwsh) + await ctx.plugin(ToolPwsh, { activityPollMs: 5 }) + return { ctx, pwsh: ctx.shell as FakePwsh } +} + +let callCounter = 0 +function call(ctx: Context, args: Record) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`pwsh-observe-${++callCounter}`), + name: 'pwsh', + arguments: args, + }) +} + +async function until(read: () => T | undefined, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const value = read() + if (value !== undefined) return value + await new Promise(resolve => setTimeout(resolve, 5)) + } + throw new Error('condition not reached before timeout') +} + +describe('background pwsh observation', () => { + it('mirrors observed streams with channels and settles with the mapped outcome', async () => { + const { ctx, pwsh } = await setup() + const stdout = { text: '' } + const stderr = { text: '' } + const scripted = observableProcess({ stdout, stderr }) + pwsh.backgroundHandler = () => scripted.proc + + await call(ctx, { command: 'Get-Progress', description: 'test command', run_in_background: true }) + const job = ctx.jobs.list()[0] + const row = await until(() => ctx.activities.list().find(item => item.correlation?.jobId === job!.id)) + expect(row.kind).toBe('pwsh') + + stdout.text = 'progress-line\n' + stderr.text = 'warn-line\n' + await until(() => { + const chunks = ctx.activities.read(row.id, 0).chunks + return chunks.some(chunk => chunk.text.includes('progress-line')) + && chunks.some(chunk => chunk.text.includes('warn-line')) + ? true + : undefined + }) + const chunks = ctx.activities.read(row.id, 0).chunks + expect(chunks.find(chunk => chunk.text.includes('progress-line'))?.channel).toBe('stdout') + expect(chunks.find(chunk => chunk.text.includes('warn-line'))?.channel).toBe('stderr') + + scripted.finish() + await until(() => ctx.activities.get(row.id).status === 'completed' ? true : undefined) + expect(ctx.activities.get(row.id).detail).toBe('exit code: 0') + }) + + it('a backend without observed readers still yields a status-only process', async () => { + const { ctx, pwsh } = await setup() + const scripted = observableProcess() + pwsh.backgroundHandler = () => scripted.proc + + await call(ctx, { command: 'Start-Job', description: 'test command', run_in_background: true }) + const job = ctx.jobs.list()[0] + const row = await until(() => ctx.activities.list().find(item => item.correlation?.jobId === job!.id)) + expect(ctx.activities.read(row.id, 0).chunks).toEqual([]) + + scripted.finish() + await until(() => ctx.activities.get(row.id).status === 'completed' ? true : undefined) + expect(ctx.activities.get(row.id).outputTotal).toBe(0) + }) +}) diff --git a/packages/shell/tool-pwsh/tsconfig.json b/packages/shell/tool-pwsh/tsconfig.json index 59a4d9478c..d38c8f031d 100644 --- a/packages/shell/tool-pwsh/tsconfig.json +++ b/packages/shell/tool-pwsh/tsconfig.json @@ -52,6 +52,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../activity/activity" } ] } diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml index f87a12407f..65a8c97d7b 100644 --- a/packages/workflow/tool-workflow/README.i18n.yaml +++ b/packages/workflow/tool-workflow/README.i18n.yaml @@ -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/workflow/tool-workflow/README.md -README.md: 918fe5b9aff74b3b959107ec1eebfd7966afe6d1 -README.zh.md: 2546a0fc92ef39d71949cc0061dcf363ecc2e22d +README.md: 29e84c7386768b77c7b85ed8426f6f604a53fb44 +README.zh.md: 582828cf7c2d5b6c6e7dca6230d29fe744b578fe diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md index 918fe5b9af..29e84c7386 100644 --- a/packages/workflow/tool-workflow/README.md +++ b/packages/workflow/tool-workflow/README.md @@ -14,7 +14,7 @@ Collection is synchronous (like [`dsh-tool-subagent`](../../subagent/tool-subage For a root transport execution (`exec.parent` absent), the tool also projects the run into the calling Agent's Session: run-start after `start()` returns, matching member starts and endings filtered by `run.id`, then run-end only after `run.result` is available and `dispose()` has reached quiescence. Nested transport calls execute normally but write no workflow record. The first failed Session append disables later recording for that run, emits one warning, and leaves either no record or a legal continuous prefix without changing the tool result or cleanup. -The browser-safe `@deepseek-ai/dsh-tool-workflow/types` subpath owns these four log-only event payloads and their `SessionEventMap` declaration. The package invariant rejects duplicate starts, unpaired members, terminal events with open members, and updates after run-end on both cold load and live append while accepting missing terminal suffixes. +The browser-safe `@deepseek-ai/dsh-tool-workflow/types` subpath owns these four log-only event payloads and their `SessionEventMap` declaration. Beside the durable record, each recorded top-level run is mirrored best-effort into the optional `ctx.activities` registry as a `workflow` activity: the otherwise-unconsumed `workflow/phase` and `workflow/log` events plus member lifecycle lines stream into it, so the Web client shows the run's live narration the session log deliberately does not carry per line; without the registry, or on any mirror failure, the run is unaffected. The package invariant rejects duplicate starts, unpaired members, terminal events with open members, and updates after run-end on both cold load and live append while accepting missing terminal suffixes. ## Render intent diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md index 2546a0fc92..582828cf7c 100644 --- a/packages/workflow/tool-workflow/README.zh.md +++ b/packages/workflow/tool-workflow/README.zh.md @@ -14,7 +14,7 @@ 对于根 transport 执行(`exec.parent` 缺省),工具还会把运行投影到调用 Agent 的 Session:`start()` 返回后写 run-start,只记录 `run.id` 匹配的成员开始与结束,并且只在 `run.result` 已取得且 `dispose()` 完全停稳后写 run-end。嵌套 transport 调用照常执行,但不写工作流记录。任一次 Session append 首次失败后,本运行会停止后续记录并只告警一次,留下空记录或合法连续前缀,同时不改变工具结果和清理。 -浏览器安全的 `@deepseek-ai/dsh-tool-workflow/types` 子路径拥有这四类 log-only 事件 payload 及其 `SessionEventMap` 声明。包 invariant 会在冷加载和实时追加时拒绝重复 start、未配对成员、仍有开放成员的终点和 run-end 后更新,同时允许缺失终态后缀的连续前缀。 +浏览器安全的 `@deepseek-ai/dsh-tool-workflow/types` 子路径拥有这四类 log-only 事件 payload 及其 `SessionEventMap` 声明。在持久记录之外,每个被记录的顶层运行还会尽力镜像进可选的 `ctx.activities` 注册表成为一个 `workflow` activity:原本无人消费的 `workflow/phase` 与 `workflow/log` 事件加上成员生命周期行会流入其中,Web 客户端因此能看到会话日志有意不逐行携带的实时叙述;没有该注册表或镜像任何失败时,运行不受影响。包 invariant 会在冷加载和实时追加时拒绝重复 start、未配对成员、仍有开放成员的终点和 run-end 后更新,同时允许缺失终态后缀的连续前缀。 ## 渲染意图 diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 455d2ee54c..6cf4bebe3d 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -37,19 +37,23 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-workflow": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-workflow": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-activity-local": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -58,7 +62,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", - "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^" } } diff --git a/packages/workflow/tool-workflow/src/activity.ts b/packages/workflow/tool-workflow/src/activity.ts new file mode 100644 index 0000000000..36b251b894 --- /dev/null +++ b/packages/workflow/tool-workflow/src/activity.ts @@ -0,0 +1,119 @@ +/** + * Best-effort activity mirror for top-level workflow runs: opens one + * `workflow` activity per run and streams the engine's `workflow/phase`, + * `workflow/log`, and member lifecycle events into it as text lines, so the + * Web client shows live progress the session log deliberately does not carry + * per line. Without a `ctx.activities` registry every operation is a no-op, + * and ANY mirror failure is logged and swallowed — the run is unaffected. + * + * @module @deepseek-ai/dsh-tool-workflow/activity + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ActivityHandle } from '@deepseek-ai/dsh-activity' +import type { WorkflowRun, WorkflowRunId, WorkflowStopReason } from '@deepseek-ai/dsh-workflow' + +declare module '@deepseek-ai/dsh-activity' { + interface ActivityKindMap { + workflow: 'workflow' + } +} + +/** Activity taps for the three points the tool already tracks a run at. */ +export interface WorkflowActivityMirror { + /** + * Open the run's activity. Call once per recorded top-level run. + * @param run - the started run (id and meta name). + * @param owner - the calling agent, when the call has one. + */ + start(run: WorkflowRun, owner: Agent | undefined): void + /** + * End the run's activity with the mapped stop reason. + * @param runId - the settled run. + * @param stopReason - how the run stopped. + */ + finish(runId: WorkflowRunId, stopReason: WorkflowStopReason): void + /** + * Drop tracking for a run that ends without a settled reason. First-wins + * with {@link finish}: after a normal finish this is inert. + * @param runId - the abandoned run. + */ + abandon(runId: WorkflowRunId): void +} + +/** Map a run's stop reason onto the activity outcome vocabulary. */ +function outcomeOf(stopReason: WorkflowStopReason): { status: 'completed' | 'failed' | 'killed'; detail: string } { + switch (stopReason) { + case 'completed': return { status: 'completed', detail: 'completed' } + case 'cancelled': return { status: 'killed', detail: 'cancelled' } + case 'error': return { status: 'failed', detail: 'error' } + } +} + +/** + * Create the run-to-activity mirror and subscribe the engine's live progress + * events for the runs it tracks. + * @param ctx - plugin context used for the optional registry and warning logs. + * @returns the mirror taps the tool wires beside its durable recorder. + */ +export function createWorkflowActivityMirror(ctx: Context): WorkflowActivityMirror { + const active = new Map() + const contained = (operation: string, act: () => void): void => { + try { + act() + } catch (error: unknown) { + ctx.logger.warn(`workflow activity mirror ${operation} failed: ${String(error)}`) + } + } + + ctx.on('workflow/phase', (info, title) => { + const handle = active.get(info.id) + if (handle === undefined) return + contained('phase', () => { + handle.updateDetail(title) + handle.append(`▸ ${title}\n`) + }) + }) + ctx.on('workflow/log', (info, message) => { + const handle = active.get(info.id) + if (handle === undefined) return + contained('log', () => { handle.append(`${message}\n`) }) + }) + ctx.on('workflow/agent-start', (info, agent) => { + const handle = active.get(info.id) + if (handle === undefined) return + contained('agent-start', () => { handle.append(`agent #${agent.seq} ${agent.label} started\n`) }) + }) + ctx.on('workflow/agent-end', (info, agent) => { + const handle = active.get(info.id) + if (handle === undefined) return + contained('agent-end', () => { handle.append(`agent #${agent.seq} ${agent.outcome}\n`) }) + }) + + return { + start(run, owner) { + contained('open', () => { + const activities = ctx.get('activities') + if (activities === undefined) return + active.set(run.id, activities.open({ + kind: 'workflow', + label: run.meta.name, + ...owner !== undefined ? { owner } : {}, + })) + }) + }, + finish(runId, stopReason) { + const handle = active.get(runId) + active.delete(runId) + if (handle === undefined) return + contained('finish', () => { handle.end(outcomeOf(stopReason)) }) + }, + abandon(runId) { + const handle = active.get(runId) + active.delete(runId) + if (handle === undefined) return + contained('abandon', () => { handle.end({ status: 'killed', detail: 'run abandoned' }) }) + }, + } +} diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 446c70ce03..0da8c0ff83 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -25,6 +25,7 @@ import type { } from './types.ts' // Declaration merge only: makes ctx.systemPrompt visible for the section registration. import type {} from '@deepseek-ai/dsh-system-prompt' +import { createWorkflowActivityMirror } from './activity.ts' export const name = 'tool-workflow' export const inject = ['tools', 'workflowEngine', 'systemPrompt'] @@ -207,6 +208,7 @@ export function apply(ctx: Context, config: Config): void { // fields; the assertion records that resolution, not a hidden fallback. const { toolName, maxResultChars } = config as ResolvedConfig const recorder = createWorkflowRecorder(ctx) + const mirror = createWorkflowActivityMirror(ctx) // Usage policy ships with the tool (the master convention: tool guidance // lives in tool plugins as prompt sections, not in the deployment persona). ctx.systemPrompt.section({ @@ -291,7 +293,10 @@ export function apply(ctx: Context, config: Config): void { const recordsRun = exec.parent === undefined // The shipped worker-thread engine publishes member events from later // worker messages, after start() returns and this run record is active. - if (recordsRun) recorder.start(parent.session, run) + if (recordsRun) { + recorder.start(parent.session, run) + mirror.start(run, parent) + } // Bridge the tool's abort signal to the run: if the parent step is aborted while the // script is in flight, cancel the whole run. The signal also enters the engine directly, but @@ -323,9 +328,13 @@ export function apply(ctx: Context, config: Config): void { /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */ if (result === undefined) throw new Error('workflow run settled without a result') recorder.finish(run.id, result.stopReason) + mirror.finish(run.id, result.stopReason) } } finally { - if (recordsRun) recorder.abandon(run.id) + if (recordsRun) { + recorder.abandon(run.id) + mirror.abandon(run.id) + } } } }, diff --git a/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts new file mode 100644 index 0000000000..50788d453f --- /dev/null +++ b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import type { WorkflowRun, WorkflowRunId, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' +import { createWorkflowActivityMirror } from '../src/activity.ts' + +function runStub(id = 'run-1'): { run: WorkflowRun; info: WorkflowRunInfo } { + const info = { id: id as WorkflowRunId, meta: { name: 'docs-sweep' } } as WorkflowRunInfo + return { + run: { id: id as WorkflowRunId, meta: { name: 'docs-sweep' } } as WorkflowRun, + info, + } +} + +function retainedText(ctx: Context): string { + const row = ctx.activities.list()[0] + if (row === undefined) throw new Error('no mirrored activity') + return ctx.activities.read(row.id, 0).chunks.map(chunk => chunk.text).join('') +} + +describe('workflow activity mirror', () => { + it('opens one workflow activity per run and streams phase, log, and member lines', async () => { + const ctx = new Context() + await ctx.plugin(LocalActivityRegistry) + const mirror = createWorkflowActivityMirror(ctx) + const { run, info } = runStub() + mirror.start(run, undefined) + + const row = ctx.activities.list()[0] + expect(row?.kind).toBe('workflow') + expect(row?.label).toBe('docs-sweep') + + ctx.emit('workflow/phase', info, 'Scan') + ctx.emit('workflow/log', info, '3 files queued') + ctx.emit('workflow/agent-start', info, { seq: 1, label: 'reader', childId: 'c1' } as never) + ctx.emit('workflow/agent-end', info, { seq: 1, label: 'reader', childId: 'c1', outcome: 'completed' } as never) + expect(ctx.activities.get(row!.id).detail).toBe('Scan') + expect(retainedText(ctx)).toBe('▸ Scan\n3 files queued\nagent #1 reader started\nagent #1 completed\n') + + mirror.finish(run.id, 'completed') + const settled = ctx.activities.get(row!.id) + expect(settled.status).toBe('completed') + // The always-run abandon after a normal finish is inert. + mirror.abandon(run.id) + expect(ctx.activities.get(row!.id).status).toBe('completed') + }) + + it('maps cancelled and error stops, and ends an abandoned run as killed', async () => { + const ctx = new Context() + await ctx.plugin(LocalActivityRegistry) + const mirror = createWorkflowActivityMirror(ctx) + + const cancelled = runStub('run-c') + mirror.start(cancelled.run, undefined) + mirror.finish(cancelled.run.id, 'cancelled') + + const failed = runStub('run-e') + mirror.start(failed.run, undefined) + mirror.finish(failed.run.id, 'error') + + const orphaned = runStub('run-o') + mirror.start(orphaned.run, undefined) + mirror.abandon(orphaned.run.id) + + const byLabelOrder = ctx.activities.list() + expect(byLabelOrder.map(row => [row.status, row.detail])).toEqual([ + ['killed', 'cancelled'], + ['failed', 'error'], + ['killed', 'run abandoned'], + ]) + }) + + it('ignores events for untracked runs and stays inert without the registry', async () => { + const bare = new Context() + const inert = createWorkflowActivityMirror(bare) + const { run, info } = runStub() + // No registry: every tap is a no-op rather than a throw. + inert.start(run, undefined) + bare.emit('workflow/log', info, 'dropped') + inert.finish(run.id, 'completed') + inert.abandon(run.id) + + const ctx = new Context() + await ctx.plugin(LocalActivityRegistry) + createWorkflowActivityMirror(ctx) + // Events for a run the mirror never opened change nothing. + ctx.emit('workflow/phase', info, 'ghost') + expect(ctx.activities.list()).toEqual([]) + }) + + it('contains a throwing registry so the run path never breaks', async () => { + const ctx = new Context() + const warn = vi.fn() + ctx.logger.warn = warn as never + await ctx.plugin({ + name: 'broken-activities-probe', + apply(child: Context) { + child.provide('activities', { open() { throw new Error('mirror boom') } }) + }, + }) + const mirror = createWorkflowActivityMirror(ctx) + const { run } = runStub() + expect(() => { mirror.start(run, undefined) }).not.toThrow() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('workflow activity mirror open failed')) + }) +}) diff --git a/packages/workflow/tool-workflow/tsconfig.json b/packages/workflow/tool-workflow/tsconfig.json index bfd38e61d4..a4cde5cbef 100644 --- a/packages/workflow/tool-workflow/tsconfig.json +++ b/packages/workflow/tool-workflow/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../activity/activity" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6878a775b..32f9a464b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -886,6 +886,110 @@ importers: specifier: workspace:^ version: link:../../interaction/user-approval + packages/activity/activity: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + + packages/activity/activity-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../activity + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + + packages/api/activity-controller: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../activity/activity + '@deepseek-ai/dsh-activity-local': + specifier: workspace:^ + version: link:../../activity/activity-local + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../gateway + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + packages/api/gateway: dependencies: '@deepseek-ai/dsh-typert-protocol': @@ -938,6 +1042,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-api-activity-controller': + specifier: workspace:^ + version: link:../activity-controller '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway @@ -1240,6 +1347,9 @@ importers: '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer + '@deepseek-ai/dsh-activity-local': + specifier: workspace:^ + version: link:../../activity/activity-local '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -1607,6 +1717,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-api-activity-controller': + specifier: workspace:^ + version: link:../../api/activity-controller '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -1631,6 +1744,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules + '@deepseek-ai/dsh-client-ui-activity': + specifier: workspace:^ + version: link:../../client/ui-activity '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../client/ui-agent-preset @@ -1976,6 +2092,45 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/client/ui-activity: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-activity-controller': + specifier: workspace:^ + version: link:../../api/activity-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-agent-preset: devDependencies: '@deepseek-ai/cordis': @@ -7701,6 +7856,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7820,6 +7978,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -9538,6 +9699,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../activity/activity + '@deepseek-ai/dsh-activity-local': + specifier: workspace:^ + version: link:../../activity/activity-local '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -9690,6 +9857,9 @@ importers: '@deepseek-ai/dsh-acp': specifier: workspace:^ version: link:../../packages/acp/acp + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../packages/activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index d5684867e6..3becddb8c7 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -5,6 +5,7 @@ "private": true, "type": "module", "dependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-group": "workspace:^", "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -12,15 +13,17 @@ "@deepseek-ai/cosmokit": "workspace:^", "@deepseek-ai/dsh": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^", + "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-atomic-write": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", @@ -32,44 +35,43 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-round-driver": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-hooks-claude-code": "workspace:^", "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", - "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", - "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-persona": "workspace:^", - "@deepseek-ai/dsh-pwsh-local": "workspace:^", - "@deepseek-ai/dsh-terminal": "workspace:^", - "@deepseek-ai/dsh-terminal-bash": "workspace:^", - "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", + "@deepseek-ai/dsh-permission-presets": "workspace:^", + "@deepseek-ai/dsh-persona": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", + "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", @@ -80,6 +82,8 @@ "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", @@ -91,31 +95,30 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", - "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-http": "workspace:^", @@ -124,8 +127,6 @@ "@deepseek-ai/dsh-web-search-perplexity": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/dsh-agent-instructions": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 05a4c8947c..095c33af7c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -105,6 +105,8 @@ export const SERVICE_PAGE: Record = { subprocess: 'subprocess.md', systemPrompt: 'system-prompt.md', jobs: 'jobs.md', + activities: 'activity.md', + activityController: 'activity.md', sessionTelemetry: 'session-telemetry.md', agentTeams: 'agent-team.md', tokenMeter: 'token-meter.md', @@ -168,6 +170,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API', workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API', + activityFeed: 'client-side interface-typed browser service — packages/api/activity-controller/README.md owns the API', } /** @@ -477,6 +480,16 @@ export const LINK_MAP: Readonly> = { PromptSection: 'system-prompt.md', SystemPrompt: 'system-prompt.md', ToolProviderResult: 'system-prompt.md', + ActivitiesChangedListener: 'activity.md', + ActivityHandle: 'activity.md', + ActivityId: 'activity.md', + ActivityOpen: 'activity.md', + ActivityOutputListener: 'activity.md', + ActivityRead: 'activity.md', + ActivitySnapshot: 'activity.md', + ActivityControlFrame: 'activity.md', + ActivityObserveFrame: 'activity.md', + ActivityObserveRequest: 'activity.md', JobDoneListener: 'jobs.md', JobId: 'jobs.md', JobRead: 'jobs.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index a47f94b943..83983f294f 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -164,6 +164,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace.', }, + { + key: 'activityController', + pkg: 'api-activity-controller', + title: 'Host Activity Remote controller', + mode: 'core', + note: 'Owns the live-activity roster stream and per-activity non-consuming output observation through the generated Remote namespace.', + }, { key: 'invariants', pkg: 'invariants', @@ -537,6 +544,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-bash', 'tool-terminal', 'tool-subagent', 'tool-jobs'], note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.', }, + { + key: 'activities', + pkg: 'activity', + title: 'Live activity observation registry', + mode: 'seam', + implementations: ['activity-local'], + consumers: ['tool-bash', 'tool-pwsh', 'tool-workflow', 'activity-controller'], + note: 'Producers mirror live output and status into non-consuming offset-addressed streams; activity-controller serves them to browsers, and the model-facing paths (jobs cursors, tool results) stay untouched.', + }, { key: 'web', pkg: 'web', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index f83b621965..48bc458bfa 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -45,6 +45,10 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, + 'packages/activity/activity': { kind: 'none', reason: 'Pure observation seam for humans; activity output becomes model-visible only through producers\' existing tool results and jobs reads.' }, + 'packages/activity/activity-local': { kind: 'none', reason: 'In-memory observation provider; producers own every model-visible surface.' }, + 'packages/api/activity-controller': { kind: 'none', reason: 'Browser/Host observation transport; registers no prompt, tool, or session event.' }, + 'packages/client/ui-activity': { kind: 'none', reason: 'Browser-side presentation of host-observed output; touches no prompt, message, schema, stream, or tool result.' }, 'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, 'packages/shell/shell-env': { kind: 'indirect', reason: 'The env service exposes managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' }, 'packages/shell/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 8a8bc37baa..11ae967059 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-api-gateway/client": ["./packages/api/gateway/src/client/index.ts"], "@deepseek-ai/dsh-api-gateway/invariant": ["./packages/api/gateway/src/invariant.ts"], "@deepseek-ai/dsh-api-gateway/types": ["./packages/api/gateway/src/types.ts"], + "@deepseek-ai/dsh-api-activity-controller": ["./packages/api/activity-controller/src/index.ts"], "@deepseek-ai/dsh-api-session-controller": ["./packages/api/session-controller/src/index.ts"], "@deepseek-ai/dsh-api-session-controller/client": ["./packages/api/session-controller/src/client/index.ts"], "@deepseek-ai/dsh-api-session-controller/invariant": ["./packages/api/session-controller/src/invariant.ts"], @@ -56,6 +57,7 @@ "@deepseek-ai/dsh-api-workspace-controller/client": ["./packages/api/workspace-controller/src/client/index.ts"], "@deepseek-ai/dsh-api-workspace-controller/invariant": ["./packages/api/workspace-controller/src/invariant.ts"], "@deepseek-ai/dsh-api-workspace-controller/types": ["./packages/api/workspace-controller/src/types.ts"], + "@deepseek-ai/dsh-api-activity-controller/types": ["./packages/api/activity-controller/src/types.ts"], "@deepseek-ai/dsh-typert-protocol": ["./packages/typert/protocol/src/index.ts"], "@deepseek-ai/dsh-typert-protocol/types": ["./packages/typert/protocol/src/types.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], @@ -95,6 +97,8 @@ "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], "@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"], + "@deepseek-ai/dsh-activity/brand": ["./packages/activity/activity/src/brand.ts"], + "@deepseek-ai/dsh-activity/invariant": ["./packages/activity/activity/src/invariant.ts"], "@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"], "@deepseek-ai/dsh-authorization/types": ["./packages/credentials/authorization/src/types.ts"], "@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"], @@ -245,6 +249,7 @@ "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], "@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"], "@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"], + "@deepseek-ai/dsh-client-ui-activity": ["./packages/client/ui-activity/src"], "@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"], "@deepseek-ai/dsh-client-ui-user-questions": ["./packages/client/ui-user-questions/src"], "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], @@ -288,6 +293,7 @@ "./packages/preset/*/src", "./packages/subagent/*/src", "./packages/jobs/*/src", + "./packages/activity/*/src", "./packages/experimental/*/src", "./packages/workflow/*/src", "./packages/webhook/*/src", diff --git a/tsconfig.client.json b/tsconfig.client.json index 291b813300..41f6aea41a 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -56,6 +56,7 @@ { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/session-controller/tsconfig.client.json" }, + { "path": "./packages/api/activity-controller/tsconfig.client.json" }, { "path": "./packages/api/workspace-controller/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/extensions/cordis-client-runner" }, @@ -77,6 +78,7 @@ { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-jobs" }, + { "path": "./packages/client/ui-activity" }, { "path": "./packages/client/ui-directory-picker-browse" }, { "path": "./packages/client/ui-directory-picker-native" }, { "path": "./packages/client/ui-goal" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 0bcbe50df3..d8bcd91c08 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -77,6 +77,7 @@ "apps/web/tests/subagent-interrupt-ui.e2e.ts", "apps/web/tests/sidebar-subagent-activity.e2e.ts", "apps/web/tests/background-job-list.e2e.ts", + "apps/web/tests/live-activity-stream.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", "apps/web/tests/turn-tail-actions.e2e.ts", @@ -146,6 +147,7 @@ { "path": "./packages/api/gateway/tsconfig.host.json" }, { "path": "./packages/api/remotes/tsconfig.host.json" }, { "path": "./packages/api/session-controller/tsconfig.host.json" }, + { "path": "./packages/api/activity-controller/tsconfig.host.json" }, { "path": "./packages/api/workspace-controller/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session/session-persistence" }, @@ -288,6 +290,8 @@ { "path": "./packages/jobs/jobs" }, { "path": "./packages/jobs/jobs-local" }, { "path": "./packages/jobs/tool-jobs" }, + { "path": "./packages/activity/activity" }, + { "path": "./packages/activity/activity-local" }, { "path": "./packages/experimental/agent-team" }, { "path": "./packages/experimental/tool-agent-team" }, { "path": "./packages/workflow/workflow" }, From ddd8baa93a7e6c7d3b61ab3d94ab3438f5e42f17 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 23:07:17 +0800 Subject: [PATCH 003/284] fix(activity): mark evicted heads on fresh observation and fit the popover to the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser acceptance against a live scaffold surfaced two defects: - A fresh observation anchors at the registry's earliest retained byte, so the client's gap predicate (anchor behind earliest) never fired for it: a viewer who watched eviction live saw the gap notice, while a reload of the same activity silently hid that the head was discarded. A fresh view (no accumulated text) anchored past offset zero now carries the gap mark; resumes keep their recorded state. - The trigger-anchored popover (left: 0, width 440px) overflowed the right viewport edge on narrow windows — max-width alone cannot see the anchor offset. The open popover now measures its anchor and shifts left to keep the Menu primitive's 12px viewport margin, re-fitting on window resize and falling back to the pure CSS anchor when layout reports no width (jsdom). JobListAction shares the latent overflow; tracked separately. --- .../activity-controller/src/client/model.ts | 7 +++- .../tests/client-model.client.spec.ts | 15 ++++++++ .../src/client/ActivityListAction.tsx | 37 ++++++++++++++++++- .../activity-list-action.client.spec.tsx | 37 +++++++++++++++++++ 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/packages/api/activity-controller/src/client/model.ts b/packages/api/activity-controller/src/client/model.ts index 96fb7cca5a..75354f1255 100644 --- a/packages/api/activity-controller/src/client/model.ts +++ b/packages/api/activity-controller/src/client/model.ts @@ -128,10 +128,15 @@ export class ClientActivityModel { */ observeOpened(id: ActivityId, frame: Extract): void { const existing = this.observedStates.get(String(id)) + // A fresh view anchored past offset zero starts after an evicted head + // (fresh observations anchor at the registry's earliest retained byte), so + // it owes the same gap mark a live observer earned from lossy reads. A + // resume that already accumulated text keeps its recorded gap state. + const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0 const view: ObservedActivity = { activityId: id, text: existing?.view.text ?? '', - gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest, + gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead, status: frame.status, ...frame.detail !== undefined ? { detail: frame.detail } : {}, streaming: true, diff --git a/packages/api/activity-controller/tests/client-model.client.spec.ts b/packages/api/activity-controller/tests/client-model.client.spec.ts index 59ec9067c5..31f5d18c33 100644 --- a/packages/api/activity-controller/tests/client-model.client.spec.ts +++ b/packages/api/activity-controller/tests/client-model.client.spec.ts @@ -106,6 +106,21 @@ describe('ClientActivityModel observation', () => { expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) }) + it('marks a fresh observation anchored past the evicted head', () => { + // Fresh observations anchor at the registry's earliest retained byte, so + // from === earliest > 0 means the head was already discarded. + const fresh = new ClientActivityModel() + opened(fresh, { from: 60_240, earliest: 60_240, total: 321_328 }) + expect(fresh.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + // A retry that accumulated no text yet earns the mark the same way. + const retried = new ClientActivityModel() + opened(retried) + expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(false) + opened(retried, { from: 6, earliest: 6, total: 6 }) + expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + }) + it('bounds the render tail without splitting a surrogate pair', () => { const model = new ClientActivityModel() opened(model) diff --git a/packages/client/ui-activity/src/client/ActivityListAction.tsx b/packages/client/ui-activity/src/client/ActivityListAction.tsx index 698815e171..1f868988b0 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.tsx +++ b/packages/client/ui-activity/src/client/ActivityListAction.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import type { ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, } from '@deepseek-ai/dsh-api-activity-controller/client' @@ -36,6 +36,9 @@ export type ActivityListActionProps = /** Stable empty list so a session with no activities keeps one array identity. */ const NO_ROWS: readonly ActivityRow[] = [] +/** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */ +const VIEWPORT_MARGIN = 12 + /** Height cap for one live output panel inside the popover. */ const PANEL_MAX_LINES = 16 @@ -170,12 +173,42 @@ export function ActivityListAction({ sessionId, useActivity, observe, t }: Activ const [expandedId, setExpandedId] = useState(undefined) const rootRef = useRef(null) const triggerRef = useRef(null) + const menuRef = useRef(null) + // Horizontal shift applied to the trigger-anchored popover so it stays + // inside the viewport (the stylesheet alone cannot see the anchor offset). + const [menuShift, setMenuShift] = useState(0) const rows = useMemo(() => ordered([...owned, ...unowned]), [owned, unowned]) const liveCount = useMemo(() => rows.filter(isLive).length, [rows]) useDismissOnOutsidePointer(rootRef, open, setOpen) + // Fit the open popover to the viewport: shift left when the anchored width + // would cross the right edge, never past the left margin. + useLayoutEffect(() => { + if (!open) { + setMenuShift(0) + return + } + const fit = (): void => { + const root = rootRef.current + const menu = menuRef.current + /* v8 ignore next -- both refs are attached while the open popover renders. */ + if (root === null || menu === null) return + const width = menu.offsetWidth + // Unlaid-out nodes (and jsdom) measure 0: keep the pure CSS anchor. + if (width === 0) return + const anchorLeft = root.getBoundingClientRect().left + setMenuShift(Math.max( + VIEWPORT_MARGIN - anchorLeft, + Math.min(0, window.innerWidth - VIEWPORT_MARGIN - width - anchorLeft), + )) + } + fit() + window.addEventListener('resize', fit) + return () => { window.removeEventListener('resize', fit) } + }, [open]) + // Observation follows visibility: the stream opens when a panel expands and // closes when it collapses, unmounts, or the popover closes. const activeId = open ? expandedId : undefined @@ -229,7 +262,7 @@ export function ActivityListAction({ sessionId, useActivity, observe, t }: Activ {/* jscpd:ignore-end */} {open ? ( -
      +
        {rows.map(row => ( { expect(screen.getAllByText(zh['status.completed']).length).toBeGreaterThan(0) }) + it('shifts an overflowing popover back inside the viewport and follows resizes', () => { + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440) + vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 344 } as DOMRect) + const originalWidth = window.innerWidth + Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) + try { + render() + openList() + const menu = screen.getByRole('list', { name: zh['list.aria'] }) + // 700 - 12 - 440 - 344 = -96: the popover moves left to keep the margin. + expect(menu.style.left).toBe('-96px') + + window.innerWidth = 900 + fireEvent(window, new Event('resize')) + // 900 - 12 - 440 - 344 = 104 > 0: the anchored position fits again. + expect(menu.style.left).toBe('0px') + } finally { + Object.defineProperty(window, 'innerWidth', { value: originalWidth, configurable: true, writable: true }) + } + }) + + it('never crosses the left viewport margin for an oversized popover', () => { + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800) + vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 4 } as DOMRect) + const originalWidth = window.innerWidth + Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) + try { + render() + openList() + const menu = screen.getByRole('list', { name: zh['list.aria'] }) + // max(12 - 4, min(0, 700 - 12 - 800 - 4)) = 8: clamped at the left margin. + expect(menu.style.left).toBe('8px') + } finally { + Object.defineProperty(window, 'innerWidth', { value: originalWidth, configurable: true, writable: true }) + } + }) + it('ignores other keys while open and closes once the roster empties', () => { const settledPair = [ row({ id: 'bash-1' as ActivityId, status: 'completed', finishedAt: 2 }), From 61aadb3abf337875c43f95152a17c7ee9dc8d3ec Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 11:58:43 +0800 Subject: [PATCH 004/284] feat(activity): one session-header task list joining jobs with their activities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser acceptance of the activity panel surfaced the product problem: a background bash registers in both planes, so the header grew two near- identical popovers — "background jobs" with lifecycle and duration, "activities" with live output — listing the same work twice. The service planes stay orthogonal (jobs: model-visible control with a consuming read cursor; activities: transient non-consuming observation). The join happens per row in the UI projection instead: - dsh-client-ui-activity renders the single merged "tasks" list. Each jobsBySession row joins the activity carrying its correlation.jobId — the job supplies identity, lifecycle (stopping exists only there), duration, and the model-visible detail; the activity supplies the expandable live-output panel. Standalone activities (workflow runs) keep their own rows; without the activity registry, job rows still render and the control degrades to the old job list exactly. - dsh-client-ui-jobs is deleted (pre-release, no shim); the merged entry takes its slot order. The job locale namespace folds into activity. - User-facing copy says "task" (任务): N tasks running / N 个任务进行中. - Hierarchy: live rows first as two lines (label over kind badge and status, ticking duration), a Finished divider when both sections exist, settled rows compacted to one de-emphasized line, and the output panel hangs behind a spine under its row. Deliberately not joined: subagent rows stay bare (the subagent panel is their surface) and foreground commands stay in tool cards. Both web e2e scenarios now assert one Tasks list; the background-job scenario's row gained a live-output panel, proving the join end to end. Agent Note: 2026-08-25-unified-task-list. --- ...08-08-web-background-job-display.i18n.yaml | 4 +- .../2026-08-08-web-background-job-display.md | 2 +- ...026-08-08-web-background-job-display.zh.md | 2 +- .../2026-08-25-unified-task-list.i18n.yaml | 6 + .../feature/2026-08-25-unified-task-list.md | 31 ++ .../2026-08-25-unified-task-list.zh.md | 31 ++ apps/web/tests/background-job-list.e2e.ts | 6 +- apps/web/tests/live-activity-stream.e2e.ts | 4 +- .../background-job-list/running.expected.md | 7 +- .../background-job-list/settled.expected.md | 7 +- .../live-activity-stream/settled.expected.md | 4 +- .../streaming.expected.md | 4 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 - docs/config-catalog.zh.md | 1 - docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 57 +++- docs/module-graph.zh.md | 57 +++- packages/bundle/web-app/cordis.patch.yml | 3 - packages/bundle/web-app/package.json | 1 - packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 2 +- packages/client/README.zh.md | 2 +- packages/client/ui-activity/README.i18n.yaml | 4 +- packages/client/ui-activity/README.md | 9 +- packages/client/ui-activity/README.zh.md | 9 +- packages/client/ui-activity/package.json | 4 +- .../src/client/ActivityListAction.module.css | 61 +++- .../src/client/ActivityListAction.tsx | 310 ++++++++++++++---- .../client/ui-activity/src/client/index.ts | 18 +- .../client/ui-activity/src/client/locales.ts | 34 +- .../activity-list-action.client.spec.tsx | 104 +++++- .../tests/browser-plugin.client.spec.ts | 3 +- packages/client/ui-activity/tsconfig.json | 1 + packages/client/ui-jobs/README.i18n.yaml | 6 - packages/client/ui-jobs/README.md | 24 -- packages/client/ui-jobs/README.zh.md | 24 -- packages/client/ui-jobs/package.json | 76 ----- .../src/client/JobListAction.module.css | 125 ------- .../ui-jobs/src/client/JobListAction.tsx | 183 ----------- packages/client/ui-jobs/src/client/index.ts | 42 --- packages/client/ui-jobs/src/client/locales.ts | 45 --- packages/client/ui-jobs/src/css-modules.d.ts | 6 - packages/client/ui-jobs/src/index.ts | 9 - packages/client/ui-jobs/src/invariant.ts | 32 -- .../tests/browser-plugin.client.spec.ts | 101 ------ .../tests/job-list-action.client.spec.tsx | 241 -------------- packages/client/ui-jobs/tsconfig.json | 39 --- packages/client/ui-jobs/tsdown.config.ts | 3 - .../src/client/slot-catalog.ts | 1 - pnpm-lock.yaml | 45 +-- .../verify-package-readme-model-experience.ts | 1 - tsconfig.base.json | 1 - tsconfig.client.json | 1 - 54 files changed, 639 insertions(+), 1167 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-25-unified-task-list.md create mode 100644 .agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md delete mode 100644 packages/client/ui-jobs/README.i18n.yaml delete mode 100644 packages/client/ui-jobs/README.md delete mode 100644 packages/client/ui-jobs/README.zh.md delete mode 100644 packages/client/ui-jobs/package.json delete mode 100644 packages/client/ui-jobs/src/client/JobListAction.module.css delete mode 100644 packages/client/ui-jobs/src/client/JobListAction.tsx delete mode 100644 packages/client/ui-jobs/src/client/index.ts delete mode 100644 packages/client/ui-jobs/src/client/locales.ts delete mode 100644 packages/client/ui-jobs/src/css-modules.d.ts delete mode 100644 packages/client/ui-jobs/src/index.ts delete mode 100644 packages/client/ui-jobs/src/invariant.ts delete mode 100644 packages/client/ui-jobs/tests/browser-plugin.client.spec.ts delete mode 100644 packages/client/ui-jobs/tests/job-list-action.client.spec.tsx delete mode 100644 packages/client/ui-jobs/tsconfig.json delete mode 100644 packages/client/ui-jobs/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 1ee7b9dfc3..9b64d73885 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 8da6c2fd914bf07cfa7d3545cff1e42552c69d27 -2026-08-08-web-background-job-display.zh.md: 0e05ef9d2fcd8193c661f471b5f7b9a84891f98a +2026-08-08-web-background-job-display.md: 23074b5d38cdab8c45925c75a7d0b851f7be2ece +2026-08-08-web-background-job-display.zh.md: 06645267ee8afadf284b7654b0cd4a672fe7e4b4 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 8da6c2fd91..23074b5d38 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -87,7 +87,7 @@ Two replacement points keep it honest. Each control-stream generation clears the ### The header action -[`@deepseek-ai/dsh-client-ui-jobs`](../../../../packages/client/ui-jobs/README.md) registers one entry in `conversation.session.header.actions`, ordered after the subagent catalog. Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. +`@deepseek-ai/dsh-client-ui-jobs` (since merged into `dsh-client-ui-activity`) registers one entry in `conversation.session.header.actions`, ordered after the subagent catalog. Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. A running one-shot background subagent therefore appears both there and in the subagent catalog. The two answer different questions — the catalog navigates into the child's transcript, this list is the only handle a cancellation can ever attach to — and suppressing `kind: 'subagent'` here would leave the cancellation phase with no entry point for exactly those tasks. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 0e05ef9d2f..06645267ee 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -87,7 +87,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void ### header 入口 -[`@deepseek-ai/dsh-client-ui-jobs`](../../../../packages/client/ui-jobs/README.zh.md) 在 `conversation.session.header.actions` 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 +`@deepseek-ai/dsh-client-ui-jobs`(后并入 `dsh-client-ui-activity`) 在 `conversation.session.header.actions` 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 因此一个运行中的一次性后台 subagent 会同时出现在那里和 subagent 目录里。两者回答不同的问题——目录负责进入子会话的 transcript,而这个列表是中断能力唯一可能附着的句柄——在这里屏蔽 `kind: 'subagent'` 会让中断那一期恰好对这批任务没有入口。 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml new file mode 100644 index 0000000000..031f01691a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-08-25-unified-task-list.md +2026-08-25-unified-task-list.md: 9ce3e6d2b066a94ee738880f69b0ddf718a10b02 +2026-08-25-unified-task-list.zh.md: ab224fd082fb8d6408449716e68e6df6c648a281 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md new file mode 100644 index 0000000000..9ce3e6d2b0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md @@ -0,0 +1,31 @@ +# Agent Note: One session-header task list — jobs joined with activities in the UI projection only + +Status: implemented + +English | [中文](2026-08-25-unified-task-list.zh.md) + +## Problem + +The [activity observation seam](2026-08-24-activity-observation-seam.md) shipped its Web surface as a second session-header button beside the [background-job list](2026-08-08-web-background-job-display.md). A background bash run registers in both planes — `ctx.jobs` for control and `ctx.activities` for observation — so the same work rendered twice: once in "background jobs" with lifecycle and duration, once in "activities" with expandable live output. Two near-identical popovers with different names for overlapping row sets read as a product accident, and the flat activity rows (live and settled interleaved, single-line, no duration) had no visual hierarchy. + +## Decision + +The service planes stay orthogonal; the join happens once, per row, in the UI projection. + +- **`ctx.jobs` and `ctx.activities` remain separate services.** Jobs are the control plane: model-visible ids, a consuming `readOutput` cursor the model drains, `stopping`, `kill`, and the `reported` completion-notice contract. Activities are the transient observation plane: non-consuming absolute offsets, invisible to the model, gone on restart. Merging the services would force one registry to carry both a consuming and a non-consuming cursor and would blur the "model-visible ⟺ logged" line for a plane designed to stay off the session log. +- **`dsh-client-ui-activity` renders the single merged list; `dsh-client-ui-jobs` is deleted** (pre-release, no compatibility shim), and the merged entry takes the job list's slot order. Each `jobsBySession` row is joined with the activity carrying its `correlation.jobId`: the job supplies identity, lifecycle (`stopping` exists only there), duration, and the model-visible `detail`; the activity supplies the expandable output panel. An activity without a job (a workflow run) keeps its own row; an activity whose job is not in the projection is not dropped. Without the activity registry, job rows still render — they just offer no panel, so the merged control degrades to exactly the old job list. +- **User-facing vocabulary is "task" (任务)** — `N tasks running` / `N 个任务进行中` — because every row is running work to the user, while "background job" excludes workflow runs and "activity" names the internal plane, not the user concept. The dictionary namespace and package names stay `activity`: internal names follow the naming ledger, not display copy. +- **Hierarchy**: live rows first (label over a kind-badge-and-status second line, ticking duration), then a "Finished" divider only when both sections exist, then settled rows compacted to one de-emphasized line. Rows without an observable activity render as static rows with no expansion affordance. + +Deliberately not joined: subagent delegation rows stay bare job rows (the subagent panel is their surface), and foreground commands stay in their tool cards — neither gains an activity producer. + +## Alternatives rejected + +- **Merge the services** — rejected for the cursor-semantics and model-visibility reasons above. +- **Keep the job list and bolt output onto it** — leaves workflow runs (activities without jobs) homeless, forcing a second list to survive anyway. + +## Consequences + +- The two web e2e scenarios (`background-job-list`, `live-activity-stream`) now assert one "Tasks" list; the job-list scenario's row gained a live-output panel, which is the join working end to end. +- A human kill control on live rows is still blocked on the jobs `reported` contract question recorded in the job-display note. +- The `job` locale namespace is gone with its package; `activity` owns the merged copy including the duration vocabulary. diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md new file mode 100644 index 0000000000..ab224fd082 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md @@ -0,0 +1,31 @@ +# Agent Note:会话头部单一任务列表——jobs 与 activity 只在 UI 投影层合并 + +状态:implemented + +[English](2026-08-25-unified-task-list.md) | 中文 + +## 问题 + +[activity 观察面](2026-08-24-activity-observation-seam.zh.md)的 Web 呈现落地为[后台任务列表](2026-08-08-web-background-job-display.zh.md)旁边的第二个会话头部按钮。一次后台 bash 会同时登记在两个面里——`ctx.jobs` 负责控制、`ctx.activities` 负责观察——于是同一份工作渲染了两次:一次在"后台任务"里带生命周期与时长,一次在"活动"里带可展开的实时输出。两个行集合高度重叠、名字却不同的近似弹层,读起来像产品事故;而活动列表的扁平行(live 与 settled 混排、单行、无时长)也缺乏视觉层次。 + +## 决策 + +服务面保持正交;合并只在 UI 投影层逐行发生一次。 + +- **`ctx.jobs` 与 `ctx.activities` 仍是两个服务。** jobs 是控制面:模型可见的 id、模型消耗式的 `readOutput` 游标、`stopping`、`kill` 与 `reported` 完成通知契约。activity 是瞬态观察面:非消耗式绝对偏移、模型不可见、重启即无。合并服务会迫使一个注册表同时携带消耗式与非消耗式两种游标,并模糊这个刻意不进 session log 的面与"模型可见 ⟺ 已记录"不变量的边界。 +- **`dsh-client-ui-activity` 渲染唯一的合并列表;`dsh-client-ui-jobs` 删除**(预发布阶段,不留兼容层),合并入口占用原 job 列表的 slot 顺位。每个 `jobsBySession` 行与携带其 `correlation.jobId` 的 activity 逐行 join:job 提供身份、生命周期(`stopping` 只存在于此)、时长与模型可见 `detail`;activity 提供可展开的输出面板。没有 job 的 activity(workflow 运行)保留自己的行;job 不在投影里的 activity 也不会被丢弃。没有 activity 注册表时 job 行照常渲染——只是没有面板,合并控件退化为与旧 job 列表完全等价。 +- **用户可见词汇统一为"任务"(task)**——`N 个任务进行中` / `N tasks running`——因为对用户而言每一行都是在跑的工作,而"后台任务"排除了 workflow 运行、"活动"命名的是内部面而非用户概念。词典命名空间与包名保持 `activity`:内部名遵循命名台账,不跟随展示文案。 +- **层次**:进行中的行在前(命令为主行、kind 徽章加状态为副行、时长每秒跳动),两段都存在时插入"已结束"分节线,已结束的行折为降权单行。没有可观察 activity 的行渲染为无展开交互的静态行。 + +刻意不合并的:subagent 委托行保持裸 job 行(subagent 面板是它的呈现面),前台命令留在工具卡片里——两者都不接活动生产者。 + +## 已否决的替代方案 + +- **合并服务层**——因上述游标语义与模型可见性理由否决。 +- **保留 job 列表、把输出挂上去**——workflow 运行(无 job 的 activity)无处安放,第二个列表终究得活下来。 + +## 后果 + +- 两个 web e2e 场景(`background-job-list`、`live-activity-stream`)现在断言同一个"任务"列表;job 场景的行获得了实时输出面板,这正是 join 端到端生效的证据。 +- live 行上的人工 kill 控件仍被 job-display note 记录的 jobs `reported` 契约问题阻塞。 +- `job` 词典命名空间随包一起消失;`activity` 拥有合并后的全部文案,包括时长词汇。 diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index b0fdc62ddb..0e1061ba09 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { it('shows a running background job in the session header without a refresh', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running')) // Polling for zero would pass at t=0 before delivery and prove nothing. - const trigger = page.getByRole('button', { name: '1 background job running' }) + const trigger = page.getByRole('button', { name: '1 task running' }) expect(await trigger.count()).toBe(0) const started = await scaffold.ctx.tools.execute({ @@ -97,7 +97,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { await trigger.waitFor({ timeout: 15_000 }) await trigger.click() - const row = page.getByRole('list', { name: 'Background jobs' }).getByRole('listitem').first() + const row = page.getByRole('list', { name: 'Tasks' }).getByRole('listitem').first() await row.waitFor({ timeout: 10_000 }) await expect.poll(() => row.textContent()).toContain(COMMAND) @@ -111,7 +111,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled')) expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') - const idle = page.getByRole('button', { name: '1 background job' }) + const idle = page.getByRole('button', { name: '1 task' }) await idle.waitFor({ timeout: 20_000 }) const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/live-activity-stream.e2e.ts b/apps/web/tests/live-activity-stream.e2e.ts index 750e1697f3..b8a702e738 100644 --- a/apps/web/tests/live-activity-stream.e2e.ts +++ b/apps/web/tests/live-activity-stream.e2e.ts @@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { it('streams a running background command\'s real output into the expanded panel', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-streaming')) - const trigger = page.getByRole('button', { name: '1 live activity' }) + const trigger = page.getByRole('button', { name: '1 task running' }) expect(await trigger.count()).toBe(0) const started = await scaffold.ctx.tools.execute({ @@ -118,7 +118,7 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { // Settlement arrives on the observation stream itself, so the open panel // flips without any further interaction; the roster trigger follows. - const idle = page.getByRole('button', { name: '1 activity' }) + const idle = page.getByRole('button', { name: '1 task' }) await idle.waitFor({ timeout: 20_000 }) await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/snapshots/background-job-list/running.expected.md b/apps/web/tests/snapshots/background-job-list/running.expected.md index 62a413bf4c..95f226f226 100644 --- a/apps/web/tests/snapshots/background-job-list/running.expected.md +++ b/apps/web/tests/snapshots/background-job-list/running.expected.md @@ -1,2 +1,5 @@ -- list "Background jobs": - - listitem: bash sleep 45 running {{duration}} +- list "Tasks": + - listitem: + - button "Show live output of sleep 45": + - text: sleep 45 {{duration}} bash running + - img diff --git a/apps/web/tests/snapshots/background-job-list/settled.expected.md b/apps/web/tests/snapshots/background-job-list/settled.expected.md index cc4bcfef74..3343ae521c 100644 --- a/apps/web/tests/snapshots/background-job-list/settled.expected.md +++ b/apps/web/tests/snapshots/background-job-list/settled.expected.md @@ -1,2 +1,5 @@ -- list "Background jobs": - - listitem: "bash sleep 45 signal: SIGTERM {{duration}}" +- list "Tasks": + - listitem: + - button "Show live output of sleep 45": + - text: "bash sleep 45 signal: SIGTERM {{duration}}" + - img diff --git a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md index 7ffec602cb..2090dca51d 100644 --- a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md +++ b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md @@ -1,7 +1,7 @@ -- list "Live activities": +- list "Tasks": - listitem: - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - - text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM" + - text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM {{duration}}" - img - text: done $ printf 'streamed-%s\n' marker-line; sleep 45 - button "Copy" diff --git a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md index fd536ef430..ea906239aa 100644 --- a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md +++ b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md @@ -1,7 +1,7 @@ -- list "Live activities": +- list "Tasks": - listitem: - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - - text: bash printf 'streamed-%s\n' marker-line; sleep 45 running + - text: printf 'streamed-%s\n' marker-line; sleep 45 {{duration}} bash running - img - text: running $ printf 'streamed-%s\n' marker-line; sleep 45 - button "Copy" diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 6c2e824a55..43c0fd7cd0 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 0bf883bbfde80377602be90bd913c32f1b320f63 -config-catalog.zh.md: 381319465dab9dafeecaacf704caba8bab7455f3 +config-catalog.md: f6c751b0942144f29a648491381fecd307e2ed8f +config-catalog.zh.md: ac16ec94533b6c818f2abb00d5ed96b3d81a07ac diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0bf883bbfd..f6c751b094 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3355,7 +3355,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-input-trigger` ([`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-jobs` ([`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-message-feedback` ([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model-selection` ([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 381319465d..ac16ec9453 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3357,7 +3357,6 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-directory-picker-native`([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-input-trigger`([`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-jobs`([`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout`([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-message-feedback`([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model-selection`([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a467f4aea6..52cd807255 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: 9d192404f4949bcf6e5c4222f0b898cc52362bd8 -module-graph.zh.md: bbdfd5642238644939fd08c02b9786310bad63e2 +module-graph.md: 8664f078fdcec3b8a2474bdf62d20fbb6dfe9525 +module-graph.zh.md: 3975d691877e2e5aeaade32a5530fd05e7d4acbb diff --git a/docs/module-graph.md b/docs/module-graph.md index 9d192404f4..8664f078fd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -104,7 +104,12 @@ flowchart TD subgraph group_acp["packages/acp"] pkg_acp["acp"] end + subgraph group_activity["packages/activity"] + pkg_activity["activity"] + pkg_activity_local["activity-local"] + end subgraph group_api["packages/api"] + pkg_api_activity_controller["api-activity-controller"] pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] @@ -132,6 +137,7 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_store["client-store"] + pkg_client_ui_activity["client-ui-activity"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] @@ -144,7 +150,6 @@ flowchart TD pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] - pkg_client_ui_jobs["client-ui-jobs"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_message_feedback["client-ui-message-feedback"] pkg_client_ui_model_selection["client-ui-model-selection"] @@ -675,6 +680,12 @@ flowchart TD pkg_hook_protocol --> pkg_invariants pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_shell + pkg_activity --> pkg_agent + pkg_activity --> pkg_brand + pkg_activity --> pkg_invariants + pkg_activity --> pkg_jobs + pkg_activity --> pkg_llm + pkg_activity --> pkg_session pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model pkg_headless --> pkg_invariants @@ -829,6 +840,10 @@ flowchart TD pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_tools + pkg_activity_local --> pkg_activity + pkg_activity_local --> pkg_agent + pkg_activity_local --> pkg_invariants + pkg_activity_local --> pkg_scope pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction pkg_command_compact --> pkg_invariants @@ -965,6 +980,7 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_tool_workflow --> pkg_activity pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm @@ -1026,6 +1042,7 @@ flowchart TD pkg_tool_cordis --> pkg_tools pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_invariants + pkg_tool_bash --> pkg_activity pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1037,6 +1054,7 @@ flowchart TD pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval + pkg_tool_pwsh --> pkg_activity pkg_tool_pwsh --> pkg_agent pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_jobs @@ -1242,6 +1260,16 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_api_activity_controller --> pkg_activity + pkg_api_activity_controller --> pkg_agent + pkg_api_activity_controller --> pkg_api_gateway + pkg_api_activity_controller --> pkg_client_connection + pkg_api_activity_controller --> pkg_client_store + pkg_api_activity_controller --> pkg_invariants + pkg_api_activity_controller --> pkg_jobs + pkg_api_activity_controller --> pkg_llm + pkg_api_activity_controller --> pkg_session + pkg_api_activity_controller --> pkg_typert_protocol pkg_api_session_controller --> pkg_agent pkg_api_session_controller --> pkg_agent_default_model pkg_api_session_controller --> pkg_agent_presets @@ -1272,6 +1300,7 @@ flowchart TD pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace pkg_api_remotes --> pkg_agent_presets + pkg_api_remotes --> pkg_api_activity_controller pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_api_session_controller pkg_api_remotes --> pkg_api_workspace_controller @@ -1382,6 +1411,13 @@ flowchart TD pkg_client_ui_workspace --> pkg_invariants pkg_client_ui_workspace --> pkg_session pkg_client_ui_workspace --> pkg_util_workspace_path + pkg_client_ui_activity --> pkg_api_activity_controller + pkg_client_ui_activity --> pkg_api_session_controller + pkg_client_ui_activity --> pkg_client_locale + pkg_client_ui_activity --> pkg_client_ui_conversation + pkg_client_ui_activity --> pkg_client_ui_renderer + pkg_client_ui_activity --> pkg_client_ui_session + pkg_client_ui_activity --> pkg_invariants pkg_client_ui_agent_preset --> pkg_api_remotes pkg_client_ui_agent_preset --> pkg_api_session_controller pkg_client_ui_agent_preset --> pkg_client_connection @@ -1423,12 +1459,6 @@ flowchart TD pkg_client_ui_input_trigger --> pkg_file_reference pkg_client_ui_input_trigger --> pkg_invariants pkg_client_ui_input_trigger --> pkg_session - pkg_client_ui_jobs --> pkg_api_session_controller - pkg_client_ui_jobs --> pkg_client_locale - pkg_client_ui_jobs --> pkg_client_ui_conversation - pkg_client_ui_jobs --> pkg_client_ui_renderer - pkg_client_ui_jobs --> pkg_client_ui_session - pkg_client_ui_jobs --> pkg_invariants pkg_client_ui_plan --> pkg_api_remotes pkg_client_ui_plan --> pkg_client_locale pkg_client_ui_plan --> pkg_client_ui_conversation @@ -1757,6 +1787,7 @@ flowchart TD | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | +| [`activity`](../packages/activity/activity) | `activity` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | @@ -1783,6 +1814,7 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`activity-local`](../packages/activity/activity-local) | `activity` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1807,7 +1839,7 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | @@ -1816,8 +1848,8 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1846,9 +1878,10 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`api-activity-controller`](../packages/api/activity-controller) | `api` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-activity-controller`](../packages/api/activity-controller), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | @@ -1861,13 +1894,13 @@ flowchart TD | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-activity`](../packages/client/ui-activity) | `client` | [`api-activity-controller`](../packages/api/activity-controller), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index bbdfd56422..3975d69187 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -106,7 +106,12 @@ flowchart TD subgraph group_acp["packages/acp"] pkg_acp["acp"] end + subgraph group_activity["packages/activity"] + pkg_activity["activity"] + pkg_activity_local["activity-local"] + end subgraph group_api["packages/api"] + pkg_api_activity_controller["api-activity-controller"] pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] @@ -134,6 +139,7 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_store["client-store"] + pkg_client_ui_activity["client-ui-activity"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] @@ -146,7 +152,6 @@ flowchart TD pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] - pkg_client_ui_jobs["client-ui-jobs"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_message_feedback["client-ui-message-feedback"] pkg_client_ui_model_selection["client-ui-model-selection"] @@ -677,6 +682,12 @@ flowchart TD pkg_hook_protocol --> pkg_invariants pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_shell + pkg_activity --> pkg_agent + pkg_activity --> pkg_brand + pkg_activity --> pkg_invariants + pkg_activity --> pkg_jobs + pkg_activity --> pkg_llm + pkg_activity --> pkg_session pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model pkg_headless --> pkg_invariants @@ -831,6 +842,10 @@ flowchart TD pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_tools + pkg_activity_local --> pkg_activity + pkg_activity_local --> pkg_agent + pkg_activity_local --> pkg_invariants + pkg_activity_local --> pkg_scope pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction pkg_command_compact --> pkg_invariants @@ -967,6 +982,7 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_tool_workflow --> pkg_activity pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm @@ -1028,6 +1044,7 @@ flowchart TD pkg_tool_cordis --> pkg_tools pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_invariants + pkg_tool_bash --> pkg_activity pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1039,6 +1056,7 @@ flowchart TD pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval + pkg_tool_pwsh --> pkg_activity pkg_tool_pwsh --> pkg_agent pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_jobs @@ -1244,6 +1262,16 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_api_activity_controller --> pkg_activity + pkg_api_activity_controller --> pkg_agent + pkg_api_activity_controller --> pkg_api_gateway + pkg_api_activity_controller --> pkg_client_connection + pkg_api_activity_controller --> pkg_client_store + pkg_api_activity_controller --> pkg_invariants + pkg_api_activity_controller --> pkg_jobs + pkg_api_activity_controller --> pkg_llm + pkg_api_activity_controller --> pkg_session + pkg_api_activity_controller --> pkg_typert_protocol pkg_api_session_controller --> pkg_agent pkg_api_session_controller --> pkg_agent_default_model pkg_api_session_controller --> pkg_agent_presets @@ -1274,6 +1302,7 @@ flowchart TD pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace pkg_api_remotes --> pkg_agent_presets + pkg_api_remotes --> pkg_api_activity_controller pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_api_session_controller pkg_api_remotes --> pkg_api_workspace_controller @@ -1384,6 +1413,13 @@ flowchart TD pkg_client_ui_workspace --> pkg_invariants pkg_client_ui_workspace --> pkg_session pkg_client_ui_workspace --> pkg_util_workspace_path + pkg_client_ui_activity --> pkg_api_activity_controller + pkg_client_ui_activity --> pkg_api_session_controller + pkg_client_ui_activity --> pkg_client_locale + pkg_client_ui_activity --> pkg_client_ui_conversation + pkg_client_ui_activity --> pkg_client_ui_renderer + pkg_client_ui_activity --> pkg_client_ui_session + pkg_client_ui_activity --> pkg_invariants pkg_client_ui_agent_preset --> pkg_api_remotes pkg_client_ui_agent_preset --> pkg_api_session_controller pkg_client_ui_agent_preset --> pkg_client_connection @@ -1425,12 +1461,6 @@ flowchart TD pkg_client_ui_input_trigger --> pkg_file_reference pkg_client_ui_input_trigger --> pkg_invariants pkg_client_ui_input_trigger --> pkg_session - pkg_client_ui_jobs --> pkg_api_session_controller - pkg_client_ui_jobs --> pkg_client_locale - pkg_client_ui_jobs --> pkg_client_ui_conversation - pkg_client_ui_jobs --> pkg_client_ui_renderer - pkg_client_ui_jobs --> pkg_client_ui_session - pkg_client_ui_jobs --> pkg_invariants pkg_client_ui_plan --> pkg_api_remotes pkg_client_ui_plan --> pkg_client_locale pkg_client_ui_plan --> pkg_client_ui_conversation @@ -1759,6 +1789,7 @@ flowchart TD | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | +| [`activity`](../packages/activity/activity) | `activity` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | @@ -1785,6 +1816,7 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`activity-local`](../packages/activity/activity-local) | `activity` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1809,7 +1841,7 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | @@ -1818,8 +1850,8 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1848,9 +1880,10 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`api-activity-controller`](../packages/api/activity-controller) | `api` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-activity-controller`](../packages/api/activity-controller), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | @@ -1863,13 +1896,13 @@ flowchart TD | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-activity`](../packages/client/ui-activity) | `client` | [`api-activity-controller`](../packages/api/activity-controller), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 9fa12bc33f..7e66b4fafd 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -273,9 +273,6 @@ - id: ui-reference name: '@deepseek-ai/dsh-client-ui-reference' - # Background jobs: the session-header list over the jobsBySession mirror. - - id: ui-jobs - name: '@deepseek-ai/dsh-client-ui-jobs' # Live activities: the session-header roster with on-demand streaming # output panels over the activity feed. diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 0837c9f6b1..c829d4a089 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -68,7 +68,6 @@ "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-model-selection": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index db771381dd..31e1304a21 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -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/README.md -README.md: eaf01b282ead3c4638435e3d02d6a803ad1faa15 -README.zh.md: 2ff4b1529f07e0f31b3d06a9577d3c480dd34916 +README.md: 928824bbdc92d933429c220572d0e5e80bcea989 +README.zh.md: 2978efc916930bc3742ca5da546091b0e8d70d5c diff --git a/packages/client/README.md b/packages/client/README.md index eaf01b282e..928824bbdc 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -35,7 +35,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. | | [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source. | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. | -| [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs in the conversation header. | +| [`ui-activity/`](ui-activity/README.md) | Lists this session's tasks — jobs joined with their live-output activities. | | [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces. | | [`ui-permission/`](ui-permission-presets/README.md) | Configures default permissions and switches the current session's access. | | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 2ff4b1529f..2978efc916 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -35,7 +35,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-skill/`](ui-skill/README.zh.md) | 向内联建议添加 skill(技能)引用。 | | [`ui-reference/`](ui-reference/README.zh.md) | 统一的 Web `@file` / `@session` 引用 source。 | | [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent(子 agent)导航、子级 transcript(文本记录)的状态和内联引用。 | -| [`ui-jobs/`](ui-jobs/README.zh.md) | 在会话标题栏列出当前会话的后台任务。 | +| [`ui-activity/`](ui-activity/README.zh.md) | 列出本会话的任务——后台任务与其实时输出 activity 的合并视图。 | | [`ui-model-selection/`](ui-model-selection/README.zh.md) | 在对话界面中提供模型选择。 | | [`ui-permission/`](ui-permission-presets/README.zh.md) | 配置默认权限并切换当前会话的访问模式。 | | [`ui-plan/`](ui-plan/README.zh.md) | 展示生效中的 plan mode 状态及其退出控件。 | diff --git a/packages/client/ui-activity/README.i18n.yaml b/packages/client/ui-activity/README.i18n.yaml index 532ea19b10..ddfc1e3f6f 100644 --- a/packages/client/ui-activity/README.i18n.yaml +++ b/packages/client/ui-activity/README.i18n.yaml @@ -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/ui-activity/README.md -README.md: 157e98e6b14bc1b556cb2aac8f6f90c43fbcb3f2 -README.zh.md: 392cc91c105caa2f79546f818700da0e6643b8a1 +README.md: beb86e0c9f685c2ff7f799f878bb8646a6c2d041 +README.zh.md: 2aebde5da685995f6c191d621aed0ad3bac56678 diff --git a/packages/client/ui-activity/README.md b/packages/client/ui-activity/README.md index 157e98e6b1..beb86e0c9f 100644 --- a/packages/client/ui-activity/README.md +++ b/packages/client/ui-activity/README.md @@ -2,13 +2,15 @@ English | [中文](README.zh.md) -Session-header live-activity list over `ctx.activityFeed`: one header action beside the background-job list renders the roster rows this session can see (owned plus unowned), and expanding a row opens that activity's observation stream into an embedded terminal panel. Observation follows visibility — the stream opens on expand and closes on collapse, unmount, or popover dismissal, so output only flows while someone is watching. Retention gaps and stream failures render as notices above the panel. +Session-header task list: one header action renders this session's background jobs joined with their correlated live-output activities, plus standalone activities such as workflow runs. The join happens per row in this component — jobs and activities stay separate planes ([why](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md)): a job row keeps the job's lifecycle, duration, and model-visible `detail` from the `jobsBySession` mirror, and gains an expandable output panel when an activity carries its `correlation.jobId`; an activity without a job keeps its own row. Live rows lead with the label over a kind-and-status second line and a ticking duration; settled rows compact to one de-emphasized line behind a section divider. -The control renders nothing until the session can see at least one activity, so an ordinary conversation never grows a control for a capability it is not using; without the activity registry the roster stays empty and the entry point never appears. +Expanding an observable row opens that activity's observation stream from `ctx.activityFeed` into an embedded terminal panel. Observation follows visibility — the stream opens on expand and closes on collapse, unmount, or popover dismissal, so output only flows while someone is watching. Retention gaps and stream failures render as notices above the panel. + +The control renders nothing until the session can see at least one task, so an ordinary conversation never grows a control for a capability it is not using. Without the activity registry, job rows still render from the session mirror — they just offer no output panel. ## Model Experience -None, as this package renders host-observed live output for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). +None, as this package renders host-observed state and live output for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). #### KV Cache effect @@ -18,4 +20,3 @@ None; the package never assembles or sends provider requests. - **No kill control** — cancellation stays with the model's `job_kill`; a human kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). - **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up. -- **A background job appears in both header lists** — the job list carries control-facing status, this list carries output; the duplication mirrors the deliberate subagent-catalog precedent. diff --git a/packages/client/ui-activity/README.zh.md b/packages/client/ui-activity/README.zh.md index 392cc91c10..2aebde5da6 100644 --- a/packages/client/ui-activity/README.zh.md +++ b/packages/client/ui-activity/README.zh.md @@ -2,13 +2,15 @@ [English](README.md) | 中文 -架在 `ctx.activityFeed` 上的会话头部实时活动列表:在后台任务列表旁提供一个头部 action,渲染本会话可见的 roster 行(有主 + 无主),展开某行即把该 activity 的观察流打开进内嵌终端面板。观察跟随可见性——展开开流,收起、卸载或关闭弹层即停流,只有有人在看时输出才流动。保留缺口与流失败以面板上方的提示呈现。 +会话头部任务列表:一个头部 action 渲染本会话的后台任务与其关联的实时输出 activity 的合并视图,外加 workflow 运行等独立 activity。合并逐行发生在本组件内——jobs 与 activity 仍是两个独立的面([缘由](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)):job 行保留来自 `jobsBySession` 镜像的生命周期、时长与模型可见 `detail`,当某个 activity 以 `correlation.jobId` 关联到它时获得可展开的输出面板;没有 job 的 activity 保留自己的行。进行中的行以命令为主行、kind 与状态为副行并带跳动时长;已结束的行折为降权单行,置于分节线之后。 -会话一个 activity 都看不到时该控件完全不渲染,普通会话不会为未使用的能力多出控件;没有 activity 注册表时 roster 恒空,入口永不出现。 +展开可观察的行即把该 activity 在 `ctx.activityFeed` 上的观察流打开进内嵌终端面板。观察跟随可见性——展开开流,收起、卸载或关闭弹层即停流,只有有人在看时输出才流动。保留缺口与流失败以面板上方的提示呈现。 + +会话一个任务都看不到时该控件完全不渲染,普通会话不会为未使用的能力多出控件。没有 activity 注册表时,job 行仍从会话镜像渲染——只是不提供输出面板。 ## 模型体验 -无:本包只为人渲染宿主观察到的实时输出,不触碰任何 prompt、消息、schema、流或工具结果。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 +无:本包只为人渲染宿主观察到的状态与实时输出,不触碰任何 prompt、消息、schema、流或工具结果。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 #### KV Cache 影响 @@ -18,4 +20,3 @@ - **没有 kill 控件**——取消仍走模型的 `job_kill`;人工 kill 被 [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题阻塞。 - **不渲染通道标签**——stdout 与 stderr chunk 连接成一条流;分通道着色是后续呈现优化。 -- **后台 job 会同时出现在两个头部列表**——job 列表承载控制侧状态,本列表承载输出;这一重复沿用 subagent catalog 的既有取舍。 diff --git a/packages/client/ui-activity/package.json b/packages/client/ui-activity/package.json index 83395df003..4c4282e60b 100644 --- a/packages/client/ui-activity/package.json +++ b/packages/client/ui-activity/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-activity", - "description": "Session-header live-activity list: roster rows from the activity control stream with on-demand streaming output panels", + "description": "Session-header task list: background jobs joined with their live-output activities, with on-demand streaming output panels", "version": "0.1.1-rc.2", "type": "module", "main": "lib/index.js", @@ -37,6 +37,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-activity-controller": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", @@ -46,6 +47,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-activity-controller": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-activity/src/client/ActivityListAction.module.css b/packages/client/ui-activity/src/client/ActivityListAction.module.css index 2cf3af6e2f..d0d632ab34 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.module.css +++ b/packages/client/ui-activity/src/client/ActivityListAction.module.css @@ -62,6 +62,16 @@ box-shadow: var(--dsw-shadow-lv3); } +/* Divider label between the live section and the settled tail. */ +.sectionHeader { + margin: 4px 4px 1px; + padding: 4px 4px 3px; + border-top: 1px solid var(--dsw-alias-border-l1); + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; +} + .item { display: flex; flex-direction: column; @@ -90,14 +100,46 @@ background: var(--dsw-alias-fill-l1); } +/* A row with no observable output offers no interaction. */ +.rowStatic { + cursor: default; +} + +.rowStatic:hover { + background: transparent; +} + .rowSettled { color: var(--dsw-alias-label-tertiary); } -.rowDot { +/* Live rows align the dot with the first text line, not the row center. */ +.row .rowDot { flex: none; } +.main { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.primary { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.secondary { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + .kind { flex: none; padding: 0 6px; @@ -117,11 +159,11 @@ text-overflow: ellipsis; } -/* A failed activity's detail is the producer's raw text, so it has no bound; +/* A failed task's detail is the producer's raw text, so it has no bound; without this it widens the row past the menu instead of eliding like .label. */ .status { flex: none; - max-width: 40%; + max-width: 60%; overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 11px; @@ -130,6 +172,14 @@ text-overflow: ellipsis; } +.duration { + flex: none; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 18px; + font-variant-numeric: tabular-nums; +} + .chevron { flex: none; color: var(--dsw-alias-label-tertiary); @@ -139,8 +189,11 @@ transform: rotate(180deg); } +/* Output hangs under its row behind a spine that ties the two together. */ .panel { - margin: 0 8px 8px; + margin: 0 8px 8px 14px; + padding-left: 8px; + border-left: 2px solid var(--dsw-alias-border-l2); } .notice { diff --git a/packages/client/ui-activity/src/client/ActivityListAction.tsx b/packages/client/ui-activity/src/client/ActivityListAction.tsx index 1f868988b0..ef77df0e9c 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.tsx +++ b/packages/client/ui-activity/src/client/ActivityListAction.tsx @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEve import type { ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, } from '@deepseek-ai/dsh-api-activity-controller/client' +import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' import { IconChevronDownOutline14, StateDot, TerminalBlock, useDismissOnOutsidePointer, type StateDotState, type TerminalBlockLabels, @@ -27,14 +28,36 @@ export interface ActivityListInjected { observe: (id: ActivityId) => () => void } -/** Full props for the session-header live-activity action. */ +/** Full props for the session-header task-list action. */ export type ActivityListActionProps = PropsRuntime<'conversation.session.header.actions'> & PropsLocale & InjectFace -/** Stable empty list so a session with no activities keeps one array identity. */ +/** + * One merged task row. Jobs and activities are separate planes — the control + * plane the model interacts with, and the transient observation plane — so + * this list joins them per row: a job carries identity, lifecycle, and the + * model-visible detail; its correlated activity contributes the observable + * live output; an activity without a job (a workflow run) stands alone. + */ +interface TaskRow { + /** Stable row identity: the job id when one exists, else the activity id. */ + key: string + kind: string + label: string + /** Job lifecycle vocabulary; activity-only rows never reach `stopping`. */ + status: SessionJob['status'] + detail?: string + startedAt: number + finishedAt?: number + /** Present when this row's live output can be observed. */ + activityId?: ActivityId +} + +/** Stable empty lists so a session with no work keeps one array identity. */ const NO_ROWS: readonly ActivityRow[] = [] +const NO_JOBS: readonly SessionJob[] = [] /** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */ const VIEWPORT_MARGIN = 12 @@ -42,19 +65,24 @@ const VIEWPORT_MARGIN = 12 /** Height cap for one live output panel inside the popover. */ const PANEL_MAX_LINES = 16 -function isLive(row: ActivityRow): boolean { - return row.status === 'running' +function isLive(row: TaskRow): boolean { + return row.status === 'running' || row.status === 'stopping' } /** Closed-union exhaustiveness fence for the wire status set. */ /* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */ function assertNever(value: never): never { - throw new Error(`unhandled activity status: ${JSON.stringify(value)}`) + throw new Error(`unhandled task status: ${JSON.stringify(value)}`) } -function dotState(status: ActivityRow['status']): StateDotState { +/** + * Status marker semantics. `stopping` and `killed` share the attention color: + * both mean the work ended (or is ending) on request rather than on its own. + */ +function dotState(status: TaskRow['status']): StateDotState { switch (status) { case 'running': return 'ongoing' + case 'stopping': return 'warning' case 'completed': return 'done' case 'killed': return 'warning' case 'failed': return 'error' @@ -63,9 +91,10 @@ function dotState(status: ActivityRow['status']): StateDotState { } } -function statusLabel(status: ActivityRow['status'], t: TranslateNS): string { +function statusLabel(status: TaskRow['status'], t: TranslateNS): string { switch (status) { case 'running': return t('status.running') + case 'stopping': return t('status.stopping') case 'completed': return t('status.completed') case 'killed': return t('status.killed') case 'failed': return t('status.failed') @@ -74,6 +103,22 @@ function statusLabel(status: ActivityRow['status'], t: TranslateNS): } } +/** + * Elapsed time in at most two adjacent units. A task that outlives an hour is + * already exceptional, so hours is the widest unit — beyond that the figure + * stays in hours rather than growing a day/month vocabulary no producer + * currently reaches. + */ +function formatDuration(elapsedMs: number, t: TranslateNS): string { + const total = Math.max(0, Math.floor(elapsedMs / 1_000)) + const seconds = total % 60 + const minutes = Math.floor(total / 60) % 60 + const hours = Math.floor(total / 3_600) + if (hours > 0) return t('duration.hours', { hours, minutes }) + if (minutes > 0) return t('duration.minutes', { minutes, seconds }) + return t('duration.seconds', { seconds }) +} + /** Localized display copy for the embedded terminal panel. */ function terminalLabels(t: TranslateNS): TerminalBlockLabels { return { @@ -96,9 +141,63 @@ function terminalLabels(t: TranslateNS): TerminalBlockLabels { } } -/* jscpd:ignore-start -- mirrors JobListAction's ordering and trigger chrome by design so the two header lists read as one family. */ -/** Live rows first in start order, then settled rows newest-first. */ -function ordered(rows: readonly ActivityRow[]): ActivityRow[] { +/** Project one standalone activity into the merged row vocabulary. */ +function activityTask(activity: ActivityRow): TaskRow { + return { + key: String(activity.id), + kind: activity.kind, + label: activity.label, + status: activity.status, + ...activity.detail !== undefined ? { detail: activity.detail } : {}, + startedAt: activity.startedAt, + ...activity.finishedAt !== undefined ? { finishedAt: activity.finishedAt } : {}, + activityId: activity.id, + } +} + +/** + * Join this session's jobs with their correlated activities and append the + * activities that stand alone. A job row keeps the job's lifecycle and detail + * (what the model sees) and gains observability from its activity; an + * activity whose job is not in the projection keeps its own row rather than + * disappearing. + */ +function mergeRows( + jobs: readonly SessionJob[], + activities: readonly ActivityRow[], +): TaskRow[] { + const byJob = new Map() + const standalone: ActivityRow[] = [] + for (const activity of activities) { + const jobId = activity.correlation?.jobId + if (jobId === undefined) standalone.push(activity) + else byJob.set(String(jobId), activity) + } + const rows: TaskRow[] = jobs.map((job) => { + const activity = byJob.get(String(job.id)) + byJob.delete(String(job.id)) + return { + key: String(job.id), + kind: job.kind, + label: job.label, + status: job.status, + ...job.detail !== undefined ? { detail: job.detail } : {}, + startedAt: job.startedAt, + ...job.finishedAt !== undefined ? { finishedAt: job.finishedAt } : {}, + ...activity !== undefined ? { activityId: activity.id } : {}, + } + }) + for (const activity of byJob.values()) rows.push(activityTask(activity)) + for (const activity of standalone) rows.push(activityTask(activity)) + return rows +} + +/** + * Live rows first in start order, then settled rows newest-first. Two rows + * that settled in the same millisecond fall back to start order, so the sort + * never depends on the host's map iteration. + */ +function ordered(rows: readonly TaskRow[]): TaskRow[] { return [...rows].sort((left, right) => { const liveLeft = isLive(left) if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 @@ -107,44 +206,87 @@ function ordered(rows: readonly ActivityRow[]): ActivityRow[] { return finished !== 0 ? finished : left.startedAt - right.startedAt }) } -/* jscpd:ignore-end */ -/** One expandable roster row plus its on-demand live output panel. */ -function ActivityItem({ row, view, expanded, onToggle, t }: { - row: ActivityRow +/** One task row plus, when observable and expanded, its live output panel. */ +function TaskItem({ row, view, expanded, now, onToggle, t }: { + row: TaskRow view: ObservedActivity | undefined expanded: boolean + /** Clock sample live rows derive their running duration from. */ + now: number onToggle: () => void t: TranslateNS }) { const live = isLive(row) const status = statusLabel(row.status, t) + const observable = row.activityId !== undefined const labels = useMemo(() => terminalLabels(t), [t]) - return ( -
      • - - {expanded + {durationCell} + {observable ? : null} + + ) + return ( +
      • + {observable + ? ( + + ) + : ( + + {body} + + )} + {expanded && view !== undefined ? (
        - {view?.gapBefore === true ?
        {t('output.gap')}
        : null} - {view?.error !== undefined + {view.gapBefore ?
        {t('output.gap')}
        : null} + {view.error !== undefined ?
        {t('output.error', { error: view.error })}
        : null} state.jobsBySession[sessionId]) ?? NO_JOBS const owned = useActivity(state => state.rowsBySession[sessionId]) ?? NO_ROWS const unowned = useActivity(state => state.rowsBySession['']) ?? NO_ROWS const observedViews = useActivity(state => state.observed) const [open, setOpen] = useState(false) - const [expandedId, setExpandedId] = useState(undefined) + const [expandedKey, setExpandedKey] = useState(undefined) + const [now, setNow] = useState(() => Date.now()) const rootRef = useRef(null) const triggerRef = useRef(null) const menuRef = useRef(null) @@ -178,11 +324,23 @@ export function ActivityListAction({ sessionId, useActivity, observe, t }: Activ // inside the viewport (the stylesheet alone cannot see the anchor offset). const [menuShift, setMenuShift] = useState(0) - const rows = useMemo(() => ordered([...owned, ...unowned]), [owned, unowned]) - const liveCount = useMemo(() => rows.filter(isLive).length, [rows]) + const rows = useMemo( + () => ordered(mergeRows(jobs, [...owned, ...unowned])), + [jobs, owned, unowned], + ) + const liveRows = useMemo(() => rows.filter(isLive), [rows]) + const settledRows = useMemo(() => rows.filter(row => !isLive(row)), [rows]) useDismissOnOutsidePointer(rootRef, open, setOpen) + // The clock only runs while an open list is showing something that moves. + useEffect(() => { + if (!open || liveRows.length === 0) return + setNow(Date.now()) + const timer = setInterval(() => { setNow(Date.now()) }, 1_000) + return () => { clearInterval(timer) } + }, [open, liveRows.length]) + // Fit the open popover to the viewport: shift left when the anchored width // would cross the right edge, never past the left margin. useLayoutEffect(() => { @@ -209,35 +367,36 @@ export function ActivityListAction({ sessionId, useActivity, observe, t }: Activ return () => { window.removeEventListener('resize', fit) } }, [open]) - // Observation follows visibility: the stream opens when a panel expands and - // closes when it collapses, unmounts, or the popover closes. - const activeId = open ? expandedId : undefined + // Observation follows visibility: the stream opens when an observable panel + // expands and closes when it collapses, unmounts, or the popover closes. + const activeActivity = open && expandedKey !== undefined + ? rows.find(row => row.key === expandedKey)?.activityId + : undefined useEffect(() => { - if (activeId === undefined) return - return observe(activeId as ActivityId) - }, [activeId, observe]) + if (activeActivity === undefined) return + return observe(activeActivity) + }, [activeActivity, observe]) - // The last activity disappearing removes this control; close first so focus + // The last task disappearing removes this control; close first so focus // does not vanish from an unmounting node. useEffect(() => { if (rows.length === 0 && open) setOpen(false) }, [rows.length, open]) - // An expanded row that left the roster (owner disposal) folds its panel. + // An expanded row that left the list (owner disposal) folds its panel. useEffect(() => { - if (expandedId !== undefined && !rows.some(row => String(row.id) === expandedId)) { - setExpandedId(undefined) + if (expandedKey !== undefined && !rows.some(row => row.key === expandedKey)) { + setExpandedKey(undefined) } - }, [rows, expandedId]) + }, [rows, expandedKey]) if (rows.length === 0) return null - const countKey = liveCount > 0 - ? (liveCount === 1 ? 'count.live.one' : 'count.live.other') + const countKey = liveRows.length > 0 + ? (liveRows.length === 1 ? 'count.live.one' : 'count.live.other') : (rows.length === 1 ? 'count.idle.one' : 'count.idle.other') - const countLabel = t(countKey, { count: liveCount > 0 ? liveCount : rows.length }) + const countLabel = t(countKey, { count: liveRows.length > 0 ? liveRows.length : rows.length }) - /* jscpd:ignore-start -- mirrors JobListAction's dismiss/trigger interaction by design. */ const onKeyDown = (event: KeyboardEvent): void => { if (event.key !== 'Escape' || !open) return event.preventDefault() @@ -245,6 +404,20 @@ export function ActivityListAction({ sessionId, useActivity, observe, t }: Activ triggerRef.current?.focus() } + const item = (row: TaskRow) => ( + { + setExpandedKey(current => current === row.key ? undefined : row.key) + }} + t={t} + /> + ) + return (
        - {/* jscpd:ignore-end */} {open ? (
          - {rows.map(row => ( - { - setExpandedId(current => current === String(row.id) ? undefined : String(row.id)) - }} - t={t} - /> - ))} + {liveRows.map(item)} + {liveRows.length > 0 && settledRows.length > 0 + ? + : null} + {settledRows.map(item)}
        ) : null} diff --git a/packages/client/ui-activity/src/client/index.ts b/packages/client/ui-activity/src/client/index.ts index 83ce0aa7bf..39f43df9e1 100644 --- a/packages/client/ui-activity/src/client/index.ts +++ b/packages/client/ui-activity/src/client/index.ts @@ -1,8 +1,10 @@ /** - * Live-activity plugin, browser half: contributes one session-header action - * that renders the activity roster and, per expanded row, an on-demand - * observation stream's live output. All data arrives through the - * `activityFeed` client service; this plugin holds no transport state. + * Task-list plugin, browser half: contributes one session-header action that + * renders this session's tasks — background jobs joined with their correlated + * live-output activities, plus standalone activities such as workflow runs. + * Job rows arrive through the `jobsBySession` list mirror; activity rows and + * per-row observation streams arrive through the `activityFeed` client + * service. This plugin holds no transport state of its own. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import { ActivityListAction } from './ActivityListAction.tsx' @@ -22,8 +24,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { export type { ActivityListActionProps, ActivityListInjected } from './ActivityListAction.tsx' -/** Required services: the activity feed, the slot registry, and dictionaries. */ -export const inject = ['activityFeed', 'slots', 'locale'] +/** Required services: session and activity state, the slot registry, and dictionaries. */ +export const inject = ['sessions', 'activityFeed', 'slots', 'locale'] /** * Client plugin body: register the dictionaries and the header action. @@ -36,8 +38,8 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'activity-list', - // After the background-job list: job control reads before live output. - order: 30, + // After the subagent catalog: session lineage reads before running work. + order: 20, locale: NS, inject: (): ActivityListInjected => ({ hooks: { activity: ctx.activityFeed.state }, diff --git a/packages/client/ui-activity/src/client/locales.ts b/packages/client/ui-activity/src/client/locales.ts index efea5126da..acb69c075d 100644 --- a/packages/client/ui-activity/src/client/locales.ts +++ b/packages/client/ui-activity/src/client/locales.ts @@ -5,17 +5,24 @@ export const NS = 'activity' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'count.live.one': '{count} 个活动进行中', - 'count.live.other': '{count} 个活动进行中', - 'count.idle.one': '{count} 个活动', - 'count.idle.other': '{count} 个活动', - 'list.aria': '实时活动', + 'count.live.one': '{count} 个任务进行中', + 'count.live.other': '{count} 个任务进行中', + 'count.idle.one': '{count} 个任务', + 'count.idle.other': '{count} 个任务', + 'list.aria': '任务', + 'section.settled': '已结束', 'row.expandAria': '展开 {label} 的实时输出', 'row.collapseAria': '收起 {label} 的实时输出', 'status.running': '运行中', + 'status.stopping': '正在停止', 'status.completed': '已完成', 'status.killed': '已取消', 'status.failed': '已失败', + 'duration.seconds': '{seconds}秒', + 'duration.minutes': '{minutes}分{seconds}秒', + 'duration.hours': '{hours}小时{minutes}分', + 'duration.title.live': '已运行 {duration}', + 'duration.title.done': '耗时 {duration}', 'output.gap': '……较早的输出已丢弃……', 'output.error': '实时输出流中断:{error}', 'terminal.signal': '信号 {signal}', @@ -34,17 +41,24 @@ export const zh = { /** English dictionary, key-identical to the Chinese source of truth. */ export const en: Record = { - 'count.live.one': '{count} live activity', - 'count.live.other': '{count} live activities', - 'count.idle.one': '{count} activity', - 'count.idle.other': '{count} activities', - 'list.aria': 'Live activities', + 'count.live.one': '{count} task running', + 'count.live.other': '{count} tasks running', + 'count.idle.one': '{count} task', + 'count.idle.other': '{count} tasks', + 'list.aria': 'Tasks', + 'section.settled': 'Finished', 'row.expandAria': 'Show live output of {label}', 'row.collapseAria': 'Hide live output of {label}', 'status.running': 'running', + 'status.stopping': 'stopping', 'status.completed': 'completed', 'status.killed': 'cancelled', 'status.failed': 'failed', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'duration.hours': '{hours}h {minutes}m', + 'duration.title.live': 'Running for {duration}', + 'duration.title.done': 'Took {duration}', 'output.gap': '… earlier output dropped …', 'output.error': 'live output stream interrupted: {error}', 'terminal.signal': 'signal {signal}', diff --git a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx index bca39ac732..6dbb9f4519 100644 --- a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx +++ b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx @@ -1,10 +1,11 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, } from '@deepseek-ai/dsh-api-activity-controller/client' +import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { ActivityListAction, type ActivityListActionProps } from '../src/client/ActivityListAction.tsx' import { zh } from '../src/client/locales.ts' @@ -12,6 +13,7 @@ import { zh } from '../src/client/locales.ts' afterEach(() => { cleanup() vi.restoreAllMocks() + vi.useRealTimers() }) const SESSION = 'session' as SessionId @@ -36,9 +38,21 @@ function unownedRow(over: Partial = {}): ActivityRow { return rest } +function job(over: Partial = {}): SessionJob { + return { + id: 'bash-1' as SessionJob['id'], + kind: 'bash', + label: 'pnpm run build', + status: 'running', + startedAt: 1_700_000_000_000, + ...over, + } +} + function props( snapshot: Partial, observe: ActivityListActionProps['observe'] = () => () => {}, + jobs: readonly SessionJob[] = [], ): ActivityListActionProps { const state: ActivityFeedSnapshot = { rowsBySession: {}, @@ -49,7 +63,11 @@ function props( function useActivity(select: (value: ActivityFeedSnapshot) => T): T { return select(state) } - return { sessionId: SESSION, useActivity, observe, t } as unknown as ActivityListActionProps + const sessionsState = { jobsBySession: jobs.length > 0 ? { [SESSION]: jobs } : {} } + function useSessions(select: (value: typeof sessionsState) => T): T { + return select(sessionsState) + } + return { sessionId: SESSION, useSessions, useActivity, observe, t } as unknown as ActivityListActionProps } function openList(): void { @@ -57,19 +75,19 @@ function openList(): void { } describe('ActivityListAction visibility', () => { - it('renders nothing while the session sees no activities', () => { + it('renders nothing while the session sees no tasks', () => { const { container } = render() expect(container.innerHTML).toBe('') }) - it('counts live activities on the trigger, merging owned and unowned rows', () => { + it('counts live tasks on the trigger, merging owned and unowned activity rows', () => { render() - expect(screen.getByRole('button', { name: '2 个活动进行中' })).toBeDefined() + expect(screen.getByRole('button', { name: '2 个任务进行中' })).toBeDefined() }) it('falls back to the total when nothing is live and hides foreign sessions', () => { @@ -79,7 +97,79 @@ describe('ActivityListAction visibility', () => { other: [row({ id: 'bash-9' as ActivityId, sessionId: 'other' as SessionId })], }, })} />) - expect(screen.getByRole('button', { name: '1 个活动' })).toBeDefined() + expect(screen.getByRole('button', { name: '1 个任务' })).toBeDefined() + }) +}) + +describe('ActivityListAction merged rows', () => { + it('joins a job with its correlated activity: job lifecycle, activity output', () => { + const observe = vi.fn(() => () => {}) + render() + openList() + const list = screen.getByRole('list', { name: zh['list.aria'] }) + // One merged row: the job's label and lifecycle word, not the activity's. + expect(within(list).getAllByRole('listitem')).toHaveLength(1) + expect(within(list).getByText('pnpm run build')).toBeDefined() + expect(screen.queryByText('activity-side label')).toBeNull() + expect(within(list).getByText('winding down')).toBeDefined() + // Expansion observes the correlated activity's stream. + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + expect(observe).toHaveBeenCalledWith('bash-act-7') + }) + + it('renders a bare job row without expansion affordances', () => { + render() + expect(screen.getByRole('button', { name: '1 个任务进行中' })).toBeDefined() + openList() + expect(screen.getByText('explore the repo')).toBeDefined() + expect(screen.queryByRole('button', { name: zh['row.expandAria'].replace('{label}', 'explore the repo') })).toBeNull() + }) + + it('keeps an activity whose job is not in the projection', () => { + render() + openList() + expect(screen.getByText('pnpm run build')).toBeDefined() + }) + + it('separates live and settled sections only when both exist', () => { + const settled = [job({ id: 'bash-2' as SessionJob['id'], status: 'completed', finishedAt: 1_700_000_012_000 })] + const { rerender } = render() + openList() + expect(screen.getByText(zh['section.settled'])).toBeDefined() + // Live-only lists carry no divider. + rerender() + expect(screen.queryByText(zh['section.settled'])).toBeNull() + }) + + it('shows a settled duration and ticks a live one', () => { + vi.useFakeTimers({ now: 1_700_000_020_000 }) + render() + openList() + expect(screen.getByText('5秒')).toBeDefined() + expect(screen.getByText('12秒')).toBeDefined() + expect(screen.getByText('1小时3分')).toBeDefined() + expect(screen.getByText('1分12秒')).toBeDefined() + act(() => { vi.advanceTimersByTime(1_000) }) + expect(screen.getByText('6秒')).toBeDefined() }) }) @@ -200,7 +290,7 @@ describe('ActivityListAction rows and observation', () => { const { rerender } = render() - expect(screen.getByRole('button', { name: '2 个活动' })).toBeDefined() + expect(screen.getByRole('button', { name: '2 个任务' })).toBeDefined() openList() const list = screen.getByRole('list', { name: zh['list.aria'] }) fireEvent.keyDown(list, { key: 'a' }) diff --git a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts b/packages/client/ui-activity/tests/browser-plugin.client.spec.ts index de6db2a069..3f84c2252d 100644 --- a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-activity/tests/browser-plugin.client.spec.ts @@ -35,6 +35,7 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null) + ctx.provide('sessions', {} as never) ctx.provide('activityFeed', { state: { getSnapshot: () => ({ rowsBySession: {}, observed: {}, phase: 'pending' }), subscribe: () => () => {} }, observe: (id: string) => { observed.push(id); return () => {} }, @@ -56,7 +57,7 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType { it('declares the services it binds', () => { - expect(inject).toEqual(['activityFeed', 'slots', 'locale']) + expect(inject).toEqual(['sessions', 'activityFeed', 'slots', 'locale']) }) it('exposes the feed source and observation control through the inject face', async () => { diff --git a/packages/client/ui-activity/tsconfig.json b/packages/client/ui-activity/tsconfig.json index bbd9c5ce85..562e2c2228 100644 --- a/packages/client/ui-activity/tsconfig.json +++ b/packages/client/ui-activity/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../../api/activity-controller/tsconfig.client.json" }, + { "path": "../../api/session-controller/tsconfig.client.json" }, { "path": "../locale" }, { "path": "../ui-conversation" }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml deleted file mode 100644 index 23ebb49d68..0000000000 --- a/packages/client/ui-jobs/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# 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/ui-jobs/README.md -README.md: 0523aa10ebfbaa6cde0bef391017bc0307d8bdd5 -README.zh.md: bb713f446855a9e8b23c5543827b9897f1177a48 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md deleted file mode 100644 index 0523aa10eb..0000000000 --- a/packages/client/ui-jobs/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# @deepseek-ai/dsh-client-ui-jobs - -English | [中文](README.zh.md) - -Web background-job feature owner: contributes one entry to `conversation.session.header.actions` listing the `ctx.jobs` records this session can see. The data arrives entirely through the `jobsBySession` list mirror that the [Session Controller](../../api/session-controller/README.md) folds from `session/jobs` frames, so this package issues no RPC and holds no state beyond popover visibility. - -The trigger renders only when the session has at least one job, so an ordinary conversation never grows a control for a capability it is not using. Its badge counts `running` plus `stopping` and is omitted at zero, leaving a session that holds only finished jobs a quiet entry point into its history rather than one advertising a count of nothing. The popover is a flat list: live rows first by `startedAt` ascending, then settled rows by `finishedAt` descending, with a same-millisecond tie broken on start order so the host's map iteration never decides it. A row shows the producer kind, the label, a status marker, the producer's `detail` in place of the generic status word once it has one, and an elapsed duration. That duration advances once per second while the row is live and freezes at `finishedAt`; the clock runs only while an open list holds something that moves. A settled row missing `finishedAt` reads as zero rather than as a negative figure, and a duration past an hour stays in hours rather than growing a day vocabulary no producer currently reaches. - -Settled rows stay visible and de-emphasized until the registry drops them at owner disposal. They are in the snapshot, a failed job's `detail` is the only place its failure is legible, and filtering them out here is work the output and cancellation phases would undo. A running one-shot background subagent therefore appears both here and in the [subagent catalog](../ui-subagent/README.md): the catalog navigates into the child's transcript, while this list is the only handle a future cancellation can attach to. - -Escape closes the list and returns focus to the trigger, as does a pointer press outside it. The last job disappearing closes the list before the control unmounts, so focus never vanishes from a removed node. Styling uses tokens only; copy goes through the package's own `job` locale namespace. The behavior is specified by the [Web background-job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). - -## Model Experience - -None, as this package renders host-computed registry state for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same jobs stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). - -#### KV Cache effect - -None; the package never assembles or sends provider requests. - -## Known Limitations and Deferred Work - -- **Rows are read-only** — a job's streamed output and a human-initiated cancellation are separate phases. Cancellation additionally owes a model-facing decision the seam does not answer: `kill()` marks terminal delivery reported, so an interrupt written against the current contract would leave the model believing its job is still running. -- **The list is not the registry's own set** — it shows what one session can see through the wire view, so a job owned by another session never appears here, and a process restart empties the list while the transcript keeps the `run_in_background` cards that started those jobs. An unowned job (one started without a live `Agent`) is the opposite case: it reaches every session's list, matching what `list(caller)` reports to every caller. diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md deleted file mode 100644 index bb713f4468..0000000000 --- a/packages/client/ui-jobs/README.zh.md +++ /dev/null @@ -1,24 +0,0 @@ -# @deepseek-ai/dsh-client-ui-jobs - -[English](README.md) | 中文 - -Web 后台任务特性的归属方:向 `conversation.session.header.actions` 贡献一个条目,列出当前会话可见的 `ctx.jobs` 记录。数据完全来自 [Session Controller](../../api/session-controller/README.zh.md) 从 `session/jobs` 帧折叠出的 `jobsBySession` 列表镜像,因此本包不发任何 RPC,除弹层开合外不持有任何状态。 - -只有当会话至少有一个任务时才渲染触发器,普通对话不会因为一项未被使用的能力而长出控件。角标计数为 `running` 加 `stopping`,为零时省略,这样只剩已完成任务的会话保留一个安静的历史入口,而不是宣告一个「零」。弹层是一个扁平列表:活跃行在前按 `startedAt` 升序,随后终态行按 `finishedAt` 降序;毫秒相同的并列按启动顺序打破,宿主的 map 迭代顺序永远不参与决定。一行显示生产者 kind、label、状态标记、生产者一旦给出 `detail` 就取代通用状态词的那段文字,以及已耗时。该耗时在活跃时每秒推进,并在 `finishedAt` 冻结;只有当打开的列表里确实有会动的东西时时钟才运行。缺少 `finishedAt` 的终态行读作零而不是负数,超过一小时的耗时停留在小时单位,不会长出任何生产者目前都到不了的「天」词汇。 - -终态行保持可见并弱化,直到注册表在 owner 销毁时把它们丢掉。它们本就在快照里,失败任务的 `detail` 是其失败唯一可读之处,在这里过滤掉它们是输出与中断两期要推翻的工作。因此一个运行中的一次性后台 subagent 会同时出现在这里和 [subagent 目录](../ui-subagent/README.zh.md)里:目录负责进入子会话的 transcript,而这个列表是将来中断能力唯一可能附着的句柄。 - -Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理。最后一个任务消失时先关闭列表再卸载控件,焦点因此不会从一个被移除的节点上凭空消失。样式只用 token;文案走本包自己的 `job` locale 命名空间。行为由 [Web 后台任务展示 Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 规定。 - -## 模型体验 - -无,因为本包为人类渲染宿主计算出的注册表状态,不触及 prompt、消息、schema、流或工具结果。模型对同一批任务的视角仍属于 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 - -#### KV Cache effect - -无;本包从不组装或发送 provider 请求。 - -## 已知限制与暂缓事项 - -- **行是只读的** —— 任务的流式输出与人类发起的中断是各自独立的阶段。中断还额外欠一个 seam 没有回答的、面向模型的决策:`kill()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。 -- **列表不等于注册表自己的集合** —— 它展示的是「一个会话通过线路视图能看到什么」,所以别的会话拥有的任务在这里永远不出现;而进程重启会清空列表,transcript 里启动这些任务的 `run_in_background` 卡片却还在。无主任务(在没有活体 `Agent` 时启动的)是反过来的情形:它会进入每一个会话的列表,与 `list(caller)` 对每个调用方的报告一致。 diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json deleted file mode 100644 index a195c70e08..0000000000 --- a/packages/client/ui-jobs/package.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-client-ui-jobs", - "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.1-rc.2", - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "dsh": { - "client": { - "inject": [ - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-primitives" - ], - "platform": "web" - } - }, - "scripts": { - "bundle": "tsdown", - "watch": "tsdown --watch" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/client/ui-jobs" - }, - "publishConfig": { - "access": "public" - }, - "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/client.js", - "lib/types/**/*.d.ts" - ] -} diff --git a/packages/client/ui-jobs/src/client/JobListAction.module.css b/packages/client/ui-jobs/src/client/JobListAction.module.css deleted file mode 100644 index 2f475bd09c..0000000000 --- a/packages/client/ui-jobs/src/client/JobListAction.module.css +++ /dev/null @@ -1,125 +0,0 @@ -.root { - position: relative; -} - -.trigger { - display: inline-flex; - align-items: center; - gap: 3px; - min-height: 28px; - padding: 3px 2px; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 18px; - cursor: pointer; -} - -.trigger:hover, -.trigger:focus-visible { - color: var(--dsw-alias-label-secondary); -} - -.trigger svg { - transition: transform 120ms ease; -} - -.triggerOpen { - transform: rotate(180deg); -} - -.triggerDot { - flex: none; -} - -.count { - margin: 0 5px; -} - -.menu { - position: absolute; - top: calc(100% + 5px); - left: 0; - z-index: 100; - box-sizing: border-box; - display: flex; - flex-direction: column; - gap: 1px; - width: 336px; - max-width: min(400px, calc(100vw - 32px)); - max-height: min(420px, calc(100vh - 140px)); - margin: 0; - padding: 4px; - overflow: auto; - list-style: none; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; - background: var(--dsw-specific-menu); - --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); - --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); - box-shadow: var(--dsw-shadow-lv3); -} - -.row { - display: flex; - align-items: center; - gap: 8px; - box-sizing: border-box; - width: 100%; - min-height: 32px; - padding: 6px 8px; - border-radius: 8px; - color: var(--dsw-alias-label-primary); - font-size: 13px; - line-height: 18px; -} - -.rowSettled { - color: var(--dsw-alias-label-tertiary); -} - -.rowDot { - flex: none; -} - -.kind { - flex: none; - padding: 0 6px; - border-radius: 5px; - background: var(--dsw-alias-fill-l2); - color: var(--dsw-alias-label-secondary); - font-size: 11px; - line-height: 18px; -} - -.label { - flex: 1; - min-width: 0; - overflow: hidden; - font-family: var(--dsw-font-mono); - white-space: nowrap; - text-overflow: ellipsis; -} - -.status, -.duration { - flex: none; - color: var(--dsw-alias-label-tertiary); - font-size: 11px; - line-height: 18px; -} - -/* A failed job's detail is the producer's raw error text, so it has no bound; - without this it widens the row past the menu instead of eliding like .label. */ -.status { - max-width: 40%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.duration { - font-variant-numeric: tabular-nums; -} diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx deleted file mode 100644 index bf15074079..0000000000 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' -import type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' -import { IconChevronDownOutline14, StateDot, useDismissOnOutsidePointer, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' -import { NS } from './locales.ts' -import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import css from './JobListAction.module.css' - -/** Full props for the session-header background-job action. */ -export type JobListActionProps = - PropsRuntime<'conversation.session.header.actions'> & PropsLocale - -/** Stable empty list so a session with no jobs keeps one array identity. */ -const NO_TASKS: readonly JobView[] = [] - -/** A job the registry still holds open, and whose duration therefore ticks. */ -function isLive(job: JobView): boolean { - return job.status === 'running' || job.status === 'stopping' -} - -/** Closed-union exhaustiveness fence for the wire status set. */ -/* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */ -function assertNever(value: never): never { - throw new Error(`unhandled job status: ${JSON.stringify(value)}`) -} - -/** - * Status marker semantics. `stopping` and `killed` share the attention color: - * both mean the work ended (or is ending) on request rather than on its own. - */ -function dotState(status: JobView['status']): StateDotState { - switch (status) { - case 'running': return 'ongoing' - case 'stopping': return 'warning' - case 'completed': return 'done' - case 'killed': return 'warning' - case 'failed': return 'error' - /* v8 ignore next -- closed wire status union */ - default: return assertNever(status) - } -} - -/** Human status word for the row and its accessible name. */ -function statusLabel(status: JobView['status'], t: TranslateNS): string { - switch (status) { - case 'running': return t('status.running') - case 'stopping': return t('status.stopping') - case 'completed': return t('status.completed') - case 'killed': return t('status.killed') - case 'failed': return t('status.failed') - /* v8 ignore next -- closed wire status union */ - default: return assertNever(status) - } -} - -/** - * Elapsed time in at most two adjacent units. A background job that outlives - * an hour is already exceptional, so hours is the widest unit — beyond that the - * figure stays in hours rather than growing a day/month vocabulary no producer - * currently reaches. - */ -function formatDuration(elapsedMs: number, t: TranslateNS): string { - const total = Math.max(0, Math.floor(elapsedMs / 1_000)) - const seconds = total % 60 - const minutes = Math.floor(total / 60) % 60 - const hours = Math.floor(total / 3_600) - if (hours > 0) return t('duration.hours', { hours, minutes }) - if (minutes > 0) return t('duration.minutes', { minutes, seconds }) - return t('duration.seconds', { seconds }) -} - -/** - * Live rows first in start order, then settled rows newest-first. Two jobs - * that settled in the same millisecond fall back to start order, so the sort - * never depends on the host's map iteration. - */ -function ordered(jobs: readonly JobView[]): JobView[] { - return [...jobs].sort((left, right) => { - const liveLeft = isLive(left) - if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 - if (liveLeft) return left.startedAt - right.startedAt - const finished = (right.finishedAt ?? right.startedAt) - (left.finishedAt ?? left.startedAt) - return finished !== 0 ? finished : left.startedAt - right.startedAt - }) -} - -/** - * Session-header entry point for this session's background jobs. It renders - * nothing at all until the session has at least one job, so an ordinary - * conversation never grows a control for a capability it is not using. - * @param props - runtime slot currency plus the namespace translator. - * @returns the trigger and its popover list, or null when there is nothing to show. - */ -export function JobListAction({ sessionId, useSessions, t }: JobListActionProps) { - const jobs = useSessions(state => state.jobsBySession[sessionId]) ?? NO_TASKS - const [open, setOpen] = useState(false) - const [now, setNow] = useState(() => Date.now()) - const rootRef = useRef(null) - const triggerRef = useRef(null) - - const rows = useMemo(() => ordered(jobs), [jobs]) - const liveCount = useMemo(() => jobs.filter(isLive).length, [jobs]) - - useDismissOnOutsidePointer(rootRef, open, setOpen) - - // The clock only runs while an open list is showing something that moves. - useEffect(() => { - if (!open || liveCount === 0) return - setNow(Date.now()) - const timer = setInterval(() => { setNow(Date.now()) }, 1_000) - return () => { clearInterval(timer) } - }, [open, liveCount]) - - // The last job disappearing removes this control; close first so focus does - // not vanish from an unmounting node. - useEffect(() => { - if (jobs.length === 0 && open) setOpen(false) - }, [jobs.length, open]) - - if (jobs.length === 0) return null - - const countKey = liveCount > 0 - ? (liveCount === 1 ? 'count.live.one' : 'count.live.other') - : (jobs.length === 1 ? 'count.idle.one' : 'count.idle.other') - const countLabel = t(countKey, { count: liveCount > 0 ? liveCount : jobs.length }) - - const onKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Escape' || !open) return - event.preventDefault() - setOpen(false) - triggerRef.current?.focus() - } - - return ( -
        - - {open - ? ( -
          - {rows.map((job) => { - const live = isLive(job) - const elapsed = live ? now - job.startedAt : (job.finishedAt ?? job.startedAt) - job.startedAt - const duration = formatDuration(elapsed, t) - const status = statusLabel(job.status, t) - return ( -
        • - - {job.kind} - {job.label} - {job.detail ?? status} - - {duration} - -
        • - ) - })} -
        - ) - : null} -
        - ) -} diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts deleted file mode 100644 index 22dc2c8425..0000000000 --- a/packages/client/ui-jobs/src/client/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Background-job plugin, browser half: contributes one session-header action - * that renders this session's `ctx.jobs` records. The data arrives entirely - * through the `jobsBySession` list mirror, so the plugin issues no RPC and - * holds no state of its own beyond popover visibility. - */ -import type { Context as ClientContext } from '@deepseek-ai/cordis' -import { JobListAction } from './JobListAction.tsx' -import type {} from '@deepseek-ai/dsh-client-locale/client' -import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' -import type {} from '@deepseek-ai/dsh-client-ui-session/client' -import { en, NS, zh, type JobKey } from './locales.ts' - -declare module '@deepseek-ai/dsh-client-ui-slots' { - interface LocaleNamespaceMap { - /** Background-job list copy. */ - 'job': JobKey - } -} - -export type { JobListActionProps } from './JobListAction.tsx' - -/** Required services for locale registration and header-slot contribution. */ -export const inject = ['sessions', 'slots', 'locale'] - -/** - * Client plugin body: register the dictionaries and the header action. - * @param ctx - client root context. - */ -export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-job: dictionaries') - ctx.slots.inject( - 'conversation.session.header.actions', - () => ctx.slots.register({ - name: 'conversation.session.header.actions', - id: 'job-list', - // After the subagent catalog: session lineage reads before process work. - order: 20, - locale: NS, - }, JobListAction), - ) -} diff --git a/packages/client/ui-jobs/src/client/locales.ts b/packages/client/ui-jobs/src/client/locales.ts deleted file mode 100644 index 70bb2e9e0f..0000000000 --- a/packages/client/ui-jobs/src/client/locales.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** `job` namespace dictionaries. */ - -/** Dictionary namespace owned by this plugin. */ -export const NS = 'job' - -/** Simplified Chinese dictionary (the key-set source of truth). */ -export const zh = { - 'count.live.one': '{count} 个后台任务运行中', - 'count.live.other': '{count} 个后台任务运行中', - 'count.idle.one': '{count} 个后台任务', - 'count.idle.other': '{count} 个后台任务', - 'list.aria': '后台任务', - 'status.running': '运行中', - 'status.stopping': '正在停止', - 'status.completed': '已完成', - 'status.killed': '已取消', - 'status.failed': '已失败', - 'duration.seconds': '{seconds}秒', - 'duration.minutes': '{minutes}分{seconds}秒', - 'duration.hours': '{hours}小时{minutes}分', - 'duration.title.live': '已运行 {duration}', - 'duration.title.done': '耗时 {duration}', -} as const - -/** English dictionary, key-identical to the Chinese source of truth. */ -export const en: Record = { - 'count.live.one': '{count} background job running', - 'count.live.other': '{count} background jobs running', - 'count.idle.one': '{count} background job', - 'count.idle.other': '{count} background jobs', - 'list.aria': 'Background jobs', - 'status.running': 'running', - 'status.stopping': 'stopping', - 'status.completed': 'completed', - 'status.killed': 'cancelled', - 'status.failed': 'failed', - 'duration.seconds': '{seconds}s', - 'duration.minutes': '{minutes}m {seconds}s', - 'duration.hours': '{hours}h {minutes}m', - 'duration.title.live': 'Running for {duration}', - 'duration.title.done': 'Took {duration}', -} - -/** Key domain of the `job` namespace (zh is the source of truth). */ -export type JobKey = keyof typeof zh diff --git a/packages/client/ui-jobs/src/css-modules.d.ts b/packages/client/ui-jobs/src/css-modules.d.ts deleted file mode 100644 index bc5e482353..0000000000 --- a/packages/client/ui-jobs/src/css-modules.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module '*.module.css' { - const classes: Record - export default classes -} - -declare module '*.css' diff --git a/packages/client/ui-jobs/src/index.ts b/packages/client/ui-jobs/src/index.ts deleted file mode 100644 index 94d45c3ba1..0000000000 --- a/packages/client/ui-jobs/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Background-job list plugin, node half. Pure UI plugin: the empty apply - * exists so the plugin appears in the host cordis.yml / Loader; the browser - * half ships via exports["./client"], discovered through the package.json - * dshClient declaration. - */ - -/** Host plugin body — no host-side behavior for this source plugin. */ -export function apply(): void {} diff --git a/packages/client/ui-jobs/src/invariant.ts b/packages/client/ui-jobs/src/invariant.ts deleted file mode 100644 index 0807c8600f..0000000000 --- a/packages/client/ui-jobs/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-jobs`. - * @module @deepseek-ai/dsh-client-ui-jobs/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-jobs' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-jobs-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package is a read-only projection of the - * `jobsBySession` mirror onto one header slot entry. It emits no cordis - * events, owns no cross-plugin mutable state, and its single slot registration - * proves disposal through the HMR-safety spec. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts deleted file mode 100644 index 93e6df1e5f..0000000000 --- a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * ui-job plugin halves: the browser entry's dictionary and header-slot - * registrations against the real SlotRegistry (with fiber teardown proving - * removal — HMR safety), the inert node entry, and the invariant companion's - * ownership reservation. - */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' -import { apply, inject } from '../src/client/index.ts' -import { apply as applyNode } from '../src/index.ts' -import * as JobInvariant from '../src/invariant.ts' -import { en, NS, zh } from '../src/client/locales.ts' - -/** Slot ledger reader: entry ids currently registered in the header list. */ -function headerEntryIds(ctx: Context): (string | undefined)[] { - return ctx.slots - .entries('conversation.session.header.actions') - .map(entry => entry.options.id) -} - -/** Boot the browser half over a real slot tree that declares the header list. */ -async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { - const ctx = new Context() - await ctx.plugin(SlotRegistry).await() - ctx.slots.register({ - name: 'root', - children: { - 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, - }, - } as never, () => null) - ctx.provide('sessions', {}) - // The locale plugin binds a settings scope, which reads the connection handle - // and the forwarded-event port. - ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) - ctx.provide('remote', { $on: () => () => {} } as never) - ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() - // These specs assert the shipped Chinese copy. There is no jsdom `window` in - // this lane, so browser-language detection never runs and the locale comes - // from FALLBACK_LOCALE (en): state the asserted locale explicitly. - ctx.locale.setLocale('zh') - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - return { ctx, fiber } -} - -describe('ui-job browser half', () => { - it('declares the services it binds', () => { - expect(inject).toEqual(['sessions', 'slots', 'locale']) - }) - - it('registers the header action, and fiber teardown removes it (HMR safety)', async () => { - const { ctx, fiber } = await bench() - expect(headerEntryIds(ctx)).toContain('job-list') - await fiber.dispose() - expect(headerEntryIds(ctx)).not.toContain('job-list') - }) - - it('registers both dictionaries under its own namespace and releases them with the fiber', async () => { - const { ctx, fiber } = await bench() - const translate = ctx.locale.bind(NS) - expect(translate('list.aria')).toBe(zh['list.aria']) - ctx.locale.setLocale('en') - expect(translate('list.aria')).toBe(en['list.aria']) - - // Withdrawn dictionaries leave the key unresolved rather than translated. - await fiber.dispose() - expect(translate('list.aria')).not.toBe(en['list.aria']) - }) - - it('keeps the English dictionary key-identical to the Chinese source of truth', () => { - expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort()) - }) -}) - -describe('ui-job node half', () => { - it('contributes no host behavior', () => { - // The node half exists only so the plugin appears in the Loader tree. - expect(applyNode).not.toThrow() - }) -}) - -describe('ui-job invariant companion', () => { - it('reserves package ownership under its declared companion name', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - const fiber = ctx.plugin(JobInvariant) - await fiber.await() - expect(JobInvariant.name).toBe('client-ui-jobs-invariant') - expect(JobInvariant.inject).toEqual(['invariants']) - // Emitting an unrelated event proves the companion installed no audit. - expect(() => { - Reflect.apply(ctx.emit.bind(ctx), undefined, ['unrelated/event']) - }).not.toThrow() - await fiber.dispose() - }) -}) diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx deleted file mode 100644 index c3f016d58e..0000000000 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ /dev/null @@ -1,241 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' -import type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' -import { zh } from '../src/client/locales.ts' - -// Live rows render `now - startedAt`, so every assertion needs a pinned clock. -beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(START) -}) - -afterEach(() => { - cleanup() - vi.useRealTimers() - vi.restoreAllMocks() -}) - -const SESSION = 'session' as SessionId -const START = 1_700_000_000_000 -const t: JobListActionProps['t'] = makeTranslate(zh) - -function job(over: Partial = {}): JobView { - return { - id: 'bash-1' as JobView['id'], - kind: 'bash', - label: 'pnpm run build', - status: 'running', - startedAt: START, - ...over, - } -} - -function props(jobs: readonly JobView[] | undefined): JobListActionProps { - const state = { - ids: [SESSION], - byId: {}, - current: SESSION, - phase: 'ready', - subagentsByParent: {}, - jobsBySession: jobs === undefined ? {} : { [SESSION]: jobs }, - currentAddress: undefined, - } satisfies SessionListState - function useSessions(select: (snapshot: SessionListState) => T): T { - return select(state) - } - return { sessionId: SESSION, useSessions, t } as unknown as JobListActionProps -} - -/** - * Rows in render order as `[kind, label, status, duration]`. Adjacent spans - * carry no whitespace between them, so the cells are read one element at a - * time rather than split out of a flattened string. - */ -function rowCells(): string[][] { - return within(screen.getByRole('list', { name: zh['list.aria'] })) - .getAllByRole('listitem') - .map(row => [...row.children] - .map(cell => cell.textContent ?? '') - .filter(text => text !== '')) -} - -describe('JobListAction visibility', () => { - it('renders nothing while the session has no jobs', () => { - const { container } = render() - expect(container.innerHTML).toBe('') - }) - - it('counts only live jobs, and falls back to the total when none are live', () => { - const { rerender } = render() - expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined() - - rerender() - expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined() - }) - - it('closes and unmounts when the last job disappears while the list is open', () => { - const { container, rerender } = render() - fireEvent.click(screen.getByRole('button')) - expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() - - rerender() - expect(container.innerHTML).toBe('') - }) -}) - -describe('JobListAction rows', () => { - it('orders live jobs by start, then settled jobs newest-first', () => { - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells()).toEqual([ - ['bash', 'earlier live', '运行中', '0秒'], - ['bash', 'later live', '运行中', '0秒'], - ['bash', 'new done', '已失败', '9秒'], - ['bash', 'old done', '已完成', '1秒'], - ]) - }) - - it('breaks a settled tie on start order so map iteration never decides it', () => { - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells().map(cells => cells[1])).toEqual(['first', 'second']) - }) - - it('prefers the producer detail over the generic status word', () => { - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells()[0]).toContain('signal: SIGTERM') - }) - - it('renders every status word, including the stopping transition', () => { - render() - fireEvent.click(screen.getByRole('button')) - const words = rowCells().map(cells => cells[2]) - expect(new Set(words)).toEqual(new Set(['运行中', '正在停止', '已完成', '已取消', '已失败'])) - }) -}) - -describe('JobListAction duration', () => { - it('advances a live row once per second and freezes a settled one', () => { - vi.setSystemTime(START + 1_000) - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells()[0]).toContain('1秒') - expect(rowCells()[1]).toContain('4秒') - - act(() => { vi.advanceTimersByTime(2_000) }) - expect(rowCells()[0]).toContain('3秒') - expect(rowCells()[1]).toContain('4秒') - }) - - it('widens to minutes and then hours, and never shows a negative figure', () => { - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells().map(cells => cells[3])).toEqual(['2小时3分', '2分5秒', '0秒']) - }) - - it('runs no clock while the list is closed', () => { - const interval = vi.spyOn(globalThis, 'setInterval') - render() - expect(interval).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button')) - expect(interval).toHaveBeenCalledTimes(1) - }) - - it('runs no clock for an open list holding only settled jobs', () => { - const interval = vi.spyOn(globalThis, 'setInterval') - render() - fireEvent.click(screen.getByRole('button')) - expect(interval).not.toHaveBeenCalled() - }) -}) - -describe('JobListAction dismissal', () => { - it('closes on Escape and returns focus to the trigger', () => { - render() - const trigger = screen.getByRole('button') - fireEvent.click(trigger) - expect(trigger.getAttribute('aria-expanded')).toBe('true') - - fireEvent.keyDown(trigger, { key: 'Escape' }) - expect(trigger.getAttribute('aria-expanded')).toBe('false') - expect(document.activeElement).toBe(trigger) - }) - - it('ignores other keys and a closed-list Escape', () => { - render() - const trigger = screen.getByRole('button') - fireEvent.keyDown(trigger, { key: 'Escape' }) - expect(trigger.getAttribute('aria-expanded')).toBe('false') - - fireEvent.click(trigger) - fireEvent.keyDown(trigger, { key: 'ArrowDown' }) - expect(trigger.getAttribute('aria-expanded')).toBe('true') - }) - - it('closes on an outside pointer press but not on one inside', () => { - render() - const trigger = screen.getByRole('button') - fireEvent.click(trigger) - - fireEvent.pointerDown(screen.getByRole('list', { name: zh['list.aria'] })) - expect(trigger.getAttribute('aria-expanded')).toBe('true') - - fireEvent.pointerDown(document.body) - expect(trigger.getAttribute('aria-expanded')).toBe('false') - }) -}) - -describe('JobListAction wire tolerance', () => { - it('treats a settled job with no finishedAt as zero-duration and sorts it by start', () => { - // `finishedAt` is optional on the wire; the Host always sets it, so this - // covers a producer or carrier that ever stops doing so. - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells().map(cells => [cells[1], cells[3]])).toEqual([ - ['finished', '3秒'], - ['no finish', '0秒'], - ]) - }) - - it('falls back to start order when neither settled job carries a finish time', () => { - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells().map(cells => cells[1])).toEqual(['later', 'earlier']) - }) -}) diff --git a/packages/client/ui-jobs/tsconfig.json b/packages/client/ui-jobs/tsconfig.json deleted file mode 100644 index 727523a055..0000000000 --- a/packages/client/ui-jobs/tsconfig.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../api/session-controller/tsconfig.client.json" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../locale" - }, - { - "path": "../ui-conversation" - }, - { - "path": "../ui-primitives" - }, - { - "path": "../ui-renderer" - }, - { - "path": "../ui-session" - }, - { - "path": "../ui-slots" - }, - { - "path": "../../runtime-diagnostics/invariants" - } - ] -} diff --git a/packages/client/ui-jobs/tsdown.config.ts b/packages/client/ui-jobs/tsdown.config.ts deleted file mode 100644 index 550cd558a8..0000000000 --- a/packages/client/ui-jobs/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-ui-jobs', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 1a53958b6c..1533fe40dc 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1143,7 +1143,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [ 'client-ui-activity ActivityListAction id \'activity-list\'', 'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'', - 'client-ui-jobs JobListAction id \'job-list\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32f9a464b7..cfbccd1d51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1786,9 +1786,6 @@ importers: '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../../client/ui-input-trigger - '@deepseek-ai/dsh-client-ui-jobs': - specifier: workspace:^ - version: link:../../client/ui-jobs '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../client/ui-layout @@ -2100,6 +2097,9 @@ importers: '@deepseek-ai/dsh-api-activity-controller': specifier: workspace:^ version: link:../../api/activity-controller + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2802,45 +2802,6 @@ importers: specifier: ^18.2.0 version: 18.3.1 - packages/client/ui-jobs: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-session-controller': - specifier: workspace:^ - version: link:../../api/session-controller - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-renderer': - specifier: workspace:^ - version: link:../ui-renderer - '@deepseek-ai/dsh-client-ui-session': - specifier: workspace:^ - version: link:../ui-session - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-layout: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 48bc458bfa..513f801566 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -86,7 +86,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-chat': { kind: 'none', reason: 'Browser-side Chat presentation; registers nothing model-facing.' }, 'packages/client/ui-message-feedback': { kind: 'none', reason: 'Browser-side controls over the message-feedback sidecar; ratings and notes never enter the Session log, model context, or telemetry.' }, 'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' }, - 'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side read-only projection of ctx.jobs records; dsh-tool-jobs owns the model-facing behavior.' }, 'packages/client/ui-workflow-run': { kind: 'none', reason: 'Browser-side UI plugin layer; renders durable workflow records without changing model context.' }, 'packages/client/ui-input-trigger': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-reference': { kind: 'indirect', reason: 'Browser-side reference selection delegates file guidance and session snapshot preparation to Host-owned providers.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 11ae967059..d92abb6ab5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -248,7 +248,6 @@ "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"], "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], "@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"], - "@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"], "@deepseek-ai/dsh-client-ui-activity": ["./packages/client/ui-activity/src"], "@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"], "@deepseek-ai/dsh-client-ui-user-questions": ["./packages/client/ui-user-questions/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 41f6aea41a..037f94a822 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -77,7 +77,6 @@ { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-subagent" }, - { "path": "./packages/client/ui-jobs" }, { "path": "./packages/client/ui-activity" }, { "path": "./packages/client/ui-directory-picker-browse" }, { "path": "./packages/client/ui-directory-picker-native" }, From e200eadd736f22c3f532a0611d9afa99b97ca0dc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 14:23:12 +0800 Subject: [PATCH 005/284] =?UTF-8?q?fix(activity):=20acceptance=20refinemen?= =?UTF-8?q?ts=20=E2=80=94=20header=20band=20order=20and=20panel=20presenta?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive acceptance produced five presentation decisions: - Header band order is preset → tasks → subagent catalog. The root-session descendant count moves out of the breadcrumb (header.lineage) into the actions band (order 30) as SubagentCatalogAction and drops its '/' separator — a root session has no hierarchy to breadcrumb. Child sessions keep their root/child switcher unchanged. - The panel's copy control copies the command (TerminalBlock.copyText), not the output: a long command ellipsizes in the row, so the control is its only complete source, and it stays rendered before any output. - Commands and output lines wrap in full (--dsl-terminal-command-whitespace / --dsl-terminal-line-whitespace: pre-wrap), so the output region never scrolls sideways; the region caps at a fixed height and scrolls (--dsl-terminal-output-max-height) with maxLines: Infinity disabling the head/tail fold. - The panel draws no run-state dot (TerminalBlock.runStateDot: false) — the row above carries the same state — and reclaims the dot gutter via --dsl-terminal-gutter, which must target the card element itself because the variable is declared on it. The panel's left spine is gone with it. All TerminalBlock changes are opt-ins (props or --dsl-terminal-* variables); tool cards keep their shipped fold/ellipsis/dot behavior. --- .../2026-08-25-unified-task-list.i18n.yaml | 4 +- .../feature/2026-08-25-unified-task-list.md | 17 ++- .../2026-08-25-unified-task-list.zh.md | 17 ++- apps/web/tests/agent-preset-selection.e2e.ts | 5 +- .../agent-preset-selection/header.expected.md | 7 +- .../live-activity-stream/settled.expected.md | 2 +- .../streaming.expected.md | 2 +- .../src/client/ActivityListAction.module.css | 19 +++- .../src/client/ActivityListAction.tsx | 13 ++- .../activity-list-action.client.spec.tsx | 16 ++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../src/TerminalBlock.module.css | 19 ++-- .../ui-primitives/src/TerminalBlock.tsx | 28 +++-- .../tests/terminal-block.client.spec.tsx | 16 +++ packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 4 +- packages/client/ui-subagent/README.zh.md | 4 +- .../client/SubagentHeaderLineage.module.css | 6 -- .../src/client/SubagentHeaderLineage.tsx | 52 ++++++--- .../client/ui-subagent/src/client/index.ts | 16 ++- .../tests/browser-plugin.client.spec.ts | 10 ++ .../tests/conversation-ui.client.spec.tsx | 101 +++++++++++------- .../src/client/slot-catalog.ts | 1 + 25 files changed, 261 insertions(+), 110 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml index 031f01691a..0e65f2bce7 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-25-unified-task-list.md -2026-08-25-unified-task-list.md: 9ce3e6d2b066a94ee738880f69b0ddf718a10b02 -2026-08-25-unified-task-list.zh.md: ab224fd082fb8d6408449716e68e6df6c648a281 +2026-08-25-unified-task-list.md: b098ea82e4762f9528ecd71bbd2b8095c540b900 +2026-08-25-unified-task-list.zh.md: 4e5a2ef5d616f73741670c1c7ad787df19069fdd diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md index 9ce3e6d2b0..b098ea82e4 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md @@ -19,11 +19,26 @@ The service planes stay orthogonal; the join happens once, per row, in the UI pr Deliberately not joined: subagent delegation rows stay bare job rows (the subagent panel is their surface), and foreground commands stay in their tool cards — neither gains an activity producer. -## Alternatives rejected +## Alternatives considered - **Merge the services** — rejected for the cursor-semantics and model-visibility reasons above. - **Keep the job list and bolt output onto it** — leaves workflow runs (activities without jobs) homeless, forcing a second list to survive anyway. +## Refinements from browser acceptance + +Driving the merged list in the real app produced four presentation decisions, all scoped to the expanded panel and the header band: + +- **The header band order is preset → tasks → subagent catalog.** The root-session descendant count moved out of the breadcrumb (`header.lineage`) into the actions band (order 30) and dropped its `/` separator — a root session has no hierarchy to breadcrumb; child sessions keep their `root / child` switcher where the `/` is real. `SubagentCatalogAction` owns the moved seat; the lineage renderer now renders nothing on roots. +- **The panel's copy control copies the command** (`TerminalBlock.copyText`), not the output: a long command ellipsizes in the row, so the control is its only complete source, while output is selectable text below. Supplying `copyText` also keeps the control rendered before any output exists. +- **The panel scrolls instead of folding**: commands and output lines wrap in full (`--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace: pre-wrap`, so the output region never scrolls sideways) and the output region caps at a fixed height (`--dsl-terminal-output-max-height`) with `maxLines: Infinity` disabling the head/tail fold. +- **The panel draws no run-state dot** (`TerminalBlock.runStateDot: false`) — the row above it already carries the same state — and reclaims the dot gutter via `--dsl-terminal-gutter`. The panel's left spine was dropped with it. + +All four land as opt-ins on `TerminalBlock` (props or `--dsl-terminal-*` variables), so tool cards keep their shipped fold/ellipsis/dot behavior unchanged. + +## Testing + +Component suites cover the join (job lifecycle wins, activity supplies the panel, bare-job rows, standalone activities), sections, durations, and the panel options; `pnpm vitest run packages/client/ui-activity packages/client/ui-primitives packages/client/ui-subagent` runs them. The keyless web e2e pair (`background-job-list`, `live-activity-stream`) replays the merged list end to end against ARIA goldens; `agent-preset-selection` pins the header band order. + ## Consequences - The two web e2e scenarios (`background-job-list`, `live-activity-stream`) now assert one "Tasks" list; the job-list scenario's row gained a live-output panel, which is the join working end to end. diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md index ab224fd082..4e5a2ef5d6 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md @@ -19,11 +19,26 @@ 刻意不合并的:subagent 委托行保持裸 job 行(subagent 面板是它的呈现面),前台命令留在工具卡片里——两者都不接活动生产者。 -## 已否决的替代方案 +## 曾考虑的替代方案 - **合并服务层**——因上述游标语义与模型可见性理由否决。 - **保留 job 列表、把输出挂上去**——workflow 运行(无 job 的 activity)无处安放,第二个列表终究得活下来。 +## 浏览器验收带来的细化 + +在真实应用里驱动合并列表得出四个呈现决策,全部限定在展开面板与头部条带: + +- **头部条带顺序为 preset → 任务 → subagent 目录。** 根会话的后代数量从面包屑(`header.lineage`)移入操作区(order 30)并去掉 `/` 分隔符——根会话没有可面包屑的层级;子会话保留 `根 / 子` 切换器,那里的 `/` 是真实层级。移位后的席位由 `SubagentCatalogAction` 拥有;谱系 renderer 在根会话上不再渲染任何东西。 +- **面板的复制控件复制命令**(`TerminalBlock.copyText`)而非输出:长命令在行内被省略号截断,控件是它唯一的完整来源,而输出本身就是下方可选中的文本。提供 `copyText` 还让控件在任何输出出现之前就保持渲染。 +- **面板以滚动取代折叠**:命令与输出行完整换行(`--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace: pre-wrap`,输出区因此永不横向滚动),输出区以固定高度封顶(`--dsl-terminal-output-max-height`),`maxLines: Infinity` 禁用头尾折叠。 +- **面板不画运行状态点**(`TerminalBlock.runStateDot: false`)——上方的行已携带同一状态——并通过 `--dsl-terminal-gutter` 收回状态点落区。面板左侧竖线随之移除。 + +四项全部以 `TerminalBlock` 的可选项落地(prop 或 `--dsl-terminal-*` 变量),工具卡片保持既有的折叠/省略/状态点行为不变。 + +## 测试 + +组件套件覆盖 join(job 生命周期优先、activity 提供面板、裸 job 行、独立 activity)、分段、时长与面板选项;`pnpm vitest run packages/client/ui-activity packages/client/ui-primitives packages/client/ui-subagent` 运行它们。无密钥 web e2e 对(`background-job-list`、`live-activity-stream`)按 ARIA golden 端到端回放合并列表;`agent-preset-selection` 钉住头部条带顺序。 + ## 后果 - 两个 web e2e 场景(`background-job-list`、`live-activity-stream`)现在断言同一个"任务"列表;job 场景的行获得了实时输出面板,这正是 join 端到端生效的证据。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index df6a2baa36..c062e21367 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -280,8 +280,9 @@ describe('web e2e: agent-preset selection', () => { await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Minimal mode') expect(snapshot).toContain('button "1 subagent"') - expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode')) - expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "Session log"')) + // The preset label leads the header band; delegation navigation follows it. + expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"')) + expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "Session log"')) // Static chrome, not a control: the header can only report a composition // the host would refuse to change. expect(snapshot).not.toContain('button "Minimal mode"') diff --git a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md index 9a9f380833..612edbc213 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md @@ -1,11 +1,10 @@ - navigation "Session hierarchy": - button "Seeded turn" [disabled] - - text: / - - button "1 subagent": - - text: 1 subagent - - img - img - text: Minimal mode +- button "1 subagent": + - text: 1 subagent + - img - button "Session log": - text: Session log - img diff --git a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md index 2090dca51d..ecdcd4fea4 100644 --- a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md +++ b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md @@ -3,6 +3,6 @@ - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM {{duration}}" - img - - text: done $ printf 'streamed-%s\n' marker-line; sleep 45 + - text: $ printf 'streamed-%s\n' marker-line; sleep 45 - button "Copy" - text: streamed-marker-line diff --git a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md index ea906239aa..aa23b11410 100644 --- a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md +++ b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md @@ -3,6 +3,6 @@ - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - text: printf 'streamed-%s\n' marker-line; sleep 45 {{duration}} bash running - img - - text: running $ printf 'streamed-%s\n' marker-line; sleep 45 + - text: $ printf 'streamed-%s\n' marker-line; sleep 45 - button "Copy" - text: streamed-marker-line diff --git a/packages/client/ui-activity/src/client/ActivityListAction.module.css b/packages/client/ui-activity/src/client/ActivityListAction.module.css index d0d632ab34..93259a16d7 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.module.css +++ b/packages/client/ui-activity/src/client/ActivityListAction.module.css @@ -189,11 +189,22 @@ transform: rotate(180deg); } -/* Output hangs under its row behind a spine that ties the two together. */ .panel { - margin: 0 8px 8px 14px; - padding-left: 8px; - border-left: 2px solid var(--dsw-alias-border-l2); + margin: 0 8px 8px; + /* Long commands and output lines wrap in full instead of ellipsizing or + scrolling sideways, and the output region scrolls within a fixed height + instead of folding its middle lines. */ + --dsl-terminal-command-whitespace: pre-wrap; + --dsl-terminal-line-whitespace: pre-wrap; + --dsl-terminal-output-max-height: 288px; +} + +/* The row carries the state dot, so the panel omits its own and reclaims the + dot gutter down to an ordinary content inset. The override targets the card + element itself because the gutter variable is declared on it — a value + inherited from this container would lose to that declaration. */ +.panel :global([data-terminal]) { + --dsl-terminal-gutter: 12px; } .notice { diff --git a/packages/client/ui-activity/src/client/ActivityListAction.tsx b/packages/client/ui-activity/src/client/ActivityListAction.tsx index ef77df0e9c..8d6c9c43f3 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.tsx +++ b/packages/client/ui-activity/src/client/ActivityListAction.tsx @@ -62,8 +62,6 @@ const NO_JOBS: readonly SessionJob[] = [] /** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */ const VIEWPORT_MARGIN = 12 -/** Height cap for one live output panel inside the popover. */ -const PANEL_MAX_LINES = 16 function isLive(row: TaskRow): boolean { return row.status === 'running' || row.status === 'stopping' @@ -136,7 +134,11 @@ function terminalLabels(t: TranslateNS): TerminalBlockLabels { noOutput: t('terminal.noOutput'), collapseAria: t('terminal.collapseAria'), collapse: t('terminal.collapse'), + // The panel never caps lines (it scrolls), so the fold controls that + // would invoke these stay unrendered. + /* v8 ignore next */ expandAria: hidden => t('terminal.expandAria', { n: hidden }), + /* v8 ignore next */ expand: hidden => t('terminal.expand', { n: hidden }), } } @@ -288,7 +290,12 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: { command={row.label} output={view.text} running={live} - maxLines={PANEL_MAX_LINES} + copyText={row.label} + // The row above the panel already carries the state dot. + runStateDot={false} + // The panel scrolls its output (a stylesheet height cap) instead + // of collapsing the middle. + maxLines={Number.POSITIVE_INFINITY} labels={labels} />
        diff --git a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx index 6dbb9f4519..878aa21c23 100644 --- a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx +++ b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx @@ -192,7 +192,9 @@ describe('ActivityListAction rows and observation', () => { expect(items[1]?.textContent).toContain('exit code: 3') }) - it('marks killed rows and expands an overflowing panel through the collapse controls', () => { + it('marks killed rows, renders every output line unfolded, and copies the command', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) const lines = Array.from({ length: 24 }, (_value, index) => `line ${index + 1}`).join('\n') const view: ObservedActivity = { activityId: 'bash-1' as ActivityId, @@ -208,9 +210,15 @@ describe('ActivityListAction rows and observation', () => { openList() expect(screen.getByText(zh['status.killed'])).toBeDefined() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - const expand = screen.getByRole('button', { name: zh['terminal.expandAria'].replace('{n}', '8') }) - fireEvent.click(expand) - expect(screen.getByRole('button', { name: zh['terminal.collapseAria'] })).toBeDefined() + // The panel scrolls instead of folding: every line renders, no fold control. + expect(screen.getByText('line 1')).toBeDefined() + expect(screen.getByText('line 24')).toBeDefined() + expect(screen.queryByRole('button', { name: zh['terminal.expandAria'].replace('{n}', '8') })).toBeNull() + // The panel draws no state dot or label of its own — the row carries it. + expect(screen.queryByText(zh['terminal.done'])).toBeNull() + // The copy control carries the command, not the output. + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['terminal.copy'] })) }) + expect(writeText).toHaveBeenCalledWith('pnpm run build') }) it('orders live rows first by start and settled rows newest-first with tie-breaks', () => { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index a025628aa4..c4a07f5cc1 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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/ui-primitives/README.md -README.md: 4df368b270511776896c74a55b0c1d2e684035d0 -README.zh.md: 77830a1f162e963709ea4d4d25c8393e7dd97d66 +README.md: 48550de318cdd637d97c23009d95215a793c9b62 +README.zh.md: 83fdaa927c04dd130b410ac885c0d110939f48b3 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 4df368b270..48550de318 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -18,7 +18,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P ## Terminal output -`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; a running block with a supplied `output` renders that live text under the running state (an undefined output keeps the historical prompt-only frame); it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with `anser` (bundled into this package's browser artifact) into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop — or the `copyText` override, which also keeps the control rendered before any output exists. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; a running block with a supplied `output` renders that live text under the running state (an undefined output keeps the historical prompt-only frame); it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. `runStateDot: false` omits the dot and its hidden label for hosts whose surrounding row already carries the same state; they reclaim the dot gutter via `--dsl-terminal-gutter`. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes; rebinding `--dsl-terminal-command-whitespace` to `pre-wrap` wraps long commands in full instead. ANSI escape sequences are parsed with `anser` (bundled into this package's browser artifact) into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping (`--dsl-terminal-line-whitespace: pre-wrap` opts a host into wrapped lines with no sideways scroll), and collapses to a head slice plus a tail slice past `maxLines` (default 16; Infinity disables the fold for hosts that cap height through `--dsl-terminal-output-max-height` instead) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). ## Read rendering diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 77830a1f16..83fdaa927c 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -18,7 +18,7 @@ ## 终端输出 -`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;运行中且提供了 `output` 的块会在 running 状态下渲染这段实时文本(未提供 output 则保持历史上的仅提示行画面);由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过 `anser`(打进本包浏览器产物)解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)。 +`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件——或写入 `copyText` 覆盖值,提供它还会让控件在任何输出出现之前就保持渲染。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;运行中且提供了 `output` 的块会在 running 状态下渲染这段实时文本(未提供 output 则保持历史上的仅提示行画面);由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。`runStateDot: false` 为外围行已携带同一状态的宿主省去状态点及其隐藏标签;此类宿主通过 `--dsl-terminal-gutter` 收回状态点落区。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断;把 `--dsl-terminal-command-whitespace` 重绑为 `pre-wrap` 则让长命令完整换行。ANSI 转义序列通过 `anser`(打进本包浏览器产物)解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行(`--dsl-terminal-line-whitespace: pre-wrap` 让宿主选择换行且不再横向滚动);超过 `maxLines`(默认 16;传 Infinity 可禁用折叠,供改用 `--dsl-terminal-output-max-height` 限高滚动的宿主)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)。 ## Read 渲染 diff --git a/packages/client/ui-primitives/src/TerminalBlock.module.css b/packages/client/ui-primitives/src/TerminalBlock.module.css index 7a74327c74..318df9df3a 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.module.css +++ b/packages/client/ui-primitives/src/TerminalBlock.module.css @@ -110,15 +110,18 @@ color: var(--dsw-alias-label-tertiary); } -/* `pre`, not `nowrap`: the prompt row renders the command verbatim, and - `nowrap` collapses the repeated spaces, tabs, and alignment of an indented - continuation. Both hold the single row and the ellipsis. */ +/* Default `pre`, not `nowrap`: the prompt row renders the command verbatim, + and `nowrap` would collapse the repeated spaces, tabs, and alignment of an + indented continuation. Hosts opt into wrapping long commands by rebinding + the variable to `pre-wrap`; the ellipsis then simply never engages, and + overflow-wrap is inert while white-space stays `pre`. */ .command { min-width: 0; color: var(--dsw-alias-label-primary); overflow: hidden; text-overflow: ellipsis; - white-space: pre; + white-space: var(--dsl-terminal-command-whitespace, pre); + overflow-wrap: anywhere; } /* Capped to the prompt's line height (Pill's own 24px height would exceed a @@ -176,10 +179,14 @@ margin: 6px; } -/* No wrapping, no word-break: alignment is the payload of terminal output. */ +/* Default `pre`: alignment is the payload of terminal output. Hosts opt + into wrapping long lines by rebinding the variable to `pre-wrap`, which + also keeps the output region from ever scrolling sideways; overflow-wrap + is inert while white-space stays `pre`. */ .line { min-height: var(--dsl-terminal-line-height); - white-space: pre; + white-space: var(--dsl-terminal-line-whitespace, pre); + overflow-wrap: anywhere; } .expand { diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index 8f87db9741..34eab4e5d0 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -61,6 +61,18 @@ export interface TerminalBlockProps { running?: boolean | undefined /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */ maxLines?: number | undefined + /** + * Copy-control payload override; the control copies the raw output when + * absent. Supplying it also keeps the control rendered before any output + * exists — a command is copyable before it prints. + */ + copyText?: string | undefined + /** + * Draw the run-state dot and its assistive label in the card gutter + * (default true). Hosts whose surrounding row already carries the same + * state omit both and reclaim the gutter via `--dsl-terminal-gutter`. + */ + runStateDot?: boolean | undefined /** Extra class merged onto the wrapper (callers position; this component draws). */ className?: string | undefined /** Localized display copy supplied by the owning render site. */ @@ -155,6 +167,8 @@ export function TerminalBlock({ signal, running = false, maxLines = DEFAULT_TERMINAL_MAX_LINES, + copyText, + runStateDot = true, className, labels, }: TerminalBlockProps) { @@ -166,7 +180,7 @@ export function TerminalBlock({ // newline (`line\n\x1b[0m`) leaves the string not ending in one while still // producing a last line with nothing visible in it. A genuinely blank final // line — the double newline — survives, since it has a real empty line before - // the terminator. The copy control still copies `text` untouched. + // the terminator. The copy control's payload is unaffected. const lines = useMemo(() => { const parsed = parseAnsiLines(text) const last = parsed[parsed.length - 1] @@ -175,9 +189,9 @@ export function TerminalBlock({ return terminated ? parsed.slice(0, -1) : parsed }, [text]) const [expanded, setExpanded] = useState(false) - // The raw output, never the rendered tree: the prompt line and the status pill - // are chrome the user did not run. - const { copied, onCopy } = useCopyFeedback(text) + // The raw output (or the caller's override), never the rendered tree: the + // prompt line and the status pill are chrome the user did not run. + const { copied, onCopy } = useCopyFeedback(copyText ?? text) const onToggle = useCallback(() => { setExpanded(value => !value) }, []) @@ -202,14 +216,14 @@ export function TerminalBlock({
        - {state.label} + {runStateDot && {state.label}} {commandLines.map((line, index) => (
        {/* One dot for the card, on the first row: the exit status the view carries is the whole call's, and bash reports no per-command status, so a dot per row would assert a per-line outcome nothing here knows. */} - {index === 0 && } + {index === 0 && runStateDot && } {/* The cwd labels the CALL, so only its first row carries it. The view knows one working directory — where the call started — and a later line may well run somewhere else (a `cd` in the @@ -224,7 +238,7 @@ export function TerminalBlock({ ))}
        {status !== undefined && {status}} - {(!running || output !== undefined) && !empty && ( + {(copyText !== undefined || ((!running || output !== undefined) && !empty)) && ( diff --git a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx index 886661b396..b4e6d7b151 100644 --- a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx @@ -98,6 +98,22 @@ describe('TerminalBlock states', () => { expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('') }) + it('copyText overrides the copy payload and keeps the control before any output', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const view = render() + // No output yet, but the command is already copyable. + const button = view.getByRole('button', { name: '复制' }) + await act(async () => { fireEvent.click(button) }) + expect(writeText).toHaveBeenCalledWith('npm run build --verbose') + }) + + it('omits the run-state dot and its assistive label when the host carries the state', () => { + const view = render() + expect(view.container.querySelector('[class*="runState"]')).toBeNull() + expect(view.queryByText('运行中')).toBeNull() + }) + it('running with supplied output streams the live text and keeps it copyable', () => { const view = render() expect(view.getByText('partial')).toBeTruthy() diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 7456c19675..3d1b41e366 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -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/ui-subagent/README.md -README.md: d4a804a94b0f2cb295fd29c09c7f32385e423495 -README.zh.md: 3cec2109cbb5f85617cf69155c5582ee43496f09 +README.md: 67240628bf91e28e3b1a226f364f2d549b28695c +README.zh.md: 1c45ce74e4c04edef22a6a59b6a041c6728cbe9a diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index d4a804a94b..67240628bf 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Web subagent feature owner: contributes current-title lineage navigation to `conversation.session.header.lineage`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.inputTriggers`. +Web subagent feature owner: contributes current-title lineage navigation to `conversation.session.header.lineage`, the root-session descendant-count entry to `conversation.session.header.actions` (ordered after the task list), reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.inputTriggers`. -The header lineage renderer reads `subagentsByParent` and session summaries through the standard `useSessions` hook. On an ordinary session it preserves the current title and, when descendants exist, appends `/` plus the descendant-count dropdown before the header action row. Every subagent breadcrumb uses the count trigger's 12px type scale and combines its title with a fixed double chevron; the current breadcrumb keeps primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, ArrowDown remains the keyboard entry path, and clicking an ancestor cancels a pending menu and navigates upward without opening it. Each catalog enables sibling switching and bolds its selected row; a catalog label overrides the session-summary title, and every switcher whose catalog has not loaded requests it automatically. Only the current subagent appends its own descendant-count dropdown. Long breadcrumb titles truncate while the chevron remains visible. The count trigger covers the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionRuntime.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. +The header lineage renderer reads `subagentsByParent` and session summaries through the standard `useSessions` hook. On an ordinary session the lineage slot stays empty; when descendants exist, the descendant-count dropdown renders in the header actions band after the task list, with no breadcrumb separator. Every subagent breadcrumb uses the count trigger's 12px type scale and combines its title with a fixed double chevron; the current breadcrumb keeps primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, ArrowDown remains the keyboard entry path, and clicking an ancestor cancels a pending menu and navigates upward without opening it. Each catalog enables sibling switching and bolds its selected row; a catalog label overrides the session-summary title, and every switcher whose catalog has not loaded requests it automatically. Only the current subagent appends its own descendant-count dropdown. Long breadcrumb titles truncate while the chevron remains visible. The count trigger covers the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionRuntime.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable and the child is not running, with copy explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer, whose input and Send action are disabled but whose independent Stop stays usable, and the takeover returns once it stops. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes prompts through `subagent.prompt`: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, while an independent Stop routes through `subagent.interrupt`. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) and the [current-turn interrupt Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md). diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 3cec2109cb..1c45ce74e4 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -Web subagent 功能 owner:向 `conversation.session.header.lineage` 贡献当前 title 谱系导航,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.inputTriggers` 的既有 `@` 引用 source。 +Web subagent 功能 owner:向 `conversation.session.header.lineage` 贡献当前 title 谱系导航,向 `conversation.session.header.actions` 贡献根会话的后代数量入口(排在任务列表之后),向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.inputTriggers` 的既有 `@` 引用 source。 -页头谱系 renderer 通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。普通会话保留当前 title,并在存在后代时,于页头操作行之前追加 `/` 和后代数量下拉菜单。每一级 subagent 面包屑都使用数量触发器的 12px 字号,并将 title 与固定显示的双向箭头组合成一个控件;当前面包屑保留主标签颜色和 500 字重,祖先面包屑则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录,ArrowDown 保留为键盘入口;点击祖先面包屑会取消待打开的菜单并仅向上导航。每个目录都可切换 sibling,并会加粗选中行;目录 label 优先于会话摘要 title,尚未加载目录的每个切换器都会自动请求该目录。只有当前 subagent 会追加自己的后代数量下拉菜单。过长的面包屑 title 会截断,而双向箭头始终保留。数量触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionRuntime.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 +页头谱系 renderer 通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。普通会话的谱系槽保持为空;存在后代时,后代数量下拉菜单渲染在页头操作区里、任务列表之后,不带任何面包屑分隔符。每一级 subagent 面包屑都使用数量触发器的 12px 字号,并将 title 与固定显示的双向箭头组合成一个控件;当前面包屑保留主标签颜色和 500 字重,祖先面包屑则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录,ArrowDown 保留为键盘入口;点击祖先面包屑会取消待打开的菜单并仅向上导航。每个目录都可切换 sibling,并会加粗选中行;目录 label 优先于会话摘要 title,尚未加载目录的每个切换器都会自动请求该目录。只有当前 subagent 会追加自己的后代数量下拉菜单。过长的面包屑 title 会截断,而双向箭头始终保留。数量触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionRuntime.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器,并以文案说明恢复路径;此类 child 仍在运行期间,selector 会让位给普通编辑器——其输入区与 Send 操作被禁用,但独立的 Stop 保持可用,停止后只读替代恢复。确切 parent 存活时,可继续 child 保留普通输入 chrome,其会话通过 `subagent.prompt` 路由提示词:child 运行期间输入和 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox,而独立的 Stop 经由 `subagent.interrupt` 路由。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md) 与[当前轮次中断 Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md) 规定。 diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css index 68b16940d9..c047a09c04 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css @@ -55,12 +55,6 @@ flex: none; } -.separator { - color: var(--dsw-alias-label-caption); - font-size: 14px; - line-height: 20px; -} - .activitySlot { display: inline-flex; flex: none; diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 2660ba3a0f..faacc43061 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -439,8 +439,6 @@ function CatalogRows({ interface CatalogDropdownSharedProps extends SubagentCatalogInjected { /** Session whose direct catalog roots the tree. */ rootSessionId: SessionId - /** Whether an ordinary title needs a breadcrumb separator before its count. */ - separator?: boolean useSessions: SubagentHeaderLineageProps['useSessions'] t: TranslateNS } @@ -482,7 +480,7 @@ function catalogMenuPosition(trigger: HTMLButtonElement): CSSProperties { /** One trigger-plus-tree dropdown over the catalog rooted at `rootSessionId`. */ function CatalogDropdown({ - rootSessionId, currentSessionId, displayTitle, openTitle, variant, separator = false, + rootSessionId, currentSessionId, displayTitle, openTitle, variant, useSessions, openChild, refresh, setCatalogOpen, t, }: CatalogDropdownProps) { const ancestorSwitcher = variant === 'switcher' && openTitle !== undefined @@ -731,7 +729,6 @@ function CatalogDropdown({ onMouseEnter={scheduleHoverOpen} onMouseLeave={scheduleHoverClose} > - {separator && /} +
        + +
        + ) +} + +describe('useDismissOnOutsidePointer', () => { + it('closes on an outside pointerdown and ignores inside ones', () => { + const setOpen = vi.fn() + const view = render() + fireEvent.pointerDown(view.getByText('inside')) + expect(setOpen).not.toHaveBeenCalled() + fireEvent.pointerDown(view.getByTestId('outside')) + expect(setOpen).toHaveBeenCalledWith(false) + }) + + it('attaches no listener while closed and detaches on close', () => { + const setOpen = vi.fn() + const view = render() + fireEvent.pointerDown(view.getByTestId('outside')) + expect(setOpen).not.toHaveBeenCalled() + + view.rerender() + view.rerender() + fireEvent.pointerDown(view.getByTestId('outside')) + expect(setOpen).not.toHaveBeenCalled() + }) + + it('ignores a pointerdown whose target is not a DOM node', () => { + const setOpen = vi.fn() + render() + const event = new Event('pointerdown', { bubbles: true }) + // Shadow the prototype getter so the listener sees a non-Node target. + Object.defineProperty(event, 'target', { get: () => ({}) }) + document.dispatchEvent(event) + expect(setOpen).not.toHaveBeenCalled() + }) +}) diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts index 66a4a906b6..3ba16a832b 100644 --- a/packages/shell/tool-bash/tests/observe.spec.ts +++ b/packages/shell/tool-bash/tests/observe.spec.ts @@ -199,6 +199,37 @@ describe('background bash observation', () => { expect(ended[0]).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) }) + it('a throwing end() is swallowed by the settlement-mapping catch', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin({ + name: 'end-throwing-activities-probe', + apply(child: Context) { + child.provide('activities', { + open: () => ({ + id: 'bash-1', + append() {}, + updateDetail() {}, + end() { throw new Error('end boom') }, + }), + }) + }, + }) + await ctx.plugin(LocalSubprocessRuntime) + ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + const warn = vi.fn() + ctx.logger.warn = warn as never + await ctx.plugin(ToolBash, { activityPollMs: 25 }) + await call(ctx, { command: 'echo settled', description: 'test command', run_in_background: true }) + await until(() => warn.mock.calls.some(args => String(args[0]).includes('activity settlement mapping')) ? true : undefined) + }) + it('a throwing observation registry never breaks the background call', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/shell/tool-pwsh/tests/observe.spec.ts b/packages/shell/tool-pwsh/tests/observe.spec.ts index 0152a710d8..60b7d90b82 100644 --- a/packages/shell/tool-pwsh/tests/observe.spec.ts +++ b/packages/shell/tool-pwsh/tests/observe.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -168,6 +168,39 @@ describe('background pwsh observation', () => { expect(String(row.id)).toContain('pwsh') }) + it('a throwing end() is swallowed by the settlement-mapping catch', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin({ + name: 'end-throwing-activities-probe', + apply(child: Context) { + child.provide('activities', { + open: () => ({ + id: 'pwsh-1', + append() {}, + updateDetail() {}, + end() { throw new Error('end boom') }, + }), + }) + }, + }) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakePwsh) + await ctx.plugin(ToolPwsh, { activityPollMs: 5 }) + const pwsh = ctx.shell as FakePwsh + const warn = vi.fn() + ctx.logger.warn = warn as never + const handle = observableProcess({ stdout: { text: '' }, stderr: { text: '' } }) + pwsh.backgroundHandler = () => handle.proc + await call(ctx, { command: 'Get-Doom', description: 'test command', run_in_background: true }) + handle.finish() + await until(() => warn.mock.calls.some(args => String(args[0]).includes('activity settlement mapping')) ? true : undefined) + }) + it('a backend without observed readers still yields a status-only process', async () => { const { ctx, pwsh } = await setup() const scripted = observableProcess() From 053979cdecbf7a5de108933e46a6048825f556cd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 21:40:44 +0800 Subject: [PATCH 010/284] test(activity): cover the owner arm and the pwsh outer observation catch The coverage lanes flag four uncovered locations after the master merge: the spec.owner spread in tool-bash/tool-pwsh observe, the workflow mirror's owner spread, and tool-pwsh's outer observation catch. The covering tests existed only on the stacked job-kill branch; give this branch its own: an owned background run per shell asserting the fenced ownerSession row, the owned workflow mirror run, and a throwing open() through the pwsh outer catch. The windows-only file-reference-local gap reproduces on master's own windows lane at the merge point and stays upstream. --- .../shell/tool-bash/tests/observe.spec.ts | 27 ++++++++ .../shell/tool-pwsh/tests/observe.spec.ts | 61 +++++++++++++++++++ .../tests/activity-mirror.spec.ts | 23 +++++++ 3 files changed, 111 insertions(+) diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts index 1a100cf833..714558227f 100644 --- a/packages/shell/tool-bash/tests/observe.spec.ts +++ b/packages/shell/tool-bash/tests/observe.spec.ts @@ -4,9 +4,11 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' @@ -262,3 +264,28 @@ describe('background bash observation', () => { expect(read.snapshot.status).toBe('completed') }) }) + +describe('owned background observation', () => { + it('mirrors an owned background run under the owning session', async () => { + const ctx = await setup() + const owner = { + id: SessionId('observe-owner'), + session: { id: SessionId('observe-owner'), header: { cwd: process.cwd() } }, + status: 'idle', + ctx, + } as unknown as Agent + ctx.agents.register(owner) + await ctx.tools.execute({ + signal: testToolSignal, + callId: ToolCallId('observe-owned-1'), + name: 'bash', + arguments: { command: 'echo owned', description: 'test command', run_in_background: true }, + agent: owner, + }) + const job = ctx.jobs.list(owner)[0] + expect(job).toBeDefined() + const row = await until(() => ctx.activities.list(owner).find(item => item.correlation?.jobId === job!.id)) + expect(row.ownerSession).toBe(owner.id) + await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined) + }) +}) diff --git a/packages/shell/tool-pwsh/tests/observe.spec.ts b/packages/shell/tool-pwsh/tests/observe.spec.ts index 97f13b064e..c9024203fb 100644 --- a/packages/shell/tool-pwsh/tests/observe.spec.ts +++ b/packages/shell/tool-pwsh/tests/observe.spec.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' @@ -216,3 +218,62 @@ describe('background pwsh observation', () => { expect(ctx.activities.get(row.id).outputTotal).toBe(0) }) }) + +describe('owned and degraded observation (pwsh)', () => { + it('mirrors an owned background run under the owning session', async () => { + const { ctx, pwsh } = await setup() + const owner = { + id: SessionId('pwsh-observe-owner'), + session: { id: SessionId('pwsh-observe-owner'), header: { cwd: process.cwd() } }, + status: 'idle', + ctx, + } as unknown as Agent + ctx.agents.register(owner) + const scripted = observableProcess() + pwsh.backgroundHandler = () => scripted.proc + await ctx.tools.execute({ + signal: testToolSignal, + callId: ToolCallId('pwsh-observe-owned'), + name: 'pwsh', + arguments: { command: 'Get-Slow', description: 'test command', run_in_background: true }, + agent: owner, + }) + const job = ctx.jobs.list(owner)[0] + expect(job).toBeDefined() + const row = await until(() => ctx.activities.list(owner).find(item => item.correlation?.jobId === job!.id)) + expect(row.ownerSession).toBe(owner.id) + scripted.finish() + await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined) + }) + + it('swallows a throwing observation registry and keeps the job alive', async () => { + // The FakePwsh composition with a synchronously throwing open(): the + // failure lands in the outer observation catch and the job runs on. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin({ + name: 'broken-activities-probe', + apply(child: Context) { + child.provide('activities', { open() { throw new Error('observation boom') } }) + }, + }) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakePwsh) + const warn = vi.fn() + ctx.logger.warn = warn as never + await ctx.plugin(ToolPwsh, { activityPollMs: 5 }) + const pwsh = ctx.shell as FakePwsh + const scripted = observableProcess() + pwsh.backgroundHandler = () => scripted.proc + await call(ctx, { command: 'Get-Slow', description: 'test command', run_in_background: true }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation unavailable')) + const job = ctx.jobs.list()[0] + expect(job?.status).toBe('running') + scripted.finish() + await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) + }) +}) diff --git a/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts index 50788d453f..8f82cb2841 100644 --- a/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts +++ b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import { SessionId } from '@deepseek-ai/dsh-session' import type { WorkflowRun, WorkflowRunId, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import { createWorkflowActivityMirror } from '../src/activity.ts' @@ -45,6 +48,26 @@ describe('workflow activity mirror', () => { expect(ctx.activities.get(row!.id).status).toBe('completed') }) + + it('opens an owned run under the owning agent session', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalActivityRegistry) + const owner = { + id: SessionId('workflow-owner'), + session: { id: SessionId('workflow-owner') }, + status: 'idle', + ctx, + } as unknown as Agent + ctx.agents.register(owner) + const mirror = createWorkflowActivityMirror(ctx) + const { run } = runStub('run-owned') + mirror.start(run, owner) + // The owned row is fenced to its owner's view. + const row = ctx.activities.list(owner)[0] + expect(row?.ownerSession).toBe(owner.id) + mirror.finish(run.id, 'completed') + }) it('maps cancelled and error stops, and ends an abandoned run as killed', async () => { const ctx = new Context() await ctx.plugin(LocalActivityRegistry) From 8aa2dfa3a518844a5202092ad00587b63ea93639 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 31 Aug 2026 11:40:11 +0800 Subject: [PATCH 011/284] fix(ci): exempt the activity controller from flattened client-face dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The master merge brought the published dependency policy: a package declaring dsh.client flattens its non-Cordis peers unless excluded. api-activity-controller is the same species as the excluded session and workspace controllers — a Host Remote controller whose host half relays real runtime peers (dsh-activity for the registry service and its brand constructor at the wire boundary, dsh-invariants for the companion) — so it joins CLIENT_FACE_EXCLUDE rather than dropping those peers. --- scripts/package-dependency-policy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 63054fd183..15b481383c 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -5,6 +5,7 @@ const CLIENT_FACE_INCLUDE: readonly string[] = [] /** Packages exempted from automatic Client/Host treatment despite declaring `dsh.client`. */ const CLIENT_FACE_EXCLUDE: readonly string[] = [ + '@deepseek-ai/dsh-api-activity-controller', '@deepseek-ai/dsh-api-session-controller', '@deepseek-ai/dsh-api-workspace-controller', ] From 48939ba7426d80f9df95a8cc8ff89251634e1d5a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 31 Aug 2026 11:49:00 +0800 Subject: [PATCH 012/284] test(ci): pin the activity controller in the Host relay roster The roster spec mirrors CLIENT_FACE_EXCLUDE; the activity controller joined the excluded Host relays in the previous commit. --- scripts/verify-package-dependencies.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index 4294cff84b..26263fe018 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -123,6 +123,7 @@ function hostRuntimeFixture(): { describe('package dependency scope', () => { it('keeps the measured Host relay roster explicit', () => { expect(PACKAGE_DEPENDENCY_POLICY.clientFaceExclude).toEqual([ + '@deepseek-ai/dsh-api-activity-controller', '@deepseek-ai/dsh-api-session-controller', '@deepseek-ai/dsh-api-workspace-controller', ]) From e580c0b3334f1774cd23f6d4728966bd196ccb5b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 17:45:43 +0800 Subject: [PATCH 013/284] feat(jobs): fold the streaming observation record into the job registry A job can now declare record: true and stream raw output into a bounded per-job ring through the RunningJob face its starter receives: readRecord serves any number of observers at absolute byte offsets without touching the consuming model cursor or notice state, onOutput signals advancement, and updateDetail publishes a live progress line into every snapshot. Job settlement is the record's only end: it trims retention to the settled cap and fires the final output signal, so producers no longer reconcile two terminal states. pumpJobOutput ports the offset-reader pump beside the seam. This is the first step of merging the standalone dsh-activity observation plane into jobs. --- packages/jobs/jobs-local/README.md | 13 +- packages/jobs/jobs-local/src/index.ts | 257 ++++++++++++++- packages/jobs/jobs-local/tests/jobs.spec.ts | 329 +++++++++++++++++++- packages/jobs/jobs/README.md | 13 +- packages/jobs/jobs/src/index.ts | 54 +++- packages/jobs/jobs/src/invariant.ts | 9 + packages/jobs/jobs/src/pump.ts | 96 ++++++ packages/jobs/jobs/src/types.ts | 125 +++++++- packages/jobs/jobs/tests/invariant.spec.ts | 7 + packages/jobs/jobs/tests/service.spec.ts | 103 +++++- 10 files changed, 961 insertions(+), 45 deletions(-) create mode 100644 packages/jobs/jobs/src/pump.ts diff --git a/packages/jobs/jobs-local/README.md b/packages/jobs/jobs-local/README.md index cfbda98ccf..2da0a0b339 100644 --- a/packages/jobs/jobs-local/README.md +++ b/packages/jobs/jobs-local/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-jobs-local` runs background jobs inside the harness process: work keeps running while the agent moves on, and the owning agent can read, wait on, list, and cancel it, with completion delivered as an in-session notice when `dsh-tool-jobs` is also mounted. It implements the `dsh-jobs` contract with in-memory records handed out as fresh snapshots, never live state. A per-owner concurrency limit (default 10) bounds how many jobs one agent can have running or stopping at once; jobs die with the harness process and are not durable across restarts. +`dsh-jobs-local` runs background jobs inside the harness process: work keeps running while the agent moves on, and the owning agent can read, wait on, list, and cancel it, with completion delivered as an in-session notice when `dsh-tool-jobs` is also mounted. It implements the `dsh-jobs` contract with in-memory records handed out as fresh snapshots, never live state. A per-owner concurrency limit (default 10) bounds how many jobs one agent can have running or stopping at once, and a job that declares a record keeps a bounded in-memory output ring (256 KiB live, trimmed to 16 KiB at settlement by default) that observers read at absolute offsets; jobs die with the harness process and are not durable across restarts. ## Table of Contents @@ -42,8 +42,10 @@ Loading the plugin registers `ctx.jobs`; `maxConcurrentJobsPerOwner` is optional | Field | Default | Meaning | |---|---|---| | `maxConcurrentJobsPerOwner` | `10` | Maximum `running` plus `stopping` jobs per exact owner, or in the shared unowned bucket | +| `retainBytes` | `262144` | Live record retention per job, in UTF-8 bytes | +| `settledRetainBytes` | `16384` | Record retention kept after a job settles, in UTF-8 bytes | -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-jobs-local) is the exhaustive source for the accepted field. +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-jobs-local) is the exhaustive source for the accepted fields. ### What each owner gets @@ -69,7 +71,8 @@ This section explains the design decisions behind the registry and points at the ### Design philosophy -- **In-memory records, fresh snapshots.** `LocalJobRegistry` keeps one `TrackedTask` per job and projects a new read-only snapshot per call; callers never receive live state. +- **In-memory records, fresh snapshots.** `LocalJobRegistry` keeps one `TrackedTask` per job — lifecycle state plus the optional record ring — and projects a new read-only snapshot or chunk copy per call; callers never receive live state. +- **Retention is bounded per record.** Appends past the live cap drop the oldest retained chunks (a single oversized chunk keeps its UTF-8-safe tail); a reader below the retained window gets a lossy read, never an error, and settlement trims to the settled cap. - **Owner-relative layers, one process-wide registry.** Controllers, completion listeners, and change observers are filed into the scope that registered them (`ScopedLayers`), and reads union the global layer with the owner's scope chain — so one preset's job controls never hold `start()` open for an agent whose own composition loads none, and a settlement reaches only the listeners its owner's composition registered. - **Preflight before start.** `start()` checks controller service, spec validity, live ownership, and capacity before invoking the producer, so a rejection leaves no job id or execution resource; registration commits without a later failable step. - **First-wins settlement, completion last.** The earliest terminal outcome records once, releases waiters, and notifies listeners once with per-listener containment; completion is announced after the record is committed and the visible-set change published, because a reporter may open a model turn synchronously. @@ -84,11 +87,11 @@ This section explains the design decisions behind the registry and points at the ### Scope layers -`attachController`, `onJobDone`, and `onJobsChanged` register into the calling context's scope layer. The controller question (`servesOwner`) and listener delivery (`listenersFor`, `changedFor`) walk the same chain: global layer first, then each scoped layer along the owner's chain. Registrations are anonymous tokens so duplicate labels stay independently disposable. +`attachController`, `onJobDone`, `onJobsChanged`, and `onOutput` register into the calling context's scope layer. The controller question (`servesOwner`) and listener delivery (`listenersFor`, `changedFor`, `outputFor`) walk the same chain: global layer first, then each scoped layer along the owner's chain. Registrations are anonymous tokens so duplicate labels stay independently disposable. ### Admission and settlement -`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` marks a job reported when waiters are pending, resolves every waiter, records the terminal snapshot, announces the visible-set change, then notifies completion listeners. Pending waits mark the job reported before listeners run so completion reporters do not duplicate notices; a teardown cancel marks it for the same reason — nothing will read a notice addressed to an owner being destroyed. +`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` marks a job reported when waiters are pending, trims a declared record to the settled cap, resolves every waiter, records the terminal snapshot, announces the visible-set change and the record's final output signal, then notifies completion listeners. Pending waits mark the job reported before listeners run so completion reporters do not duplicate notices; a teardown cancel marks it for the same reason — nothing will read a notice addressed to an owner being destroyed. ### Teardown diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index 8c71ae1b79..15cb5336b0 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -1,7 +1,8 @@ /** * Process-local provider for the background-job capability seam - * (`ctx.jobs`). It keeps every record in memory and hands out fresh - * snapshots, never live state. + * (`ctx.jobs`). It keeps every record — lifecycle state plus the optional + * bounded output ring — in memory and hands out fresh snapshots and chunk + * copies, never live state. * * Registrations outlive producer and controller fibers. Agent or service * disposal cancels live work and awaits compliant producers; a throwing @@ -17,8 +18,8 @@ import type { ScopeLayer } from '@deepseek-ai/dsh-scope' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' import type { - JobDoneListener, JobKind, JobOutcome, JobRead, JobSnapshot, JobStart, JobStatus, - JobsChangedListener, + JobAppendOptions, JobChannel, JobDoneListener, JobKind, JobOutcome, JobOutputListener, JobRead, + JobRecordChunk, JobRecordRead, JobSnapshot, JobStart, JobStatus, JobsChangedListener, RunningJob, } from '@deepseek-ai/dsh-jobs' /** Timeout code that distinguishes a bounded wait from caller cancellation. */ @@ -27,6 +28,12 @@ export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT' /** Default maximum number of active jobs in one exact-owner bucket. */ const DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER = 10 +/** Default live record retention per job, in UTF-8 bytes. */ +const DEFAULT_RETAIN_BYTES = 256 * 1024 + +/** Default record retention kept after settlement, in UTF-8 bytes. */ +const DEFAULT_SETTLED_RETAIN_BYTES = 16 * 1024 + /** Configuration for the process-local job registry. */ export interface Config { /** @@ -34,6 +41,45 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number + /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number +} + +/** One retained record ring entry; `bytes` caches the chunk's UTF-8 length. */ +interface RingChunk { + at: number + text: string + bytes: number + channel?: JobChannel + gapBefore?: true +} + +/** The bounded output ring behind one job's declared record. */ +interface RecordState { + /** Retained chunks in offset order; offsets stay absolute across eviction. */ + chunks: RingChunk[] + /** Sum of the retained chunks' byte lengths. */ + retainedBytes: number + /** Total UTF-8 bytes ever appended. */ + total: number + /** Offset of the oldest retained byte (equals {@link total} when nothing is retained). */ + earliest: number +} + +/** + * Producer-written state shared between the {@link RunningJob} face and the + * registered record: the starter call writes through it before the commit, + * the same object serves the job for its whole life afterwards. + */ +interface ProducerState { + /** Bounded output ring; undefined when the spec declared no record. */ + record: RecordState | undefined + /** Live progress detail until settlement replaces it with the terminal one. */ + detail: string | undefined + /** The committed registry record; undefined exactly during the starter call. */ + job: TrackedTask | undefined } /** The registry's mutable per-job record (never handed out — see {@link LocalJobRegistry.snapshot}). */ @@ -47,7 +93,8 @@ interface TrackedTask { cancel: (reason?: string) => void readOutput: (() => string) | undefined status: JobStatus - detail: string | undefined + /** Producer-shared record ring and detail line. */ + state: ProducerState output: string | undefined startedAt: number finishedAt: number | undefined @@ -67,19 +114,39 @@ function isTerminal(status: JobStatus): boolean { return status === 'completed' || status === 'killed' || status === 'failed' } +/** + * The UTF-8-safe tail of `text` no longer than `maxBytes`: the byte cut + * advances past continuation bytes so the surviving text never starts inside + * a code point. + * @param text - the oversized chunk text. + * @param maxBytes - positive byte budget for the surviving tail. + * @returns the surviving tail and its exact byte length. + */ +function utf8Tail(text: string, maxBytes: number): { text: string; bytes: number } { + const raw = Buffer.from(text, 'utf8') + let start = raw.length - maxBytes + // The loop bound proves the index is in range; the assertion only + // discharges noUncheckedIndexedAccess. + while (start < raw.length && ((raw[start] as number) & 0xC0) === 0x80) start += 1 + const tail = raw.subarray(start) + return { text: tail.toString('utf8'), bytes: tail.length } +} + /** * One scope's contributions: the job controllers attached from it and the - * completion listeners registered there. Both tables are anonymous because a - * contribution is identified by its own disposer, never by a name a second - * registrant could shadow. + * completion, change, and record-output listeners registered there. All + * tables are anonymous because a contribution is identified by its own + * disposer, never by a name a second registrant could shadow. */ class JobLayer implements ScopeLayer { readonly controllers = new AnonymousEntries() readonly listeners = new AnonymousEntries() readonly changed = new AnonymousEntries() + readonly output = new AnonymousEntries() isEmpty(): boolean { return this.controllers.isEmpty() && this.listeners.isEmpty() && this.changed.isEmpty() + && this.output.isEmpty() } } @@ -95,10 +162,24 @@ export class LocalJobRegistry extends JobRegistry { .min(1) .max(Number.MAX_SAFE_INTEGER) .default(DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER), + retainBytes: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_RETAIN_BYTES), + settledRetainBytes: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_SETTLED_RETAIN_BYTES), }) /** Schemastery-defaulted active-job limit. */ private readonly maxConcurrentJobsPerOwner: number + /** Schemastery-defaulted live record retention cap. */ + private readonly retainBytes: number + /** Schemastery-defaulted settled record retention cap. */ + private readonly settledRetainBytes: number private store = new Map() private counters = new Map() /** @@ -122,8 +203,10 @@ export class LocalJobRegistry extends JobRegistry { constructor(ctx: Context, config: Config) { super(ctx) - // Schemastery validates and fills the default before constructing the service. + // Schemastery validates and fills the defaults before constructing the service. this.maxConcurrentJobsPerOwner = (config as Required).maxConcurrentJobsPerOwner + this.retainBytes = (config as Required).retainBytes + this.settledRetainBytes = (config as Required).settledRetainBytes this.selfCtx = ctx ctx.effect(() => () => this.disposeAll(), 'jobs teardown') } @@ -147,10 +230,23 @@ export class LocalJobRegistry extends JobRegistry { ) } - const hooks = spec.run() + // The id is issued before the starter runs so the producer face can carry + // it; a throwing starter still leaves nothing registered — its ordinal is + // simply skipped. const count = (this.counters.get(spec.kind) ?? 0) + 1 this.counters.set(spec.kind, count) const id = JobId(`${spec.kind}-${count}`) + const state: ProducerState = { + record: spec.record === true ? { chunks: [], retainedBytes: 0, total: 0, earliest: 0 } : undefined, + detail: undefined, + job: undefined, + } + const handle: RunningJob = { + id, + append: (text, options) => { this.appendRecord(id, state, text, options) }, + updateDetail: (detail) => { this.updateDetail(state, detail) }, + } + const hooks = spec.run(handle) let markSettled!: () => void const settled = new Promise((resolve) => { markSettled = resolve }) @@ -163,7 +259,7 @@ export class LocalJobRegistry extends JobRegistry { cancel: hooks.cancel.bind(hooks), readOutput: hooks.readOutput?.bind(hooks), status: 'running', - detail: undefined, + state, output: undefined, startedAt: Date.now(), finishedAt: undefined, @@ -173,6 +269,10 @@ export class LocalJobRegistry extends JobRegistry { waiters: 0, waitResolvers: new Set(), } + // Binding the shared producer state is the commit: writes staged inside + // the starter are already in `state`, and every later handle call reaches + // the registered record for its terminal checks and observer signals. + state.job = job this.store.set(id, job) void hooks.done.then( @@ -294,6 +394,37 @@ export class LocalJobRegistry extends JobRegistry { ) } + readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead { + const job = this.expect(id) + this.assertAccess(job, caller) + if (!Number.isSafeInteger(from) || from < 0) { + throw new Error(`invalid record read offset: expected a non-negative safe integer, got ${JSON.stringify(from)}`) + } + const record = job.state.record + if (record === undefined) { + throw new Error(`job ${job.id} declared no output record`) + } + const chunks: JobRecordChunk[] = [] + for (const chunk of record.chunks) { + if (chunk.at + chunk.bytes <= from) continue + chunks.push({ + at: chunk.at, + text: chunk.text, + ...chunk.channel !== undefined ? { channel: chunk.channel } : {}, + ...chunk.gapBefore !== undefined ? { gapBefore: chunk.gapBefore } : {}, + }) + } + return { chunks, next: record.total, lossy: from < record.earliest } + } + + onOutput(listener: JobOutputListener): () => void { + return this.layers.effect( + this.ctx, + layer => layer.output.append(listener), + { label: 'jobs.onOutput()' }, + ) + } + attachController(name: string): () => void { // One token per call keeps duplicate labels independently disposable. const token = Symbol(name) @@ -362,6 +493,7 @@ export class LocalJobRegistry extends JobRegistry { /** Project a fresh read-only snapshot from the mutable record. */ private snapshot(job: TrackedTask): JobSnapshot { const ownerSession = job.owner?.id + const record = job.state.record return { id: job.id, kind: job.kind, @@ -369,10 +501,11 @@ export class LocalJobRegistry extends JobRegistry { ...job.outputLimitBytes !== undefined ? { outputLimitBytes: job.outputLimitBytes } : {}, ...ownerSession !== undefined ? { ownerSession } : {}, status: job.status, - ...job.detail !== undefined ? { detail: job.detail } : {}, + ...job.state.detail !== undefined ? { detail: job.state.detail } : {}, startedAt: job.startedAt, ...job.finishedAt !== undefined ? { finishedAt: job.finishedAt } : {}, reported: job.reported, + ...record !== undefined ? { outputTotal: record.total, outputEarliest: record.earliest } : {}, } } @@ -405,6 +538,95 @@ export class LocalJobRegistry extends JobRegistry { } } + /** The record-output observers that own `owner`'s stream signals, resolved like {@link changedFor}. */ + private *outputFor(owner?: Agent): IterableIterator { + yield* this.layers.global.output.values() + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) yield* layer.output.values() + } + + /** Announce that one job's record advanced (append or settlement), with per-listener containment. */ + private notifyOutput(job: TrackedTask): void { + for (const listener of this.outputFor(job.owner)) { + try { + listener(job.id) + } catch (error: unknown) { + this.selfCtx.logger.warn(`jobs: onOutput listener threw: ${String(error)}`) + } + } + } + + /** + * Append one chunk through a producer face. A chunk against a settled job or + * without a record declaration is logged and dropped; an empty chunk is + * dropped silently. A chunk staged inside the starter call is retained and + * signals no observer — the registration commit publishes it. + */ + private appendRecord(id: JobId, state: ProducerState, text: string, options?: JobAppendOptions): void { + const job = state.job + if (job !== undefined && isTerminal(job.status)) { + this.selfCtx.logger.warn(`jobs: append to settled job ${job.id} dropped`) + return + } + const record = state.record + if (record === undefined) { + this.selfCtx.logger.warn(`jobs: append to job ${id} without a record declaration dropped`) + return + } + if (text.length === 0) return + const bytes = Buffer.byteLength(text, 'utf8') + record.chunks.push({ + at: record.total, + text, + bytes, + ...options?.channel !== undefined ? { channel: options.channel } : {}, + ...options?.gapBefore !== undefined ? { gapBefore: options.gapBefore } : {}, + }) + record.total += bytes + record.retainedBytes += bytes + this.trimRecord(record, this.retainBytes) + if (job !== undefined) this.notifyOutput(job) + } + + /** + * Replace the live detail line through a producer face; a write against a + * settled job is logged and dropped. A write staged inside the starter call + * seeds the registered snapshot and signals no observer. + */ + private updateDetail(state: ProducerState, detail: string): void { + const job = state.job + if (job !== undefined && isTerminal(job.status)) { + this.selfCtx.logger.warn(`jobs: detail update on settled job ${job.id} dropped`) + return + } + state.detail = detail + if (job !== undefined) this.notifyChanged(job.owner) + } + + /** + * Drop retained head chunks until the ring fits `cap`; a single oversized + * chunk keeps only its UTF-8-safe tail with a `gapBefore` marker. Offsets + * stay absolute: `earliest` advances over everything dropped. + */ + private trimRecord(record: RecordState, cap: number): void { + while (record.retainedBytes > cap && record.chunks.length > 1) { + const dropped = record.chunks.shift() + /* v8 ignore next -- the length guard proves shift() returns a chunk; the check only satisfies noUncheckedIndexedAccess. */ + if (dropped === undefined) break + record.retainedBytes -= dropped.bytes + } + const single = record.chunks.length === 1 ? record.chunks[0] : undefined + if (single !== undefined && single.bytes > cap) { + const tail = utf8Tail(single.text, cap) + single.at += single.bytes - tail.bytes + single.text = tail.text + single.bytes = tail.bytes + single.gapBefore = true + record.retainedBytes = tail.bytes + } + record.earliest = record.chunks[0]?.at ?? record.total + } + /** * Record the first terminal outcome, release waiters, then announce * completion. First-wins preserves a teardown force-failure against late @@ -416,16 +638,25 @@ export class LocalJobRegistry extends JobRegistry { private settle(job: TrackedTask, outcome: JobOutcome): void { if (isTerminal(job.status)) return job.status = outcome.status - job.detail = outcome.detail + // A terminal detail replaces the live progress line; without one the last + // progress line stays, still describing what the job was doing. + if (outcome.detail !== undefined) job.state.detail = outcome.detail job.output = outcome.output job.finishedAt = Date.now() if (job.waiters > 0) job.reported = true + // Settlement ends the record: trim to the settled cap before any observer + // reads the terminal snapshot. + const record = job.state.record + if (record !== undefined) this.trimRecord(record, this.settledRetainBytes) const snapshot = this.snapshot(job) const waitResolvers = [...job.waitResolvers] job.waitResolvers.clear() for (const resolveWait of waitResolvers) resolveWait() job.markSettled() this.notifyChanged(job.owner) + // The record stream ends with settlement; the signal follows the committed + // visible-set change and precedes completion listeners, which stay last. + if (record !== undefined) this.notifyOutput(job) if (this.listenersClosed) return for (const listener of this.listenersFor(job.owner)) { try { diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 33ea29ba68..f305c73ba6 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -6,7 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { JobId } from '@deepseek-ai/dsh-jobs' -import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart } from '@deepseek-ai/dsh-jobs' +import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RunningJob } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local' declare module '@deepseek-ai/dsh-jobs' { @@ -55,25 +55,39 @@ async function disposeAgentScope(agent: Agent): Promise { await dispose() } -/** A controllable producer start-spec: settle its `done` on demand, record cancels. */ +/** A controllable producer start-spec: settle its `done` on demand, record cancels, expose the job face. */ function producer(overrides: Partial & JobHooks> = {}) { let settle!: (outcome: JobOutcome) => void let reject!: (error: unknown) => void const cancels: (string | undefined)[] = [] - const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides + const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, record, ...hookOverrides } = overrides const hooks: JobHooks = { cancel(reason) { cancels.push(reason) }, done: new Promise((res, rej) => { settle = res; reject = rej }), ...hookOverrides, } + let started: RunningJob | undefined const spec: JobStart = { kind, label, ...owner !== undefined ? { owner } : {}, ...outputLimitBytes !== undefined ? { outputLimitBytes } : {}, - run: () => hooks, + ...record !== undefined ? { record } : {}, + run: (job) => { + started = job + return hooks + }, + } + return { + spec, + settle, + reject, + cancels, + job(): RunningJob { + if (started === undefined) throw new Error('producer not started') + return started + }, } - return { spec, settle, reject, cancels } } async function harness(config: JobsConfig = {}) { @@ -1135,3 +1149,308 @@ describe('LocalJobRegistry teardown change notifications', () => { expect(seen).toEqual([undefined, undefined, undefined]) }) }) + +describe('LocalJobRegistry record', () => { + it('appends advance absolute offsets; record reads are non-consuming and never mark reported', async () => { + const ctx = await harness() + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('hello ') + p.job().append('world', { channel: 'stderr' }) + const first = ctx.jobs.readRecord(id, 0) + expect(first.lossy).toBe(false) + expect(first.next).toBe(11) + expect(first.chunks).toEqual([ + { at: 0, text: 'hello ' }, + { at: 6, text: 'world', channel: 'stderr' }, + ]) + // A second identical read proves nothing was consumed. + expect(ctx.jobs.readRecord(id, 0)).toEqual(first) + // Resuming from `next` yields nothing until more output arrives. + expect(ctx.jobs.readRecord(id, first.next).chunks).toEqual([]) + p.job().append('!') + expect(ctx.jobs.readRecord(id, first.next).chunks).toEqual([{ at: 11, text: '!' }]) + const snapshot = ctx.jobs.get(id) + expect(snapshot.outputTotal).toBe(12) + expect(snapshot.outputEarliest).toBe(0) + // The observer surface owns no notice state: even a terminal record read + // leaves the completion notice due. + p.settle({ status: 'completed' }) + await tick() + ctx.jobs.readRecord(id, 0) + expect(ctx.jobs.get(id).reported).toBe(false) + }) + + it('staged starter writes surface at the registration commit without observer signals', async () => { + const ctx = await harness() + const outputSignals: string[] = [] + ctx.jobs.onOutput((id) => { outputSignals.push(String(id)) }) + const changes: number[] = [] + ctx.jobs.onJobsChanged(() => { changes.push(1) }) + let settle!: (outcome: JobOutcome) => void + const id = ctx.jobs.start({ + kind: 'bash', + label: 'staged writes', + record: true, + run: (job) => { + job.append('early ', { channel: 'stdout' }) + job.updateDetail('booting') + return { + cancel() {}, + done: new Promise((resolve) => { settle = resolve }), + } + }, + }) + // Only the registration commit announced anything. + expect(outputSignals).toEqual([]) + expect(changes).toEqual([1]) + const snapshot = ctx.jobs.get(id) + expect(snapshot.detail).toBe('booting') + expect(snapshot.outputTotal).toBe(6) + expect(ctx.jobs.readRecord(id, 0).chunks).toEqual([{ at: 0, text: 'early ', channel: 'stdout' }]) + settle({ status: 'completed' }) + await tick() + }) + + it('drops an empty append without waking observers', async () => { + const ctx = await harness() + const signals: string[] = [] + ctx.jobs.onOutput((id) => { signals.push(String(id)) }) + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('') + expect(signals).toEqual([]) + expect(ctx.jobs.get(id).outputTotal).toBe(0) + }) + + it('logs and drops an append without a record declaration; updateDetail still works', async () => { + const ctx = await harness() + const warn = vi.fn() + ctx.logger.warn = warn as never + const p = producer() + const id = ctx.jobs.start(p.spec) + p.job().append('nowhere to land') + expect(warn).toHaveBeenCalledWith(expect.stringContaining(`append to job ${id} without a record declaration dropped`)) + expect(() => ctx.jobs.readRecord(id, 0)).toThrow(`job ${id} declared no output record`) + const snapshot = ctx.jobs.get(id) + expect(snapshot.outputTotal).toBeUndefined() + expect(snapshot.outputEarliest).toBeUndefined() + p.job().updateDetail('7/10 items') + expect(ctx.jobs.get(id).detail).toBe('7/10 items') + }) + + it('rejects a negative or fractional record read offset', async () => { + const ctx = await harness() + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + expect(() => ctx.jobs.readRecord(id, -1)).toThrow(/invalid record read offset/) + expect(() => ctx.jobs.readRecord(id, 0.5)).toThrow(/invalid record read offset/) + expect(() => ctx.jobs.readRecord(JobId('bash-99'), 0)).toThrow(/unknown job/) + }) + + it('fences record reads to the owning session', async () => { + const ctx = await harness() + const alice = stubAgent(ctx, 'alice') + const bob = stubAgent(ctx, 'bob') + ctx.agents.register(alice) + ctx.agents.register(bob) + const p = producer({ owner: alice, record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('secret') + expect(() => ctx.jobs.readRecord(id, 0, bob)).toThrow(/belongs to another session/) + expect(() => ctx.jobs.readRecord(id, 0)).toThrow(/belongs to another session/) + expect(ctx.jobs.readRecord(id, 0, alice).chunks[0]?.text).toBe('secret') + }) + + it('evicts whole head chunks past the live cap and flags stale readers lossy', async () => { + const ctx = await harness({ retainBytes: 8 }) + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('aaaa') + p.job().append('bbbb') + p.job().append('cccc') + const read = ctx.jobs.readRecord(id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 4, text: 'bbbb' }, { at: 8, text: 'cccc' }]) + expect(read.next).toBe(12) + const snapshot = ctx.jobs.get(id) + expect(snapshot.outputEarliest).toBe(4) + expect(snapshot.outputTotal).toBe(12) + // A reader at the retained boundary is not lossy. + expect(ctx.jobs.readRecord(id, 4).lossy).toBe(false) + }) + + it('keeps only the UTF-8-safe tail of a single oversized chunk', async () => { + const ctx = await harness({ retainBytes: 5 }) + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + // '你好' is six UTF-8 bytes; a five-byte budget must not split the second code point. + p.job().append('你好') + const read = ctx.jobs.readRecord(id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 3, text: '好', gapBefore: true }]) + const snapshot = ctx.jobs.get(id) + expect(snapshot.outputEarliest).toBe(3) + expect(snapshot.outputTotal).toBe(6) + }) + + it('an exhausted byte budget leaves an empty gap-marked tail at the total offset', async () => { + const ctx = await harness({ retainBytes: 1 }) + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + // Every byte of '好' from the cut is a continuation byte, so no code point survives. + p.job().append('好') + const read = ctx.jobs.readRecord(id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 3, text: '', gapBefore: true }]) + expect(ctx.jobs.get(id).outputEarliest).toBe(3) + }) + + it('returns the whole overlapped chunk for a foreign mid-chunk offset', async () => { + const ctx = await harness() + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('abcdef') + const read = ctx.jobs.readRecord(id, 3) + expect(read.chunks).toEqual([{ at: 0, text: 'abcdef' }]) + expect(read.lossy).toBe(false) + }) + + it('preserves a producer-reported gap marker through reads', async () => { + const ctx = await harness() + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('tail after drop', { gapBefore: true, channel: 'stdout' }) + expect(ctx.jobs.readRecord(id, 0).chunks).toEqual([ + { at: 0, text: 'tail after drop', channel: 'stdout', gapBefore: true }, + ]) + }) + + it('settlement trims to the settled cap, signals once more, and drops later writes', async () => { + const ctx = await harness({ retainBytes: 1024, settledRetainBytes: 4 }) + const warn = vi.fn() + ctx.logger.warn = warn as never + const signals: string[] = [] + ctx.jobs.onOutput((id) => { signals.push(String(id)) }) + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('abcdefgh') + p.settle({ status: 'completed', detail: 'exit code: 0' }) + await tick() + expect(signals).toEqual([String(id), String(id)]) + const snapshot = ctx.jobs.get(id) + expect(snapshot).toMatchObject({ status: 'completed', detail: 'exit code: 0', outputTotal: 8, outputEarliest: 4 }) + const read = ctx.jobs.readRecord(id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 4, text: 'efgh', gapBefore: true }]) + // The stream ended with the settlement: a trailing flush cannot break teardown. + p.job().append('late') + p.job().updateDetail('late detail') + expect(warn.mock.calls.map(call => String(call[0]))).toEqual([ + expect.stringContaining('append to settled job'), + expect.stringContaining('detail update on settled job'), + ]) + expect(signals).toHaveLength(2) + expect(ctx.jobs.get(id).detail).toBe('exit code: 0') + }) + + it('a terminal outcome without detail keeps the last progress line', async () => { + const ctx = await harness() + const changes: number[] = [] + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + ctx.jobs.onJobsChanged(() => { changes.push(1) }) + p.job().updateDetail('3/10 agents done') + expect(changes).toHaveLength(1) + expect(ctx.jobs.get(id).detail).toBe('3/10 agents done') + p.settle({ status: 'completed' }) + await tick() + expect(ctx.jobs.get(id)).toMatchObject({ status: 'completed', detail: '3/10 agents done' }) + }) + + it('a teardown-forced settlement also ends the record', async () => { + const ctx = await harness() + const warn = vi.fn() + ctx.logger.warn = warn as never + const owner = stubAgent(ctx, 'owner') + ctx.agents.register(owner) + const p = producer({ owner, record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('partial') + const disposal = disposeAgentScope(owner) + p.settle({ status: 'killed' }) + await disposal + expect(() => ctx.jobs.readRecord(id, 0, owner)).toThrow(/unknown job/) + // The producer face outlives the row; its trailing flush is dropped, not thrown. + p.job().append('late') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('append to settled job')) + }) + + it('delivers onOutput to the global layer and to the owning scope only', async () => { + const ctx = await harness() + const mount = createScope(ctx, {}) + const otherMount = createScope(ctx, {}) + const alice = stubAgent(ctx, 'alice', scopeOf(mount.ctx)) + const bob = stubAgent(ctx, 'bob', scopeOf(otherMount.ctx)) + ctx.agents.register(alice) + ctx.agents.register(bob) + const globalSignals: string[] = [] + ctx.jobs.onOutput((id) => { globalSignals.push(String(id)) }) + const scopedSignals: string[] = [] + await mount.ctx.plugin({ + inject: ['jobs'], + apply(scopedCtx: Context) { + scopedCtx.jobs.onOutput((id) => { scopedSignals.push(String(id)) }) + }, + }) + const pa = producer({ owner: alice, record: true }) + const aliceId = ctx.jobs.start(pa.spec) + const pb = producer({ owner: bob, record: true }) + ctx.jobs.start(pb.spec) + pa.job().append('for alice observers') + pb.job().append('for bob observers') + expect(globalSignals).toHaveLength(2) + expect(scopedSignals).toEqual([String(aliceId)]) + }) + + it('contains a throwing onOutput listener without breaking the append commit', async () => { + const ctx = await harness() + const warn = vi.fn() + ctx.logger.warn = warn as never + ctx.jobs.onOutput(() => { throw new Error('output boom') }) + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('x') + expect(ctx.jobs.get(id).outputTotal).toBe(1) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('onOutput listener threw')) + }) + + it('unregisters onOutput listeners with their registering fiber (HMR safety)', async () => { + const ctx = await harness() + const signals: string[] = [] + const fiber = ctx.plugin({ + inject: ['jobs'], + apply(pluginCtx: Context) { + pluginCtx.jobs.onOutput((id) => { signals.push(String(id)) }) + }, + }) + await fiber + const p = producer({ record: true }) + ctx.jobs.start(p.spec) + p.job().append('a') + expect(signals).toHaveLength(1) + await fiber.dispose() + p.job().append('b') + expect(signals).toHaveLength(1) + }) + + it('hands out fresh record snapshots, never live registry state', async () => { + const ctx = await harness() + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + const before = ctx.jobs.get(id) + p.job().append('grow') + expect(before.outputTotal).toBe(0) + expect(ctx.jobs.get(id).outputTotal).toBe(4) + }) +}) diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index 7625c3672d..10c74af908 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-jobs` lets tools run long work as background jobs: the work gets a stable `-N` id, keeps running while the agent moves on, and the owning agent can read its output, wait for it with a timeout, or request cancellation at any time. Jobs belong to the agent session that started them, so one agent's work is never visible to another, and completion reaches the owner as an in-session notice rather than by polling. This package ships the contract only: the process-local registry lives in `dsh-jobs-local`, and the model-facing controls and completion notices live in `dsh-tool-jobs`. Load an implementation to get background jobs; without one, `ctx.jobs` does not exist and `start()` cannot run. +`dsh-jobs` lets tools run long work as background jobs: the work gets a stable `-N` id, keeps running while the agent moves on, and the owning agent can read its output, wait for it with a timeout, or request cancellation at any time. Jobs belong to the agent session that started them, so one agent's work is never visible to another, and completion reaches the owner as an in-session notice rather than by polling. A producer can also declare a per-job observation record: an append-only bounded output stream that any number of observers (the Web client) read at absolute byte offsets without touching the model-facing consuming cursor. This package ships the contract only: the process-local registry lives in `dsh-jobs-local`, and the model-facing controls and completion notices live in `dsh-tool-jobs`. Load an implementation to get background jobs; without one, `ctx.jobs` does not exist and `start()` cannot run. ## Table of Contents @@ -31,6 +31,8 @@ Use this package when you are composing a background-job capability or writing a A producer registers work with a kind and a one-line label; the registry returns a `-N` id such as `bash-1`. Anyone who owns the job can read output, list jobs, wait up to a timeout for settlement, and request cancellation — each call returns a fresh snapshot of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the owning agent is notified through the completion listener that `dsh-tool-jobs` turns into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing output read or completion notice stays bounded. +A producer that declares `record: true` additionally streams raw output into the job's bounded record through the `RunningJob` face its starter receives: observers read retained chunks at absolute byte offsets and get signaled on advancement, the settlement that ends the job also ends the stream, and `updateDetail` publishes a live progress line into every snapshot. The record is invisible to the model: reads consume nothing and never touch notice state. + ### The ownership boundary A job belongs to the agent session that started it: another agent cannot read or stop it. Ids such as `bash-1` are predictable, so this fence is authorization, not secrecy. A job started without an owner is open to any caller and lasts until the service is disposed. @@ -75,13 +77,14 @@ This section explains the design decisions behind the contract and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `JobRegistry` service and its contract | -| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `JobSnapshot`, listener types | +| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `JobSnapshot`, listener types | | [`src/brand.ts`](src/brand.ts) | `JobId` branded identifier, importable without the agent dependency | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates snapshot identity, status, timestamps, and owner fields | +| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`: poll pump copying producer offset-readers into a job record | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates snapshot identity, status, timestamps, owner, and record-offset fields | ### Service operations -Every operation is a thin projection over the registered jobs: `get` and `list` return non-consuming snapshots, `read` advances the single stream cursor, `kill` invokes producer cancellation before changing status, `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; listeners observe terminal records and visible-set changes at owner granularity, and `attachController` scopes controller availability to its effect lifetime. Exact signatures and behavior live in the JSDoc on [`src/index.ts`](src/index.ts) and the generated [`ctx.jobs` cordis surface](../../../docs/subsystems/jobs.md). +Every operation is a thin projection over the registered jobs: `get` and `list` return non-consuming snapshots, `read` advances the single stream cursor, `readRecord` reads retained record chunks at an absolute offset without consuming anything, `kill` invokes producer cancellation before changing status, `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; listeners observe terminal records, visible-set changes, and record advancement at owner granularity, and `attachController` scopes controller availability to its effect lifetime. Exact signatures and behavior live in the JSDoc on [`src/index.ts`](src/index.ts) and the generated [`ctx.jobs` cordis surface](../../../docs/subsystems/jobs.md). @@ -118,7 +121,7 @@ No direct invalidation; the named consumers own any request-prefix changes. These limits define when the contract is a poor fit. They are current package constraints, not a task backlog. - **The contract is in-process** — `JobStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam. -- **Stream output has one consuming cursor** — the model owns it; independent observers use the non-consuming [`dsh-activity`](../../activity/activity/README.md) plane the producers mirror into instead. +- **Stream output has one consuming cursor** — the model owns it; independent observers read the job's declared record through the non-consuming `readRecord` instead. - **Foreground work cannot be promoted** — producers choose foreground or background before starting. diff --git a/packages/jobs/jobs/src/index.ts b/packages/jobs/jobs/src/index.ts index 7d46dfa342..796afc0882 100644 --- a/packages/jobs/jobs/src/index.ts +++ b/packages/jobs/jobs/src/index.ts @@ -1,30 +1,42 @@ /** * The background-job Service Definition (`ctx.jobs`). It owns the contract for - * job ids, session-scoped access, lifecycle state, completion listeners, and - * owner cleanup while producers retain their execution resources. The - * process-local registry lives in `@deepseek-ai/dsh-jobs-local`. + * job ids, session-scoped access, lifecycle state, completion listeners, + * owner cleanup, and the optional per-job observation record — an append-only + * bounded output stream any number of independent observers read at absolute + * byte offsets, invisible to the model-facing consuming cursor — while + * producers retain their execution resources. The process-local registry + * lives in `@deepseek-ai/dsh-jobs-local`. * @module @deepseek-ai/dsh-jobs */ import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { - JobDoneListener, JobId, JobRead, JobSnapshot, JobStart, JobsChangedListener, + JobDoneListener, JobId, JobOutputListener, JobRead, JobRecordRead, JobSnapshot, JobStart, + JobsChangedListener, } from './types.ts' export { JobId } from './types.ts' export type { + JobAppendOptions, + JobChannel, JobDoneListener, JobHooks, JobKind, JobKindMap, JobOutcome, + JobOutputListener, JobRead, + JobRecordChunk, + JobRecordRead, JobSnapshot, JobStart, JobStatus, JobsChangedListener, + RunningJob, } from './types.ts' +export { pumpJobOutput } from './pump.ts' +export type { JobPumpOptions, JobPumpRead, JobPumpSource } from './pump.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -58,6 +70,15 @@ declare module '@deepseek-ai/cordis' { * than process-wide: registrations made from an unscoped context serve * every owner, and registrations made under an agent composition's scope * serve exactly the agents composed under it. + * - Record observation never consumes. Any number of {@link readRecord} + * readers hold their own absolute byte offsets; a record read changes no + * cursor and no notice state, so the model-facing surfaces (the consuming + * {@link read}, completion notices) are unaffected. + * - Record retention is bounded. Appends past the live cap drop the oldest + * retained bytes; a reader below the retained window gets a lossy read, + * never an error. Settlement — the producer outcome, a kill, or teardown — + * trims retention to the settled cap and ends the stream; the record has no + * separate lifecycle. */ export abstract class JobRegistry extends Service { constructor(ctx: Context) { @@ -166,6 +187,31 @@ export abstract class JobRegistry extends Service { */ abstract onJobsChanged(listener: JobsChangedListener): () => void + /** + * Read retained record output from an absolute byte offset without consuming + * it. Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Never marks the job reported. Throws for an unknown or foreign job, a job + * without a {@link JobStart.record} declaration, or a negative or + * non-integer offset. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ + abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead + + /** + * Register an effect-scoped observer of record advancement — one signal per + * committed append and one at settlement, carrying only the job id. A + * consumer schedules a {@link readRecord} from its own cursor; the registry + * never pushes payloads. Delivery is owner-relative on the same terms as + * {@link onJobsChanged}. Listeners are contained and never awaited. + * @param listener - receives the id whose record advanced. + * @returns disposer that unregisters the listener. + */ + abstract onOutput(listener: JobOutputListener): () => void + /** * Attach an effect-scoped controller that can read and stop jobs. It serves the * owners its registering context's scope covers, and {@link start} refuses an diff --git a/packages/jobs/jobs/src/invariant.ts b/packages/jobs/jobs/src/invariant.ts index 5963179c00..36aca11fd8 100644 --- a/packages/jobs/jobs/src/invariant.ts +++ b/packages/jobs/jobs/src/invariant.ts @@ -40,6 +40,15 @@ function validateSnapshot(snapshot: JobSnapshot, owner: Agent | undefined, fail: if (snapshot.ownerSession !== expectedOwner) { fail(`job ${JSON.stringify(id)} ownerSession does not match its completion owner`) } + + const { outputTotal, outputEarliest } = snapshot + if ((outputTotal !== undefined) !== (outputEarliest !== undefined)) { + fail(`job ${JSON.stringify(id)} record offsets must be present together`) + } else if (outputTotal !== undefined && outputEarliest !== undefined + && (!Number.isSafeInteger(outputTotal) || !Number.isSafeInteger(outputEarliest) + || outputEarliest < 0 || outputEarliest > outputTotal)) { + fail(`job ${JSON.stringify(id)} record offsets must satisfy 0 <= outputEarliest <= outputTotal`) + } } /** Install checks over current unowned records and every terminal snapshot. */ diff --git a/packages/jobs/jobs/src/pump.ts b/packages/jobs/jobs/src/pump.ts new file mode 100644 index 0000000000..8f56b6abc6 --- /dev/null +++ b/packages/jobs/jobs/src/pump.ts @@ -0,0 +1,96 @@ +/** + * Shared poll pump for producers whose output substrate is a non-consuming + * offset reader (the subprocess `readFrom(fromByte)` family). Pure utility — + * no cordis, no timers retained past settlement. + * @module + */ + +import type { JobChannel, RunningJob } from './types.ts' + +/** One non-consuming offset read from a producer-owned stream. */ +export interface JobPumpRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean +} + +/** One producer-owned stream the pump copies into a job record. */ +export interface JobPumpSource { + /** Stream label attached to every chunk this source yields. */ + channel?: JobChannel + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, and the lossy flag. + */ + read(fromByte: number): JobPumpRead +} + +/** Cadence and settlement for one {@link pumpJobOutput} run. */ +export interface JobPumpOptions { + /** Poll interval in milliseconds; a positive finite number. */ + pollMs: number + /** + * Settles when the producer's work finished and its sources hold their + * final bytes. The pump drains once more after settlement and returns; + * a rejection is treated as settlement. + */ + done: Promise +} + +/** + * Copy producer streams into a job record at a bounded cadence: drain every + * source, sleep `pollMs` or until `done` settles, repeat, then drain one + * final time. A lossy source read appends its surviving tail with + * `gapBefore`, so the discontinuity stays visible to observers. Fold the + * returned promise into the producer's `JobHooks.done` chain so the final + * drain lands before settlement trims and closes the record. + * @param job - the running job's producer face receiving the copied chunks. + * @param sources - producer streams, each pumped at its own offset. + * @param options - poll cadence and the producer's settlement promise. + * @returns resolves after the final post-settlement drain. + */ +export async function pumpJobOutput( + job: RunningJob, + sources: readonly JobPumpSource[], + options: JobPumpOptions, +): Promise { + if (!Number.isFinite(options.pollMs) || options.pollMs <= 0) { + throw new Error(`invalid pump pollMs: expected a positive finite number of milliseconds, got ${JSON.stringify(options.pollMs)}`) + } + const states = sources.map(source => ({ source, cursor: 0 })) + const drain = (): void => { + for (const state of states) { + const { source } = state + const read = source.read(state.cursor) + state.cursor = read.nextOffset + if (read.text.length === 0) continue + if (source.channel === undefined && !read.lossy) { + job.append(read.text) + } else { + job.append(read.text, { + ...source.channel !== undefined ? { channel: source.channel } : {}, + ...read.lossy ? { gapBefore: true as const } : {}, + }) + } + } + } + let settled = false + const settlement = options.done.then(() => { settled = true }, () => { settled = true }) + while (!settled) { + drain() + await Promise.race([ + new Promise((resolve) => { + const timer = setTimeout(resolve, options.pollMs) + // The settlement branch wins the race; clear the loser so a finished + // pump holds no pending timer for up to one poll interval. + void settlement.then(() => { clearTimeout(timer); resolve() }) + }), + settlement, + ]) + } + drain() +} diff --git a/packages/jobs/jobs/src/types.ts b/packages/jobs/jobs/src/types.ts index 2842f6a3a5..b42d7815e4 100644 --- a/packages/jobs/jobs/src/types.ts +++ b/packages/jobs/jobs/src/types.ts @@ -38,6 +38,24 @@ export interface JobOutcome { output?: string } +/** + * Record-stream label. Producers without distinct streams omit it; consumers + * treat an unrecognized label like an absent one. + */ +export type JobChannel = 'stdout' | 'stderr' + +/** Options for one {@link RunningJob.append}. */ +export interface JobAppendOptions { + /** Stream label for the chunk; omitted for producers without distinct streams. */ + channel?: JobChannel + /** + * Marks that producer-side bytes between the previous chunk and this one + * were lost (a source window slid before the producer could copy it), so an + * observer can render the discontinuity instead of a silent splice. + */ + gapBefore?: true +} + /** * Producer declaration passed to {@link JobRegistry.start}. The runtime * preflights access and cleanup before invoking {@link run}; the producer owns @@ -50,7 +68,9 @@ export interface JobStart { label: string /** * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. + * output read, including controller status metadata. Independent of record + * retention: it bounds the consuming model surface, never + * {@link JobRegistry.readRecord}. */ outputLimitBytes?: number /** @@ -61,11 +81,49 @@ export interface JobStart { */ owner?: Agent /** - * Start the work after preflight and synchronously return its hooks. Called - * once; a throw leaves nothing registered, and the producer must clean up any - * partially started resources. + * Declares an observable output record beside the model-facing surfaces: + * {@link RunningJob.append} retains chunks in a bounded ring that any number + * of observers read at absolute byte offsets through + * {@link JobRegistry.readRecord}, and snapshots carry + * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + * Without the declaration `append` logs and drops. */ - run(): JobHooks + record?: true + /** + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the record append and live-detail writers. + */ + run(job: RunningJob): JobHooks +} + +/** + * Producer face of one registered job, handed to {@link JobStart.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — both methods log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. + */ +export interface RunningJob { + /** The registry-issued id (`-N`). */ + readonly id: JobId + /** + * Append one record chunk. Offsets advance by the chunk's UTF-8 byte + * length; an empty chunk is dropped without waking observers. Without a + * {@link JobStart.record} declaration the chunk is logged and dropped. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + */ + append(text: string, options?: JobAppendOptions): void + /** + * Replace the snapshot's status detail with a live progress line (`3/10`). + * Works for every job, with or without a record. + * @param detail - the new detail line. + */ + updateDetail(detail: string): void } /** Hooks through which the runtime controls and observes producer work. */ @@ -79,13 +137,16 @@ export interface JobHooks { * Resolves after the producer releases its resources, not merely when work * finishes. Must not reject; the runtime converts a rejection to `failed`. * If teardown cancellation throws, the runtime may force-fail only the - * registry record without claiming that the work stopped. + * registry record without claiming that the work stopped. Settlement also + * ends the record: fold any record-filling pump into this promise so the + * final drain lands before the registry trims and closes the stream. */ done: Promise /** * Consume output produced since the previous call. The producer formats * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. + * job has one consuming cursor. Independent of the record: this is the + * model-facing projection, {@link JobRegistry.readRecord} the observer one. */ readOutput?(): string } @@ -111,7 +172,10 @@ export interface JobSnapshot { ownerSession?: SessionId /** Current lifecycle state. */ status: JobStatus - /** Kind-specific status detail, present once the producer supplied one (usually terminal). */ + /** + * Kind-specific status detail: live progress while the producer updates it + * through {@link RunningJob.updateDetail}, the terminal detail once settled. + */ detail?: string /** Epoch ms when the job was registered. */ startedAt: number @@ -125,6 +189,17 @@ export interface JobSnapshot { * otherwise spend a model request per teardown layer. */ reported: boolean + /** + * Total UTF-8 bytes ever appended to the record — the offset the next chunk + * starts at. Present exactly when the job declared {@link JobStart.record}. + */ + outputTotal?: number + /** + * Offset of the oldest retained record byte. Greater than zero exactly when + * retention dropped the head; a reader starting below it gets a lossy read. + * Present exactly when the job declared {@link JobStart.record}. + */ + outputEarliest?: number } /** Output and post-read state returned by {@link JobRegistry.read}. */ @@ -139,6 +214,33 @@ export interface JobRead { snapshot: JobSnapshot } +/** One retained record chunk returned by {@link JobRegistry.readRecord}. */ +export interface JobRecordChunk { + /** Absolute offset of the chunk's first byte. */ + at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + text: string + /** Stream label, when the producer supplied one. */ + channel?: JobChannel + /** Producer-reported loss immediately before this chunk. */ + gapBefore?: true +} + +/** Result of one non-consuming {@link JobRegistry.readRecord}. */ +export interface JobRecordRead { + /** Retained chunks overlapping `[from, total)`, in offset order. */ + chunks: readonly JobRecordChunk[] + /** + * Offset to resume from — the record's current `outputTotal`. Always a + * chunk boundary: appends land whole and trimming only advances chunk + * starts, and consumers concatenate `chunks` under that assumption, so a + * provider serving partial chunks would silently duplicate text. + */ + next: number + /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean +} + /** * Completion callback with the exact owner supplied at start, or `undefined` * for an unowned job. Returned promises are observed but not awaited. @@ -158,3 +260,10 @@ export type JobDoneListener = ( * set changed with it. */ export type JobsChangedListener = (owner: Agent | undefined) => void + +/** + * Observer of record advancement for one job: new appended output, or the + * settlement that ends the stream. Carries no payload — a consumer schedules a + * {@link JobRegistry.readRecord} from its own cursor. + */ +export type JobOutputListener = (id: JobId) => void diff --git a/packages/jobs/jobs/tests/invariant.spec.ts b/packages/jobs/jobs/tests/invariant.spec.ts index 5cbaf94b11..fa42eb6c74 100644 --- a/packages/jobs/jobs/tests/invariant.spec.ts +++ b/packages/jobs/jobs/tests/invariant.spec.ts @@ -62,6 +62,7 @@ describe('job-registry invariants', () => { const owner = { id: SessionId('owner') } as Agent expect(() => { notify({ ...BASE, id: JobId('subagent-2'), kind: 'subagent', ownerSession: owner.id }, owner) }) .not.toThrow() + expect(() => { notify({ ...BASE, outputTotal: 8, outputEarliest: 4 }) }).not.toThrow() }) it.each([ @@ -76,6 +77,12 @@ describe('job-registry invariants', () => { [{ ...BASE, finishedAt: 9 }, undefined, /no earlier than startedAt/], [{ ...BASE, finishedAt: 20.5 }, undefined, /no earlier than startedAt/], [{ ...BASE, ownerSession: SessionId('recorded') }, { id: SessionId('actual') } as Agent, /does not match its completion owner/], + [{ ...BASE, outputTotal: 8 }, undefined, /record offsets must be present together/], + [{ ...BASE, outputEarliest: 0 }, undefined, /record offsets must be present together/], + [{ ...BASE, outputTotal: 8, outputEarliest: 9 }, undefined, /0 <= outputEarliest <= outputTotal/], + [{ ...BASE, outputTotal: 8.5, outputEarliest: 0 }, undefined, /0 <= outputEarliest <= outputTotal/], + [{ ...BASE, outputTotal: 8, outputEarliest: 0.5 }, undefined, /0 <= outputEarliest <= outputTotal/], + [{ ...BASE, outputTotal: 8, outputEarliest: -1 }, undefined, /0 <= outputEarliest <= outputTotal/], ] as const)('rejects an incoherent registry snapshot', async (snapshot, owner, message) => { const notify = await setup() expect(() => { notify(snapshot, owner) }).toThrow(message) diff --git a/packages/jobs/jobs/tests/service.spec.ts b/packages/jobs/jobs/tests/service.spec.ts index 6c4f65c52f..ff2c282a27 100644 --- a/packages/jobs/jobs/tests/service.spec.ts +++ b/packages/jobs/jobs/tests/service.spec.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { JobId, JobRegistry } from '@deepseek-ai/dsh-jobs' +import { JobId, JobRegistry, pumpJobOutput } from '@deepseek-ai/dsh-jobs' import type { - JobDoneListener, JobRead, JobSnapshot, JobStart, JobsChangedListener, + JobAppendOptions, JobDoneListener, JobOutputListener, JobPumpSource, JobRead, JobRecordRead, + JobSnapshot, JobStart, JobsChangedListener, RunningJob, } from '@deepseek-ai/dsh-jobs' /** @@ -24,8 +25,9 @@ class StubJobRegistry extends JobRegistry { } start(spec: JobStart): JobId { - spec.run() - return JobId(`${spec.kind}-1`) + const id = JobId(`${spec.kind}-1`) + spec.run({ id, append() {}, updateDetail() {} }) + return id } list(): JobSnapshot[] { @@ -56,6 +58,14 @@ class StubJobRegistry extends JobRegistry { return () => {} } + readRecord(_id: JobId, from: number): JobRecordRead { + return { chunks: [], next: from, lossy: false } + } + + onOutput(_listener: JobOutputListener): () => void { + return () => {} + } + attachController(_name: string): () => void { return () => {} } @@ -74,10 +84,13 @@ describe('JobRegistry seam', () => { expect(ctx.jobs.read(id).text).toBe('') expect(ctx.jobs.kill(id)).toBe('requested') await expect(ctx.jobs.wait(id, 5)).resolves.toMatchObject({ id }) + expect(ctx.jobs.readRecord(id, 0)).toEqual({ chunks: [], next: 0, lossy: false }) const detachListener = ctx.jobs.onJobDone(() => {}) detachListener() const detachChanges = ctx.jobs.onJobsChanged(() => {}) detachChanges() + const detachOutput = ctx.jobs.onOutput(() => {}) + detachOutput() detachController() }) @@ -94,3 +107,83 @@ describe('JobRegistry seam', () => { .rejects.toThrow(/abstract job registry seam; load an implementation such as @deepseek-ai\/dsh-jobs-local/) }) }) + +/** Record every append so pump behavior is observable without a registry. */ +function recordingJob(): { job: RunningJob; appends: { text: string; options?: JobAppendOptions }[] } { + const appends: { text: string; options?: JobAppendOptions }[] = [] + const job: RunningJob = { + id: JobId('bash-1'), + append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) }, + updateDetail() {}, + } + return { job, appends } +} + +/** A scripted source: each read() shifts the next scripted result. */ +function scriptedSource( + reads: { text: string; lossy?: boolean }[], + channel?: 'stdout' | 'stderr', +): { source: JobPumpSource; offsets: number[] } { + const offsets: number[] = [] + let offset = 0 + const source: JobPumpSource = { + ...channel !== undefined ? { channel } : {}, + read(fromByte) { + offsets.push(fromByte) + const next = reads.shift() ?? { text: '' } + offset += Buffer.byteLength(next.text, 'utf8') + return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false } + }, + } + return { source, offsets } +} + +describe('pumpJobOutput', () => { + it('rejects a non-positive poll interval before touching any source', async () => { + const { job } = recordingJob() + await expect(pumpJobOutput(job, [], { pollMs: 0, done: Promise.resolve() })) + .rejects.toThrow(/invalid pump pollMs/) + }) + + it('copies labeled deltas at the poll cadence and resumes each source at its own offset', async () => { + vi.useFakeTimers() + try { + const { job, appends } = recordingJob() + let settle!: () => void + const done = new Promise((resolve) => { settle = resolve }) + const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout') + const err = scriptedSource([{ text: '' }, { text: 'E' }], 'stderr') + const pump = pumpJobOutput(job, [out.source, err.source], { pollMs: 50, done }) + + await vi.advanceTimersByTimeAsync(50) + settle() + await pump + expect(appends).toEqual([ + { text: 'a', options: { channel: 'stdout' } }, + { text: 'bc', options: { channel: 'stdout' } }, + { text: 'E', options: { channel: 'stderr' } }, + ]) + // Every read resumed from the previous nextOffset, never from a shared cursor. + expect(out.offsets).toEqual([0, 1, 3]) + expect(err.offsets).toEqual([0, 0, 1]) + // Settlement cleared the raced poll timer instead of leaving it pending. + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('marks a lossy source read as a gap so observers see the discontinuity', async () => { + const { job, appends } = recordingJob() + const { source } = scriptedSource([{ text: 'tail', lossy: true }]) + await pumpJobOutput(job, [source], { pollMs: 1, done: Promise.resolve() }) + expect(appends).toEqual([{ text: 'tail', options: { gapBefore: true } }]) + }) + + it('treats a rejected done as settlement and still drains the final bytes', async () => { + const { job, appends } = recordingJob() + const { source } = scriptedSource([{ text: 'last' }]) + await pumpJobOutput(job, [source], { pollMs: 1, done: Promise.reject(new Error('producer broke')) }) + expect(appends).toEqual([{ text: 'last' }]) + }) +}) From 8537b8d5bb620fab135b26d0700d845e90584bdc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 17:52:26 +0800 Subject: [PATCH 014/284] refactor(shell,workflow): produce into the job record instead of ctx.activities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bash and pwsh background runs declare record: true and pump their observed streams straight into the job through observeProcessRecord — the open/end pairing, outcome mapping, and registry-absent degradation branches are gone, and the pump's final drain folds into hooks.done so the record holds its last bytes before settlement closes it. activityPollMs becomes recordPollMs. tool-workflow drops its foreground activity mirror deliberately: a foreground run's live phase/log narration had the mirror as its only carrier, and the transcript keeps the recorded run/member lifecycle events. Live narration returns with backgrounded runs when workflow gains run_in_background. --- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/package.json | 2 - packages/shell/tool-bash/src/index.ts | 34 +-- packages/shell/tool-bash/src/observe.ts | 92 +++----- .../shell/tool-bash/tests/observe.spec.ts | 218 +++++------------- packages/shell/tool-bash/tsconfig.json | 3 - packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/package.json | 2 - packages/shell/tool-pwsh/src/index.ts | 40 +--- packages/shell/tool-pwsh/src/observe.ts | 92 +++----- .../shell/tool-pwsh/tests/observe.spec.ts | 121 +++------- packages/shell/tool-pwsh/tsconfig.json | 3 - packages/workflow/tool-workflow/README.md | 3 +- packages/workflow/tool-workflow/package.json | 3 - .../workflow/tool-workflow/src/activity.ts | 119 ---------- packages/workflow/tool-workflow/src/index.ts | 5 - .../tests/activity-mirror.spec.ts | 129 ----------- packages/workflow/tool-workflow/tsconfig.json | 3 - 18 files changed, 166 insertions(+), 707 deletions(-) delete mode 100644 packages/workflow/tool-workflow/src/activity.ts delete mode 100644 packages/workflow/tool-workflow/tests/activity-mirror.spec.ts diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 5e30ed598e..39e894970a 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -55,7 +55,7 @@ The tool executes `bash -c ` and returns the combined output. Commands ### Running long commands in the background -Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted. When the optional `ctx.activities` registry is loaded, the committed background run is also mirrored into it best-effort: a `bash` activity correlated to the call and job pumps the handle's non-consuming `observed` readers at `activityPollMs` (default 150), so the Web client streams live output without touching the job's cursor; any observation failure is logged and swallowed. +Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted. The background job declares an observation record: the run's non-consuming `observed` readers are pumped into it at `recordPollMs` (default 150), so the Web client streams live output without touching the job's consuming cursor; a pump failure is logged and swallowed while the job runs on. ### Sandboxed execution and escalation diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 7c143f415d..d4be992ae9 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -37,7 +37,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", @@ -59,7 +58,6 @@ "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts index c79fa2c4c6..c4586d0b10 100644 --- a/packages/shell/tool-bash/src/index.ts +++ b/packages/shell/tool-bash/src/index.ts @@ -22,10 +22,9 @@ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandb import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' -import type { ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' -import type {} from '@deepseek-ai/dsh-activity' +import type { ShellRunResult } from '@deepseek-ai/dsh-shell' import { processOutcome } from './background.ts' -import { observeBackgroundActivity } from './observe.ts' +import { observeProcessRecord } from './observe.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' export const name = 'tool-bash' @@ -35,14 +34,14 @@ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ - activityPollMs?: number + /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ + recordPollMs?: number } /** Runtime configuration schema for the bash tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), - activityPollMs: z.number() + recordPollMs: z.number() .step(1) .min(1) .max(Number.MAX_SAFE_INTEGER) @@ -197,7 +196,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = { export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true - const activityPollMs = config.activityPollMs ?? 150 + const recordPollMs = config.recordPollMs ?? 150 const defaultMode = ctx.shell.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') @@ -370,32 +369,23 @@ export function apply(ctx: Context, config: Config = {}): void { error.name = 'AbortError' throw error } - // Task preflight finishes before the starter can spawn a process. - let started: ShellProcess | undefined const id = jobs.start({ kind: 'bash', label: args.command, ...exec.agent ? { owner: exec.agent } : {}, - run: () => { + record: true, + run: (job) => { const proc = ctx.shell.start(ctx.shell.resolve(request)) - started = proc + // The pump's final drain is folded into `done` so the record holds + // its last bytes before settlement trims and closes it. + const observed = observeProcessRecord(ctx, job, proc, recordPollMs) return { cancel: () => void proc.kill(), - done: proc.done.then(() => processOutcome(proc)), + done: observed.then(() => proc.done).then(() => processOutcome(proc)), readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox, escalationModes), } }, }) - // The starter ran synchronously inside the committed start; mirror the - // spawned process into the optional observation registry. - if (started !== undefined) { - observeBackgroundActivity(ctx, started, { - kind: 'bash', - label: args.command, - ...exec.agent ? { owner: exec.agent } : {}, - correlation: { callId: exec.callId, jobId: id }, - }, activityPollMs, processOutcome) - } return { kind: 'background' as const, jobId: id } } const result = await ctx.shell.run(ctx.shell.resolve({ diff --git a/packages/shell/tool-bash/src/observe.ts b/packages/shell/tool-bash/src/observe.ts index 3dc2aeeb79..2e2874f9c1 100644 --- a/packages/shell/tool-bash/src/observe.ts +++ b/packages/shell/tool-bash/src/observe.ts @@ -1,82 +1,44 @@ /** - * Best-effort process-observation tap for background bash runs: mirrors the - * spawned process's captured streams into `ctx.activities` so non-consuming - * observers (the Web client) see live output without touching the job's - * consuming `readOutput` cursor. Absent registry or absent backend offset - * readers degrade to no observation; the job path is unaffected either way. + * Record tap for background bash runs: copies the spawned process's captured + * streams into the job's observation record, so non-consuming observers (the + * Web client) see live output without touching the job's consuming + * `readOutput` cursor. Observation is strictly best-effort: absent backend + * offset readers degrade to no observation, and a pump failure is logged and + * swallowed — the job path is unaffected either way. * * @module @deepseek-ai/dsh-tool-bash/observe */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' import type { ShellProcess } from '@deepseek-ai/dsh-shell' -import { pumpActivityOutput } from '@deepseek-ai/dsh-activity' -import type { ActivityCorrelation, ActivityKind, ActivityPumpSource } from '@deepseek-ai/dsh-activity' - -/** Identity of one observed background run. */ -export interface ObserveBackgroundSpec { - /** Process kind — the producing tool's registered {@link ActivityKind}. */ - kind: ActivityKind - /** One-line label (the command). */ - label: string - /** Owning live agent, when the call has one. */ - owner?: Agent - /** Links to the starting tool call and the registered job. */ - correlation: ActivityCorrelation -} +import { pumpJobOutput } from '@deepseek-ai/dsh-jobs' +import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs' /** - * Open a process beside a committed background job and pump the handle's - * non-consuming offset readers into it until the run settles, then record the - * mapped outcome. Observation is strictly best-effort: without a - * `ctx.activities` registry this is a no-op, without backend offset readers - * the process still carries status and settlement, and ANY observation - * failure — including a rejected `open()` — is logged and swallowed, because - * the background job is already committed and running. - * @param ctx - plugin context used for the optional registry and warning logs. + * Pump the process's non-consuming offset readers into the job record until + * the run settles, then drain once more. The returned promise never rejects: + * a pump failure is logged and the record simply stops advancing, while the + * process and its job continue untouched. + * @param ctx - plugin context used for the warning log. + * @param job - the running job's producer face receiving the copied chunks. * @param proc - the started background process handle. - * @param spec - process identity and correlation for the registry row. * @param pollMs - producer-configured pump cadence in milliseconds. - * @param outcome - maps the settled handle to the process outcome. + * @returns resolves after the final drain; fold into the job's `done` chain so + * the record holds its final bytes before settlement closes it. */ -export function observeBackgroundActivity( +export function observeProcessRecord( ctx: Context, + job: RunningJob, proc: ShellProcess, - spec: ObserveBackgroundSpec, pollMs: number, - outcome: (proc: ShellProcess) => { status: 'completed' | 'killed'; detail: string }, -): void { - try { - const processes = ctx.get('activities') - if (processes === undefined) return - const handle = processes.open({ - kind: spec.kind, - label: spec.label, - ...spec.owner !== undefined ? { owner: spec.owner } : {}, - correlation: spec.correlation, +): Promise { + const sources: JobPumpSource[] = [] + const stdout = proc.observed?.stdout + if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) + const stderr = proc.observed?.stderr + if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) + return pumpJobOutput(job, sources, { pollMs, done: proc.done }) + .catch((error: unknown) => { + ctx.logger.warn(`record observation pump for ${job.id} failed: ${String(error)}`) }) - const sources: ActivityPumpSource[] = [] - const stdout = proc.observed?.stdout - if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) - const stderr = proc.observed?.stderr - if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) - void pumpActivityOutput(handle, sources, { pollMs, done: proc.done }) - .catch((error: unknown) => { - ctx.logger.warn(`activity observation pump for ${handle.id} failed: ${String(error)}`) - }) - .then(async () => { - // The pump can fail while the process is still running; the outcome - // mapping requires a settled handle, and `end` is first-wins, so an - // early mapping would freeze a wrong terminal state onto the row. - // `proc.done` never rejects. - await proc.done - handle.end(outcome(proc)) - }) - .catch((error: unknown) => { - ctx.logger.warn(`activity settlement mapping for ${handle.id} failed: ${String(error)}`) - }) - } catch (error: unknown) { - ctx.logger.warn(`activity observation unavailable for this run: ${String(error)}`) - } } diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts index 714558227f..8a4ac6ae2a 100644 --- a/packages/shell/tool-bash/tests/observe.spec.ts +++ b/packages/shell/tool-bash/tests/observe.spec.ts @@ -10,18 +10,19 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import type { JobId, RunningJob } from '@deepseek-ai/dsh-jobs' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' -import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' -import type { ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import type { ShellProcess } from '@deepseek-ai/dsh-shell' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import { observeProcessRecord } from '../src/observe.ts' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' const testToolSignal = new AbortController().signal const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-observe-spec-')) -/** Job harness plus the observation registry, with a fast pump for tests. */ +/** Job harness with a fast record pump for tests. */ async function setup() { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -29,12 +30,11 @@ async function setup() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) - await ctx.plugin(LocalActivityRegistry) await ctx.plugin(LocalSubprocessRuntime) ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - await ctx.plugin(ToolBash, { activityPollMs: 25 }) + await ctx.plugin(ToolBash, { recordPollMs: 25 }) return ctx } @@ -62,12 +62,12 @@ async function until(read: () => T | undefined, timeoutMs = 5_000): Promise chunk.text).join('') +function retainedText(ctx: Context, id: JobId, caller?: Agent): string { + return ctx.jobs.readRecord(id, 0, caller).chunks.map(chunk => chunk.text).join('') } -describe('background bash observation', () => { - it('mirrors a background run into ctx.activities with correlation, live output, and settlement', async () => { +describe('background bash record', () => { + it('streams a background run into the job record with live output and settlement', async () => { const ctx = await setup() const ack = await call(ctx, { command: 'printf "line-1\\n"; sleep 0.4; printf "line-2\\n"', @@ -77,21 +77,16 @@ describe('background bash observation', () => { expect(text(ack)).toContain('started background job') const job = ctx.jobs.list()[0] expect(job).toBeDefined() + expect(job!.outputTotal).toBeDefined() - const snapshot = await until(() => - ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) - expect(snapshot.kind).toBe('bash') - expect(snapshot.label).toContain('printf') - expect(String(snapshot.correlation?.callId)).toMatch(/^observe-call-/) + // Live output appears in the record while the command is still running. + await until(() => retainedText(ctx, job!.id).includes('line-1') ? true : undefined) + expect(ctx.jobs.get(job!.id).status).toBe('running') - // Live output appears while the command is still running. - await until(() => retainedText(ctx, snapshot.id).includes('line-1') ? true : undefined) - expect(ctx.activities.get(snapshot.id).status).toBe('running') - - // Settlement carries the mapped bash outcome and the trailing output. - await until(() => ctx.activities.get(snapshot.id).status === 'completed' ? true : undefined) - expect(ctx.activities.get(snapshot.id).detail).toBe('exit code: 0') - expect(retainedText(ctx, snapshot.id)).toContain('line-2') + // Settlement ends the record with the job; the trailing bytes are drained first. + await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) + expect(ctx.jobs.get(job!.id).detail).toBe('exit code: 0') + expect(retainedText(ctx, job!.id)).toContain('line-2') // The model-facing consuming cursor still delivers everything: observation stole nothing. const collected = await until(() => { @@ -101,15 +96,13 @@ describe('background bash observation', () => { expect(collected).toContain('line-2') }) - it('a killed background job settles its mirrored process as killed', async () => { + it('a killed background job ends its record with the killed settlement', async () => { const ctx = await setup() await call(ctx, { command: 'sleep 60', description: 'test command', run_in_background: true }) const job = ctx.jobs.list()[0] - const snapshot = await until(() => - ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) ctx.jobs.kill(job!.id, undefined, 'test cleanup') - await until(() => ctx.activities.get(snapshot.id).status === 'killed' ? true : undefined) - expect(ctx.activities.get(snapshot.id).detail).toMatch(/signal|killed before exit/) + await until(() => ctx.jobs.get(job!.id).status === 'killed' ? true : undefined) + expect(ctx.jobs.get(job!.id).detail).toMatch(/signal|killed before exit/) }) it('labels stderr chunks with their channel', async () => { @@ -120,153 +113,53 @@ describe('background bash observation', () => { run_in_background: true, }) const job = ctx.jobs.list()[0] - const snapshot = await until(() => - ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) - await until(() => ctx.activities.get(snapshot.id).status === 'completed' ? true : undefined) - const chunks = ctx.activities.read(snapshot.id, 0).chunks + await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) + const chunks = ctx.jobs.readRecord(job!.id, 0).chunks expect(chunks.find(chunk => chunk.text.includes('out-line'))?.channel).toBe('stdout') expect(chunks.find(chunk => chunk.text.includes('err-line'))?.channel).toBe('stderr') }) +}) - it('a registry whose handle rejects appends degrades to a warning and still settles', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - await ctx.plugin(ToolTasks) - const ended: unknown[] = [] - await ctx.plugin({ - name: 'appending-broken-activities-probe', - apply(child: Context) { - child.provide('activities', { - open: () => ({ - id: 'bash-1', - append() { throw new Error('append boom') }, - updateDetail() {}, - end(outcome: unknown) { ended.push(outcome) }, - }), - }) +describe('observeProcessRecord', () => { + /** A RunningJob face recording appends for pump assertions. */ + function recordingJob(): { job: RunningJob; appends: string[] } { + const appends: string[] = [] + return { + job: { + id: 'bash-1' as JobId, + append(chunk) { appends.push(chunk) }, + updateDetail() {}, }, - }) - await ctx.plugin(LocalSubprocessRuntime) - ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } - await ctx.plugin(BashEnvPlugin) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - const warn = vi.fn() - ctx.logger.warn = warn as never - await ctx.plugin(ToolBash, { activityPollMs: 25 }) - await call(ctx, { command: 'echo tolerated', description: 'test command', run_in_background: true }) - await until(() => ended.length > 0 ? true : undefined) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation pump')) - expect(ended[0]).toMatchObject({ status: 'completed' }) + appends, + } + } + + it('degrades to no observation when the backend exposes no offset readers', async () => { + const ctx = new Context() + const { job, appends } = recordingJob() + const proc = { done: Promise.resolve() } as unknown as ShellProcess + await observeProcessRecord(ctx, job, proc, 25) + expect(appends).toEqual([]) }) - it('a pump failure waits for process settlement before mapping the outcome', async () => { + it('contains a pump failure with a warning; the job path is unaffected', async () => { const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - await ctx.plugin(ToolTasks) - const ended: unknown[] = [] - await ctx.plugin({ - name: 'early-failing-activities-probe', - apply(child: Context) { - child.provide('activities', { - open: () => ({ - id: 'bash-1', - append() { throw new Error('append boom') }, - updateDetail() {}, - end(outcome: unknown) { ended.push(outcome) }, - }), - }) - }, - }) - await ctx.plugin(LocalSubprocessRuntime) - ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } - await ctx.plugin(BashEnvPlugin) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) const warn = vi.fn() ctx.logger.warn = warn as never - await ctx.plugin(ToolBash, { activityPollMs: 25 }) - // One early line makes the first pump poll append (and fail) while the - // process still has most of its sleep ahead. - await call(ctx, { command: 'printf "early\\n"; sleep 0.7', description: 'test command', run_in_background: true }) - await until(() => warn.mock.calls.some(args => String(args[0]).includes('activity observation pump')) ? true : undefined) - // The pump already failed; the outcome must wait for real settlement - // rather than freezing a fake terminal state onto the running process. - expect(ended).toHaveLength(0) - await until(() => ended.length > 0 ? true : undefined) - expect(ended[0]).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) - }) - - it('a throwing end() is swallowed by the settlement-mapping catch', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - await ctx.plugin(ToolTasks) - await ctx.plugin({ - name: 'end-throwing-activities-probe', - apply(child: Context) { - child.provide('activities', { - open: () => ({ - id: 'bash-1', - append() {}, - updateDetail() {}, - end() { throw new Error('end boom') }, - }), - }) + const { job } = recordingJob() + const proc = { + done: new Promise((resolve) => { setTimeout(resolve, 10) }), + observed: { + stdout: { readFrom() { throw new Error('reader boom') } }, }, - }) - await ctx.plugin(LocalSubprocessRuntime) - ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } - await ctx.plugin(BashEnvPlugin) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - const warn = vi.fn() - ctx.logger.warn = warn as never - await ctx.plugin(ToolBash, { activityPollMs: 25 }) - await call(ctx, { command: 'echo settled', description: 'test command', run_in_background: true }) - await until(() => warn.mock.calls.some(args => String(args[0]).includes('activity settlement mapping')) ? true : undefined) - }) - - it('a throwing observation registry never breaks the background call', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - await ctx.plugin(ToolTasks) - await ctx.plugin({ - name: 'broken-activities-probe', - apply(child: Context) { - child.provide('activities', { open() { throw new Error('observation boom') } }) - }, - }) - await ctx.plugin(LocalSubprocessRuntime) - ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } - await ctx.plugin(BashEnvPlugin) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - const warn = vi.fn() - ctx.logger.warn = warn as never - await ctx.plugin(ToolBash, { activityPollMs: 25 }) - const ack = await call(ctx, { command: 'echo resilient', description: 'test command', run_in_background: true }) - expect(text(ack)).toContain('started background job') - expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation unavailable')) - // The job itself still completes and reads normally. - const job = ctx.jobs.list()[0] - const read = await until(() => { - const value = ctx.jobs.read(job!.id) - return value.text.includes('resilient') ? value : undefined - }) - expect(read.snapshot.status).toBe('completed') + } as unknown as ShellProcess + await observeProcessRecord(ctx, job, proc, 5) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('record observation pump for bash-1 failed')) }) }) -describe('owned background observation', () => { - it('mirrors an owned background run under the owning session', async () => { +describe('owned background record', () => { + it('fences an owned background run record under the owning session', async () => { const ctx = await setup() const owner = { id: SessionId('observe-owner'), @@ -284,8 +177,9 @@ describe('owned background observation', () => { }) const job = ctx.jobs.list(owner)[0] expect(job).toBeDefined() - const row = await until(() => ctx.activities.list(owner).find(item => item.correlation?.jobId === job!.id)) - expect(row.ownerSession).toBe(owner.id) + expect(job!.ownerSession).toBe(owner.id) await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined) + expect(() => retainedText(ctx, job!.id)).toThrow(/belongs to another session/) + await until(() => retainedText(ctx, job!.id, owner).includes('owned') ? true : undefined) }) }) diff --git a/packages/shell/tool-bash/tsconfig.json b/packages/shell/tool-bash/tsconfig.json index 8aa66f93ff..304d983a95 100644 --- a/packages/shell/tool-bash/tsconfig.json +++ b/packages/shell/tool-bash/tsconfig.json @@ -49,9 +49,6 @@ }, { "path": "../../sandbox/sandbox-policy" - }, - { - "path": "../../activity/activity" } ] } diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index 8ea7e6f1e2..cfd6af5fe0 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-tool-pwsh` gives the agent a `pwsh` tool that runs PowerShell commands through the mounted shell executor — the Windows counterpart of `dsh-tool-bash`, mirroring it call-for-call. Each call runs in a fresh pwsh process, so no state survives; `run_in_background` turns long-running commands into background jobs. Commands are PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables, with no dialect translation. Every call runs with the managed `DSH_*` environment, and under a sandboxing executor the tool teaches and enforces the Windows-specific language-mode and named-pipe contracts. Mount it with a PowerShell executor such as `dsh-pwsh-local` and the `dsh-shell-env` plugin. When the optional `ctx.activities` registry is loaded, a committed background run is also mirrored into it best-effort as a `pwsh` activity pumped at `activityPollMs` (default 150), exactly like `dsh-tool-bash`. +`dsh-tool-pwsh` gives the agent a `pwsh` tool that runs PowerShell commands through the mounted shell executor — the Windows counterpart of `dsh-tool-bash`, mirroring it call-for-call. Each call runs in a fresh pwsh process, so no state survives; `run_in_background` turns long-running commands into background jobs. Commands are PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables, with no dialect translation. Every call runs with the managed `DSH_*` environment, and under a sandboxing executor the tool teaches and enforces the Windows-specific language-mode and named-pipe contracts. Mount it with a PowerShell executor such as `dsh-pwsh-local` and the `dsh-shell-env` plugin. A background run's job declares an observation record pumped at `recordPollMs` (default 150), exactly like `dsh-tool-bash`. ## Table of Contents diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index b95e102218..49c3942a30 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -37,7 +37,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", @@ -59,7 +58,6 @@ "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts index 66c29b604f..a7926e1b34 100644 --- a/packages/shell/tool-pwsh/src/index.ts +++ b/packages/shell/tool-pwsh/src/index.ts @@ -32,11 +32,10 @@ import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' -import type { ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' +import type { ShellRunResult } from '@deepseek-ai/dsh-shell' import { parseExitStatus } from '@deepseek-ai/dsh-shell' -import type {} from '@deepseek-ai/dsh-activity' import { processOutcome } from './background.ts' -import { observeBackgroundActivity } from './observe.ts' +import { observeProcessRecord } from './observe.ts' import { renderPwshProcessRead, renderPwshResult } from './render.ts' import type { RenderablePwshResult } from './render.ts' @@ -46,12 +45,6 @@ declare module '@deepseek-ai/dsh-jobs' { } } -declare module '@deepseek-ai/dsh-activity' { - interface ActivityKindMap { - pwsh: 'pwsh' - } -} - export const name = 'tool-pwsh' export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] @@ -60,14 +53,14 @@ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ - activityPollMs?: number + /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ + recordPollMs?: number } /** Runtime configuration schema for the pwsh tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), - activityPollMs: z.number() + recordPollMs: z.number() .step(1) .min(1) .max(Number.MAX_SAFE_INTEGER) @@ -210,7 +203,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = { /* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */ export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true - const activityPollMs = config.activityPollMs ?? 150 + const recordPollMs = config.recordPollMs ?? 150 const defaultMode = ctx.shell.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') @@ -394,32 +387,23 @@ export function apply(ctx: Context, config: Config = {}): void { error.name = 'AbortError' throw error } - // Task preflight finishes before the starter can spawn a process. - let started: ShellProcess | undefined const id = jobs.start({ kind: 'pwsh', label: args.command, ...exec.agent ? { owner: exec.agent } : {}, - run: () => { + record: true, + run: (job) => { const proc = ctx.shell.start(ctx.shell.resolve(request)) - started = proc + // The pump's final drain is folded into `done` so the record holds + // its last bytes before settlement trims and closes it. + const observed = observeProcessRecord(ctx, job, proc, recordPollMs) return { cancel: () => void proc.kill(), - done: proc.done.then(() => processOutcome(proc)), + done: observed.then(() => proc.done).then(() => processOutcome(proc)), readOutput: () => renderPwshProcessRead(proc.readOutput(), proc.sandbox, escalationModes), } }, }) - // The starter ran synchronously inside the committed start; mirror the - // spawned process into the optional observation registry. - if (started !== undefined) { - observeBackgroundActivity(ctx, started, { - kind: 'pwsh', - label: args.command, - ...exec.agent ? { owner: exec.agent } : {}, - correlation: { callId: exec.callId, jobId: id }, - }, activityPollMs, processOutcome) - } return { kind: 'background' as const, jobId: id } } const result = await ctx.shell.run(ctx.shell.resolve({ diff --git a/packages/shell/tool-pwsh/src/observe.ts b/packages/shell/tool-pwsh/src/observe.ts index abf325c6b9..6e9e466981 100644 --- a/packages/shell/tool-pwsh/src/observe.ts +++ b/packages/shell/tool-pwsh/src/observe.ts @@ -1,84 +1,46 @@ /** - * Best-effort process-observation tap for background pwsh runs: mirrors the - * spawned process's captured streams into `ctx.activities` so non-consuming - * observers (the Web client) see live output without touching the job's - * consuming `readOutput` cursor. Absent registry or absent backend offset - * readers degrade to no observation; the job path is unaffected either way. + * Record tap for background pwsh runs: copies the spawned process's captured + * streams into the job's observation record, so non-consuming observers (the + * Web client) see live output without touching the job's consuming + * `readOutput` cursor. Observation is strictly best-effort: absent backend + * offset readers degrade to no observation, and a pump failure is logged and + * swallowed — the job path is unaffected either way. * * @module @deepseek-ai/dsh-tool-pwsh/observe */ /* jscpd:ignore-start -- mirrors tool-bash's observe tap by design, like render/background. */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' import type { ShellProcess } from '@deepseek-ai/dsh-shell' -import { pumpActivityOutput } from '@deepseek-ai/dsh-activity' -import type { ActivityCorrelation, ActivityKind, ActivityPumpSource } from '@deepseek-ai/dsh-activity' - -/** Identity of one observed background run. */ -export interface ObserveBackgroundSpec { - /** Process kind — the producing tool's registered {@link ActivityKind}. */ - kind: ActivityKind - /** One-line label (the command). */ - label: string - /** Owning live agent, when the call has one. */ - owner?: Agent - /** Links to the starting tool call and the registered job. */ - correlation: ActivityCorrelation -} +import { pumpJobOutput } from '@deepseek-ai/dsh-jobs' +import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs' /** - * Open a process beside a committed background job and pump the handle's - * non-consuming offset readers into it until the run settles, then record the - * mapped outcome. Observation is strictly best-effort: without a - * `ctx.activities` registry this is a no-op, without backend offset readers - * the process still carries status and settlement, and ANY observation - * failure — including a rejected `open()` — is logged and swallowed, because - * the background job is already committed and running. - * @param ctx - plugin context used for the optional registry and warning logs. + * Pump the process's non-consuming offset readers into the job record until + * the run settles, then drain once more. The returned promise never rejects: + * a pump failure is logged and the record simply stops advancing, while the + * process and its job continue untouched. + * @param ctx - plugin context used for the warning log. + * @param job - the running job's producer face receiving the copied chunks. * @param proc - the started background process handle. - * @param spec - process identity and correlation for the registry row. * @param pollMs - producer-configured pump cadence in milliseconds. - * @param outcome - maps the settled handle to the process outcome. + * @returns resolves after the final drain; fold into the job's `done` chain so + * the record holds its final bytes before settlement closes it. */ -export function observeBackgroundActivity( +export function observeProcessRecord( ctx: Context, + job: RunningJob, proc: ShellProcess, - spec: ObserveBackgroundSpec, pollMs: number, - outcome: (proc: ShellProcess) => { status: 'completed' | 'killed'; detail: string }, -): void { - try { - const processes = ctx.get('activities') - if (processes === undefined) return - const handle = processes.open({ - kind: spec.kind, - label: spec.label, - ...spec.owner !== undefined ? { owner: spec.owner } : {}, - correlation: spec.correlation, +): Promise { + const sources: JobPumpSource[] = [] + const stdout = proc.observed?.stdout + if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) + const stderr = proc.observed?.stderr + if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) + return pumpJobOutput(job, sources, { pollMs, done: proc.done }) + .catch((error: unknown) => { + ctx.logger.warn(`record observation pump for ${job.id} failed: ${String(error)}`) }) - const sources: ActivityPumpSource[] = [] - const stdout = proc.observed?.stdout - if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) - const stderr = proc.observed?.stderr - if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) - void pumpActivityOutput(handle, sources, { pollMs, done: proc.done }) - .catch((error: unknown) => { - ctx.logger.warn(`activity observation pump for ${handle.id} failed: ${String(error)}`) - }) - .then(async () => { - // The pump can fail while the process is still running; the outcome - // mapping requires a settled handle, and `end` is first-wins, so an - // early mapping would freeze a wrong terminal state onto the row. - // `proc.done` never rejects. - await proc.done - handle.end(outcome(proc)) - }) - .catch((error: unknown) => { - ctx.logger.warn(`activity settlement mapping for ${handle.id} failed: ${String(error)}`) - }) - } catch (error: unknown) { - ctx.logger.warn(`activity observation unavailable for this run: ${String(error)}`) - } } /* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh/tests/observe.spec.ts b/packages/shell/tool-pwsh/tests/observe.spec.ts index c9024203fb..0fbdcb80de 100644 --- a/packages/shell/tool-pwsh/tests/observe.spec.ts +++ b/packages/shell/tool-pwsh/tests/observe.spec.ts @@ -8,7 +8,6 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' -import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' @@ -81,10 +80,9 @@ async function setup() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) - await ctx.plugin(LocalActivityRegistry) await ctx.plugin(BashEnvPlugin) await ctx.plugin(FakePwsh) - await ctx.plugin(ToolPwsh, { activityPollMs: 5 }) + await ctx.plugin(ToolPwsh, { recordPollMs: 5 }) return { ctx, pwsh: ctx.shell as FakePwsh } } @@ -108,8 +106,8 @@ async function until(read: () => T | undefined, timeoutMs = 5_000): Promise { - it('mirrors observed streams with channels and settles with the mapped outcome', async () => { +describe('background pwsh record', () => { + it('streams observed channels into the job record and settles with the mapped outcome', async () => { const { ctx, pwsh } = await setup() const stdout = { text: '' } const stderr = { text: '' } @@ -118,29 +116,32 @@ describe('background pwsh observation', () => { await call(ctx, { command: 'Get-Progress', description: 'test command', run_in_background: true }) const job = ctx.jobs.list()[0] - const row = await until(() => ctx.activities.list().find(item => item.correlation?.jobId === job!.id)) - expect(row.kind).toBe('pwsh') + expect(job).toBeDefined() + expect(job!.kind).toBe('pwsh') + expect(job!.outputTotal).toBe(0) stdout.text = 'progress-line\n' stderr.text = 'warn-line\n' await until(() => { - const chunks = ctx.activities.read(row.id, 0).chunks + const chunks = ctx.jobs.readRecord(job!.id, 0).chunks return chunks.some(chunk => chunk.text.includes('progress-line')) && chunks.some(chunk => chunk.text.includes('warn-line')) ? true : undefined }) - const chunks = ctx.activities.read(row.id, 0).chunks + const chunks = ctx.jobs.readRecord(job!.id, 0).chunks expect(chunks.find(chunk => chunk.text.includes('progress-line'))?.channel).toBe('stdout') expect(chunks.find(chunk => chunk.text.includes('warn-line'))?.channel).toBe('stderr') scripted.finish() - await until(() => ctx.activities.get(row.id).status === 'completed' ? true : undefined) - expect(ctx.activities.get(row.id).detail).toBe('exit code: 0') + await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) + expect(ctx.jobs.get(job!.id).detail).toBe('exit code: 0') }) - it('a pump failure waits for process settlement before mapping the outcome', async () => { + it('a pump failure warns and never fakes a terminal state onto the running job', async () => { const { ctx, pwsh } = await setup() + const warn = vi.fn() + ctx.logger.warn = warn as never let resolveDone: () => void = () => {} const done = new Promise((resolve) => { resolveDone = resolve }) const proc: ShellProcess = { @@ -157,70 +158,36 @@ describe('background pwsh observation', () => { } pwsh.backgroundHandler = () => proc await call(ctx, { command: 'Get-Broken', description: 'test command', run_in_background: true }) - const row = await until(() => ctx.activities.list()[0]) - // The pump rejected on its first poll; the still-running process must not - // be mapped to a terminal state until it actually settles. + const job = ctx.jobs.list()[0] + await until(() => warn.mock.calls.some(args => String(args[0]).includes('record observation pump')) ? true : undefined) + // The pump already failed; the job must wait for real settlement rather + // than freezing a fake terminal state onto the running process. await new Promise(resolve => setTimeout(resolve, 50)) - expect(ctx.activities.list()[0]?.status).toBe('running') + expect(ctx.jobs.get(job!.id).status).toBe('running') proc.status = 'completed' proc.exitCode = 0 resolveDone() - await until(() => ctx.activities.list()[0]?.status !== 'running' ? true : undefined) - expect(ctx.activities.list()[0]).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) - expect(String(row.id)).toContain('pwsh') + await until(() => ctx.jobs.get(job!.id).status !== 'running' ? true : undefined) + expect(ctx.jobs.get(job!.id)).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) }) - it('a throwing end() is swallowed by the settlement-mapping catch', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - await ctx.plugin(ToolTasks) - await ctx.plugin({ - name: 'end-throwing-activities-probe', - apply(child: Context) { - child.provide('activities', { - open: () => ({ - id: 'pwsh-1', - append() {}, - updateDetail() {}, - end() { throw new Error('end boom') }, - }), - }) - }, - }) - await ctx.plugin(BashEnvPlugin) - await ctx.plugin(FakePwsh) - await ctx.plugin(ToolPwsh, { activityPollMs: 5 }) - const pwsh = ctx.shell as FakePwsh - const warn = vi.fn() - ctx.logger.warn = warn as never - const handle = observableProcess({ stdout: { text: '' }, stderr: { text: '' } }) - pwsh.backgroundHandler = () => handle.proc - await call(ctx, { command: 'Get-Doom', description: 'test command', run_in_background: true }) - handle.finish() - await until(() => warn.mock.calls.some(args => String(args[0]).includes('activity settlement mapping')) ? true : undefined) - }) - - it('a backend without observed readers still yields a status-only process', async () => { + it('a backend without observed readers still yields a status-only record', async () => { const { ctx, pwsh } = await setup() const scripted = observableProcess() pwsh.backgroundHandler = () => scripted.proc await call(ctx, { command: 'Start-Job', description: 'test command', run_in_background: true }) const job = ctx.jobs.list()[0] - const row = await until(() => ctx.activities.list().find(item => item.correlation?.jobId === job!.id)) - expect(ctx.activities.read(row.id, 0).chunks).toEqual([]) + expect(ctx.jobs.readRecord(job!.id, 0).chunks).toEqual([]) scripted.finish() - await until(() => ctx.activities.get(row.id).status === 'completed' ? true : undefined) - expect(ctx.activities.get(row.id).outputTotal).toBe(0) + await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) + expect(ctx.jobs.get(job!.id).outputTotal).toBe(0) }) }) -describe('owned and degraded observation (pwsh)', () => { - it('mirrors an owned background run under the owning session', async () => { +describe('owned background record (pwsh)', () => { + it('keeps an owned background run record under the owning session', async () => { const { ctx, pwsh } = await setup() const owner = { id: SessionId('pwsh-observe-owner'), @@ -240,40 +207,10 @@ describe('owned and degraded observation (pwsh)', () => { }) const job = ctx.jobs.list(owner)[0] expect(job).toBeDefined() - const row = await until(() => ctx.activities.list(owner).find(item => item.correlation?.jobId === job!.id)) - expect(row.ownerSession).toBe(owner.id) + expect(job!.ownerSession).toBe(owner.id) + expect(() => ctx.jobs.readRecord(job!.id, 0)).toThrow(/belongs to another session/) + expect(ctx.jobs.readRecord(job!.id, 0, owner).chunks).toEqual([]) scripted.finish() await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined) }) - - it('swallows a throwing observation registry and keeps the job alive', async () => { - // The FakePwsh composition with a synchronously throwing open(): the - // failure lands in the outer observation catch and the job runs on. - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - await ctx.plugin(ToolTasks) - await ctx.plugin({ - name: 'broken-activities-probe', - apply(child: Context) { - child.provide('activities', { open() { throw new Error('observation boom') } }) - }, - }) - await ctx.plugin(BashEnvPlugin) - await ctx.plugin(FakePwsh) - const warn = vi.fn() - ctx.logger.warn = warn as never - await ctx.plugin(ToolPwsh, { activityPollMs: 5 }) - const pwsh = ctx.shell as FakePwsh - const scripted = observableProcess() - pwsh.backgroundHandler = () => scripted.proc - await call(ctx, { command: 'Get-Slow', description: 'test command', run_in_background: true }) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation unavailable')) - const job = ctx.jobs.list()[0] - expect(job?.status).toBe('running') - scripted.finish() - await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) - }) }) diff --git a/packages/shell/tool-pwsh/tsconfig.json b/packages/shell/tool-pwsh/tsconfig.json index d38c8f031d..59a4d9478c 100644 --- a/packages/shell/tool-pwsh/tsconfig.json +++ b/packages/shell/tool-pwsh/tsconfig.json @@ -52,9 +52,6 @@ }, { "path": "../../runtime-diagnostics/invariants" - }, - { - "path": "../../activity/activity" } ] } diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md index a7096838da..2291b274a5 100644 --- a/packages/workflow/tool-workflow/README.md +++ b/packages/workflow/tool-workflow/README.md @@ -68,7 +68,7 @@ The consumer owns the model-facing schema, the `tool:` system-prompt g For a root transport execution (`exec.parent` absent), the tool projects the run into the calling Agent's Session with four log-only events: run-start after `start()` returns, member starts and endings filtered by `run.id`, then run-end only after the result is available and disposal reaches quiescence. Nested transport calls execute normally but write no record. The first failed Session append disables later recording for that run with one warning, leaving either no record or a legal continuous prefix without changing the tool result or cleanup. The package invariant rejects duplicate starts, unpaired members, terminal events with open members, and updates after run-end on both cold load and live append, while accepting missing terminal suffixes. -Beside the durable record, each recorded top-level run is mirrored best-effort into the optional `ctx.activities` registry as a `workflow` activity: the otherwise-unconsumed `workflow/phase` and `workflow/log` events plus member lifecycle lines stream into it, so the Web client shows the run's live narration the session log deliberately does not carry per line. Without the registry, or on any mirror failure, the run is unaffected. +The engine's `workflow/phase` and `workflow/log` events have no per-line durable or observer surface from this tool: the session log deliberately records run and member lifecycle only, and the Web transcript derives from those records. ### Render intent @@ -80,7 +80,6 @@ Decided up front per the [render-intent Agent Note](../../../.agents/notes/imple |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: tool registration, run lifecycle, recorder wiring | | [`src/types.ts`](src/types.ts) | The four log-only record event payloads and their `SessionEventMap` declaration | -| [`src/activity.ts`](src/activity.ts) | Best-effort activity mirror for top-level runs | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion: durable workflow-record protocol validation | diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index a9707f1456..4f148077b5 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -38,7 +38,6 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -52,8 +51,6 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", - "@deepseek-ai/dsh-activity-local": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/workflow/tool-workflow/src/activity.ts b/packages/workflow/tool-workflow/src/activity.ts deleted file mode 100644 index 36b251b894..0000000000 --- a/packages/workflow/tool-workflow/src/activity.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Best-effort activity mirror for top-level workflow runs: opens one - * `workflow` activity per run and streams the engine's `workflow/phase`, - * `workflow/log`, and member lifecycle events into it as text lines, so the - * Web client shows live progress the session log deliberately does not carry - * per line. Without a `ctx.activities` registry every operation is a no-op, - * and ANY mirror failure is logged and swallowed — the run is unaffected. - * - * @module @deepseek-ai/dsh-tool-workflow/activity - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ActivityHandle } from '@deepseek-ai/dsh-activity' -import type { WorkflowRun, WorkflowRunId, WorkflowStopReason } from '@deepseek-ai/dsh-workflow' - -declare module '@deepseek-ai/dsh-activity' { - interface ActivityKindMap { - workflow: 'workflow' - } -} - -/** Activity taps for the three points the tool already tracks a run at. */ -export interface WorkflowActivityMirror { - /** - * Open the run's activity. Call once per recorded top-level run. - * @param run - the started run (id and meta name). - * @param owner - the calling agent, when the call has one. - */ - start(run: WorkflowRun, owner: Agent | undefined): void - /** - * End the run's activity with the mapped stop reason. - * @param runId - the settled run. - * @param stopReason - how the run stopped. - */ - finish(runId: WorkflowRunId, stopReason: WorkflowStopReason): void - /** - * Drop tracking for a run that ends without a settled reason. First-wins - * with {@link finish}: after a normal finish this is inert. - * @param runId - the abandoned run. - */ - abandon(runId: WorkflowRunId): void -} - -/** Map a run's stop reason onto the activity outcome vocabulary. */ -function outcomeOf(stopReason: WorkflowStopReason): { status: 'completed' | 'failed' | 'killed'; detail: string } { - switch (stopReason) { - case 'completed': return { status: 'completed', detail: 'completed' } - case 'cancelled': return { status: 'killed', detail: 'cancelled' } - case 'error': return { status: 'failed', detail: 'error' } - } -} - -/** - * Create the run-to-activity mirror and subscribe the engine's live progress - * events for the runs it tracks. - * @param ctx - plugin context used for the optional registry and warning logs. - * @returns the mirror taps the tool wires beside its durable recorder. - */ -export function createWorkflowActivityMirror(ctx: Context): WorkflowActivityMirror { - const active = new Map() - const contained = (operation: string, act: () => void): void => { - try { - act() - } catch (error: unknown) { - ctx.logger.warn(`workflow activity mirror ${operation} failed: ${String(error)}`) - } - } - - ctx.on('workflow/phase', (info, title) => { - const handle = active.get(info.id) - if (handle === undefined) return - contained('phase', () => { - handle.updateDetail(title) - handle.append(`▸ ${title}\n`) - }) - }) - ctx.on('workflow/log', (info, message) => { - const handle = active.get(info.id) - if (handle === undefined) return - contained('log', () => { handle.append(`${message}\n`) }) - }) - ctx.on('workflow/agent-start', (info, agent) => { - const handle = active.get(info.id) - if (handle === undefined) return - contained('agent-start', () => { handle.append(`agent #${agent.seq} ${agent.label} started\n`) }) - }) - ctx.on('workflow/agent-end', (info, agent) => { - const handle = active.get(info.id) - if (handle === undefined) return - contained('agent-end', () => { handle.append(`agent #${agent.seq} ${agent.outcome}\n`) }) - }) - - return { - start(run, owner) { - contained('open', () => { - const activities = ctx.get('activities') - if (activities === undefined) return - active.set(run.id, activities.open({ - kind: 'workflow', - label: run.meta.name, - ...owner !== undefined ? { owner } : {}, - })) - }) - }, - finish(runId, stopReason) { - const handle = active.get(runId) - active.delete(runId) - if (handle === undefined) return - contained('finish', () => { handle.end(outcomeOf(stopReason)) }) - }, - abandon(runId) { - const handle = active.get(runId) - active.delete(runId) - if (handle === undefined) return - contained('abandon', () => { handle.end({ status: 'killed', detail: 'run abandoned' }) }) - }, - } -} diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 022d8e4266..77e21672b4 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -24,7 +24,6 @@ import type { ToolWorkflowAgentEndData, ToolWorkflowAgentStartData, ToolWorkflowRunEndData, ToolWorkflowRunStartData, } from './types.ts' -import { createWorkflowActivityMirror } from './activity.ts' export const name = 'tool-workflow' export const inject = ['tools', 'workflowEngine', 'systemPrompt'] @@ -207,7 +206,6 @@ export function apply(ctx: Context, config: Config): void { // fields; the assertion records that resolution, not a hidden fallback. const { toolName, maxResultChars } = config as ResolvedConfig const recorder = createWorkflowRecorder(ctx) - const mirror = createWorkflowActivityMirror(ctx) // Usage policy ships with the tool (the master convention: tool guidance // lives in tool plugins as prompt sections, not in the deployment persona). ctx.systemPrompt.section({ @@ -294,7 +292,6 @@ export function apply(ctx: Context, config: Config): void { // worker messages, after start() returns and this run record is active. if (recordsRun) { recorder.start(parent.session, run) - mirror.start(run, parent) } // Bridge the tool's abort signal to the run: if the parent step is aborted while the @@ -327,12 +324,10 @@ export function apply(ctx: Context, config: Config): void { /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */ if (result === undefined) throw new Error('workflow run settled without a result') recorder.finish(run.id, result.stopReason) - mirror.finish(run.id, result.stopReason) } } finally { if (recordsRun) { recorder.abandon(run.id) - mirror.abandon(run.id) } } } diff --git a/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts deleted file mode 100644 index 8f82cb2841..0000000000 --- a/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' -import { SessionId } from '@deepseek-ai/dsh-session' -import type { WorkflowRun, WorkflowRunId, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' -import { createWorkflowActivityMirror } from '../src/activity.ts' - -function runStub(id = 'run-1'): { run: WorkflowRun; info: WorkflowRunInfo } { - const info = { id: id as WorkflowRunId, meta: { name: 'docs-sweep' } } as WorkflowRunInfo - return { - run: { id: id as WorkflowRunId, meta: { name: 'docs-sweep' } } as WorkflowRun, - info, - } -} - -function retainedText(ctx: Context): string { - const row = ctx.activities.list()[0] - if (row === undefined) throw new Error('no mirrored activity') - return ctx.activities.read(row.id, 0).chunks.map(chunk => chunk.text).join('') -} - -describe('workflow activity mirror', () => { - it('opens one workflow activity per run and streams phase, log, and member lines', async () => { - const ctx = new Context() - await ctx.plugin(LocalActivityRegistry) - const mirror = createWorkflowActivityMirror(ctx) - const { run, info } = runStub() - mirror.start(run, undefined) - - const row = ctx.activities.list()[0] - expect(row?.kind).toBe('workflow') - expect(row?.label).toBe('docs-sweep') - - ctx.emit('workflow/phase', info, 'Scan') - ctx.emit('workflow/log', info, '3 files queued') - ctx.emit('workflow/agent-start', info, { seq: 1, label: 'reader', childId: 'c1' } as never) - ctx.emit('workflow/agent-end', info, { seq: 1, label: 'reader', childId: 'c1', outcome: 'completed' } as never) - expect(ctx.activities.get(row!.id).detail).toBe('Scan') - expect(retainedText(ctx)).toBe('▸ Scan\n3 files queued\nagent #1 reader started\nagent #1 completed\n') - - mirror.finish(run.id, 'completed') - const settled = ctx.activities.get(row!.id) - expect(settled.status).toBe('completed') - // The always-run abandon after a normal finish is inert. - mirror.abandon(run.id) - expect(ctx.activities.get(row!.id).status).toBe('completed') - }) - - - it('opens an owned run under the owning agent session', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalActivityRegistry) - const owner = { - id: SessionId('workflow-owner'), - session: { id: SessionId('workflow-owner') }, - status: 'idle', - ctx, - } as unknown as Agent - ctx.agents.register(owner) - const mirror = createWorkflowActivityMirror(ctx) - const { run } = runStub('run-owned') - mirror.start(run, owner) - // The owned row is fenced to its owner's view. - const row = ctx.activities.list(owner)[0] - expect(row?.ownerSession).toBe(owner.id) - mirror.finish(run.id, 'completed') - }) - it('maps cancelled and error stops, and ends an abandoned run as killed', async () => { - const ctx = new Context() - await ctx.plugin(LocalActivityRegistry) - const mirror = createWorkflowActivityMirror(ctx) - - const cancelled = runStub('run-c') - mirror.start(cancelled.run, undefined) - mirror.finish(cancelled.run.id, 'cancelled') - - const failed = runStub('run-e') - mirror.start(failed.run, undefined) - mirror.finish(failed.run.id, 'error') - - const orphaned = runStub('run-o') - mirror.start(orphaned.run, undefined) - mirror.abandon(orphaned.run.id) - - const byLabelOrder = ctx.activities.list() - expect(byLabelOrder.map(row => [row.status, row.detail])).toEqual([ - ['killed', 'cancelled'], - ['failed', 'error'], - ['killed', 'run abandoned'], - ]) - }) - - it('ignores events for untracked runs and stays inert without the registry', async () => { - const bare = new Context() - const inert = createWorkflowActivityMirror(bare) - const { run, info } = runStub() - // No registry: every tap is a no-op rather than a throw. - inert.start(run, undefined) - bare.emit('workflow/log', info, 'dropped') - inert.finish(run.id, 'completed') - inert.abandon(run.id) - - const ctx = new Context() - await ctx.plugin(LocalActivityRegistry) - createWorkflowActivityMirror(ctx) - // Events for a run the mirror never opened change nothing. - ctx.emit('workflow/phase', info, 'ghost') - expect(ctx.activities.list()).toEqual([]) - }) - - it('contains a throwing registry so the run path never breaks', async () => { - const ctx = new Context() - const warn = vi.fn() - ctx.logger.warn = warn as never - await ctx.plugin({ - name: 'broken-activities-probe', - apply(child: Context) { - child.provide('activities', { open() { throw new Error('mirror boom') } }) - }, - }) - const mirror = createWorkflowActivityMirror(ctx) - const { run } = runStub() - expect(() => { mirror.start(run, undefined) }).not.toThrow() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('workflow activity mirror open failed')) - }) -}) diff --git a/packages/workflow/tool-workflow/tsconfig.json b/packages/workflow/tool-workflow/tsconfig.json index a4cde5cbef..bfd38e61d4 100644 --- a/packages/workflow/tool-workflow/tsconfig.json +++ b/packages/workflow/tool-workflow/tsconfig.json @@ -37,9 +37,6 @@ }, { "path": "../../runtime-diagnostics/invariants" - }, - { - "path": "../../activity/activity" } ] } From 6e8a7a50ea85d3f9835a57f064fb9cc4ed6156d0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 18:03:09 +0800 Subject: [PATCH 015/284] feat(api): serve job-record observation from the session namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionJob rows carry outputTotal exactly for record-declaring jobs, and a new session.observeJob stream serves one job's retained record from an absolute offset (anchor, coalesced output frames, terminal status) with the fenced read resolved from the request's session — the same authorization shape as the jobs roster. The client half gains ctx.jobOutput: the reference-counted per-job observation service and its accumulated view model, ported from the activity controller this replaces. --- .../session-controller/src/client/index.ts | 4 + .../src/client/job-output.ts | 348 +++++++++++++++ .../api/session-controller/src/control.ts | 1 + packages/api/session-controller/src/index.ts | 44 ++ .../api/session-controller/src/observe-job.ts | 189 ++++++++ packages/api/session-controller/src/types.ts | 63 +++ .../tests/control-jobs.host.spec.ts | 19 + .../tests/job-output.client.spec.ts | 413 ++++++++++++++++++ .../tests/observe-job.host.spec.ts | 298 +++++++++++++ .../session-controller/tests/test-remote.ts | 4 + .../api/session-controller/tsconfig.host.json | 1 + 11 files changed, 1384 insertions(+) create mode 100644 packages/api/session-controller/src/client/job-output.ts create mode 100644 packages/api/session-controller/src/observe-job.ts create mode 100644 packages/api/session-controller/tests/job-output.client.spec.ts create mode 100644 packages/api/session-controller/tests/observe-job.host.spec.ts diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 4bad2bb8c2..78a54b37ef 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent/types' import { createSessionControlStream } from './transport.ts' +import { ClientJobOutput, ClientJobOutputModel } from './job-output.ts' import { ClientSessions } from './sessions/service.ts' import type { SessionRemotes } from './sessions/remotes.ts' import type {} from '../remote-events.ts' @@ -23,6 +24,8 @@ export type { } from './transport.ts' export { createScope, scopeOf } from './scope.ts' export type { AgentContext, AgentScopeHandle } from './scope.ts' +export { ClientJobOutput, ClientJobOutputModel } from './job-output.ts' +export type { IJobOutput, JobOutputSnapshot, JobOutputSource, ObservedJob } from './job-output.ts' export { SessionCreateError, SessionForkError } from './sessions/service.ts' export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' export type { @@ -89,6 +92,7 @@ export const inject = [ export function apply(ctx: Context): void { const remotes = ctx.remote as unknown as SessionRemotes const sessions = new ClientSessions(ctx, remotes) + new ClientJobOutput(ctx, remotes, new ClientJobOutputModel()) ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) }) ctx.remote.$on('api-session/status', (sessionId, running) => { diff --git a/packages/api/session-controller/src/client/job-output.ts b/packages/api/session-controller/src/client/job-output.ts new file mode 100644 index 0000000000..933c104497 --- /dev/null +++ b/packages/api/session-controller/src/client/job-output.ts @@ -0,0 +1,348 @@ +/** + * React-free client job-record observation: per-job accumulated output views + * over the `session.observeJob` stream, reference-counted per job so + * overlapping panels share one stream. The roster itself rides the session + * control stream (`jobsBySession`); this module carries only the output. + * @module @deepseek-ai/dsh-api-session-controller/client/job-output + */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionJob, SessionObserveJobFrame } from '../types.ts' +import type { SessionRemote } from './transport.ts' + +/** Bounded per-job render tail, in UTF-16 code units. */ +const RENDER_TAIL_LIMIT = 128 * 1024 + +/** One observed job's live view state. */ +export interface ObservedJob { + readonly jobId: JobId + /** Accumulated output tail, bounded to the render limit. */ + readonly text: string + /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ + readonly gapBefore: boolean + /** Latest status delivered on the observation stream. */ + readonly status: SessionJob['status'] + readonly detail?: string + /** True while the observation stream is open and the job has not settled. */ + readonly streaming: boolean + /** Terminal observation failure, when the stream ended abnormally. */ + readonly error?: string +} + +/** Immutable client job-output state. */ +export interface JobOutputSnapshot { + /** Live observation state keyed by job id. */ + readonly observed: Readonly> +} + +/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */ +interface ObservedState { + view: ObservedJob + /** Resume offset for the next generation's `from`. */ + cursor: number | undefined +} + +/** + * Owns per-job observation state. Pure data plus subscriptions — transport + * wiring stays in the client service, UI stays in slot components. + */ +export class ClientJobOutputModel { + private readonly observedStates = new Map() + private readonly listeners = new Set<() => void>() + private snapshotCache: JobOutputSnapshot = { observed: {} } + private snapshotDirty = false + + /** + * Read the identity-stable current snapshot. + * @returns the cached snapshot, rebuilt only after a change. + */ + getSnapshot(): JobOutputSnapshot { + if (this.snapshotDirty) { + const observed: Record = {} + for (const [id, state] of this.observedStates) observed[id] = state.view + this.snapshotCache = { observed } + this.snapshotDirty = false + } + return this.snapshotCache + } + + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * The resume offset for one job's next observation generation. + * @param id - observed job. + * @returns the last accepted `next`, or undefined for a fresh observation. + */ + cursorOf(id: JobId): number | undefined { + return this.observedStates.get(String(id))?.cursor + } + + /** + * Install or reset observation state when a generation's anchor arrives. + * @param id - observed job. + * @param frame - the generation's `opened` anchor. + */ + observeOpened(id: JobId, frame: Extract): void { + const existing = this.observedStates.get(String(id)) + // A fresh view anchored past offset zero starts after an evicted head + // (fresh observations anchor at the registry's earliest retained byte), so + // it owes the same gap mark a live observer earned from lossy reads. A + // resume that already accumulated text keeps its recorded gap state. + const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0 + const view: ObservedJob = { + jobId: id, + text: existing?.view.text ?? '', + gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead, + status: frame.status, + ...frame.detail !== undefined ? { detail: frame.detail } : {}, + streaming: true, + } + this.observedStates.set(String(id), { view, cursor: frame.from }) + this.changed() + } + + /** + * Append one output frame's chunks to the bounded render tail. + * @param id - observed job. + * @param frame - a coalesced `output` frame. + */ + observeOutput(id: JobId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('') + let gapBefore = state.view.gapBefore || frame.lossy === true + || frame.chunks.some(chunk => chunk.gapBefore === true) + if (text.length > RENDER_TAIL_LIMIT) { + let cut = text.length - RENDER_TAIL_LIMIT + // Never split a surrogate pair at the render bound. + const unit = text.charCodeAt(cut) + if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1 + text = text.slice(cut) + gapBefore = true + } + state.view = { ...state.view, text, gapBefore } + state.cursor = frame.next + this.changed() + } + + /** + * Record the terminal status delivered at the end of the observation stream. + * @param id - observed job. + * @param frame - the terminal `status` frame. + */ + observeStatus(id: JobId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + state.view = { + ...state.view, + status: frame.status, + ...frame.detail !== undefined ? { detail: frame.detail } : {}, + streaming: false, + } + this.changed() + } + + /** + * Record a terminal observation failure. + * @param id - observed job. + * @param error - the stream's terminal failure. + */ + observeFailed(id: JobId, error: unknown): void { + const state = this.observedStates.get(String(id)) + if (state === undefined) return + state.view = { ...state.view, streaming: false, error: String(error) } + this.changed() + } + + /** + * Drop observation state after the last observer stops. + * @param id - the no-longer-observed job. + */ + observeStopped(id: JobId): void { + if (!this.observedStates.delete(String(id))) return + this.changed() + } + + private changed(): void { + this.snapshotDirty = true + notifySubscribers(this.listeners, 'job output') + } +} + +/** Bare observable source for the client job-output snapshot. */ +export interface JobOutputSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): JobOutputSnapshot + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void +} + +/** The job-output client service face. */ +export interface IJobOutput { + /** Per-job observation state. */ + readonly state: JobOutputSource + /** + * Start observing one job's live record; reference-counted, so two viewers + * of the same job share one stream. + * @param sessionId - owning session used for the fenced read; undefined for an unowned job. + * @param id - job to observe. + * @returns stop function releasing this observer's reference. + */ + observe(sessionId: SessionId | undefined, id: JobId): () => void +} + +/** One reference-counted observation stream. */ +interface ObservationEntry { + refs: number + stopped: boolean + dispose: () => Promise +} + +/** Remote face the observation runner drives. */ +type JobObserveRemote = Pick & { readonly session: Pick } + +declare module '@deepseek-ai/cordis' { + interface Context { + /** React-free client job-record observation control. */ + jobOutput: IJobOutput + } +} + +/** Owns the bare job-output snapshot and per-job observation streams. */ +export class ClientJobOutput extends Service implements IJobOutput { + readonly state: JobOutputSource + private readonly entries = new Map() + + /** + * @param ctx - client root Context. + * @param remote - generated session namespace plus the Gateway stream factory. + * @param model - shared client job-output model. + */ + constructor( + ctx: Context, + private readonly remote: JobObserveRemote, + private readonly model: ClientJobOutputModel, + ) { + super(ctx, 'jobOutput') + this.state = model + ctx.effect(() => () => { + const open = [...this.entries.values()] + this.entries.clear() + for (const entry of open) { + entry.stopped = true + void entry.dispose() + } + }, 'session-controller.client.observations') + } + + observe(sessionId: SessionId | undefined, id: JobId): () => void { + const key = String(id) + const existing = this.entries.get(key) + if (existing !== undefined && !existing.stopped) { + existing.refs += 1 + return this.releaser(key, existing) + } + const entry = this.startObservation(sessionId, id) + this.entries.set(key, entry) + return this.releaser(key, entry) + } + + /** + * Release closures bind the exact entry they were minted for, never the + * map's current occupant: a later `observe()` on the same id may have + * replaced a stopped entry, and decrementing or disposing through the key + * alone would tear down that newer stream's references. + */ + private releaser(key: string, entry: ObservationEntry): () => void { + let released = false + return () => { + if (released) return + released = true + entry.refs -= 1 + if (entry.refs > 0) return + if (this.entries.get(key) === entry) this.entries.delete(key) + entry.stopped = true + void entry.dispose().then(() => { + // Clear the view only while no successor observation holds the key: + // a re-expand inside the dispose round-trip already re-anchored the + // model, and a stale clear would blank its panel for good. + if (this.entries.has(key)) return + // The key is the stringified branded id this releaser was minted for. + this.model.observeStopped(key as JobId) + }) + } + } + + private startObservation(sessionId: SessionId | undefined, id: JobId): ObservationEntry { + const name = `job observation ${String(id)}` + const stream = this.remote.$stream({ + name, + open: (signal) => { + const from = this.model.cursorOf(id) + return this.remote.session.observeJob( + { + jobId: id, + ...sessionId !== undefined ? { sessionId } : {}, + ...from !== undefined ? { from } : {}, + }, + signal, + ) + }, + // A premature end after the anchor is retryable (a Host reload closes the + // generation); resuming from the cursor loses nothing. An end before the + // anchor is terminal. + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${name} ended before settlement`) + : new Error(`${name} ended before its anchor`), + }) + const entry: ObservationEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + } + void (async () => { + try { + for await (const item of stream) { + const frame = item.value + if (frame.type === 'opened') { + this.model.observeOpened(id, frame) + item.accept() + continue + } + if (frame.type === 'output') { + this.model.observeOutput(id, frame) + continue + } + // Terminal status: leave the loop before the generation end is + // classified, then close the stream for good. + this.model.observeStatus(id, frame) + break + } + } catch (error) { + if (!entry.stopped) this.model.observeFailed(id, error) + } finally { + entry.stopped = true + void entry.dispose() + } + })() + return entry + } +} diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 1a03011644..aa9f0a77be 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -212,5 +212,6 @@ function jobView(job: JobSnapshot): SessionJob { ...(job.detail === undefined ? {} : { detail: job.detail }), startedAt: job.startedAt, ...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }), + ...(job.outputTotal === undefined ? {} : { outputTotal: job.outputTotal }), } } diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 572f895754..99dad0a75e 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -2,6 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/dsh-jobs' import { errorChain } from '@deepseek-ai/dsh-llm' import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' @@ -15,6 +16,7 @@ import { import { SessionCommandController } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' +import { observeJobRecord } from './observe-job.ts' import { SessionFileReferences } from './file-references.ts' import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts' import { buildModelCatalog } from './catalog.ts' @@ -35,6 +37,8 @@ import type { SessionForkValue, SessionListRequest, SessionListValue, + SessionObserveJobFrame, + SessionObserveJobRequest, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, SessionPage, @@ -63,12 +67,22 @@ declare module '@deepseek-ai/cordis' { } } +/** Default coalescing window between job-observation reads, in milliseconds. */ +const DEFAULT_OBSERVE_FLUSH_MS = 100 + +/** Default soft byte budget per job-observation output frame. */ +const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024 + /** Session Controller deployment policy. */ export interface Config { /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean + /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number } /** Host integrations replaceable by direct unit tests. */ @@ -96,6 +110,8 @@ export class SessionController extends TypertRemoteService { static Config: z = z.object({ coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), nativeOpen: z.boolean(), + observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS), + observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES), }) private readonly agents: ApiSessionAgentController @@ -106,6 +122,8 @@ export class SessionController extends TypertRemoteService { private readonly openPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean private readonly promotions = new Set>() + private readonly observeFlushMs: number + private readonly observeMaxFrameBytes: number /** * @param ctx - Host context containing the Session capability assembly. @@ -130,6 +148,8 @@ export class SessionController extends TypertRemoteService { this.openPath = internals.openPath ?? openNativePath this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) + this.observeFlushMs = config.observeFlushMs ?? DEFAULT_OBSERVE_FLUSH_MS + this.observeMaxFrameBytes = config.observeMaxFrameBytes ?? DEFAULT_OBSERVE_MAX_FRAME_BYTES ctx.plugin(SessionFileReferences) ctx.plugin(SessionSkillCatalog) @@ -386,6 +406,30 @@ export class SessionController extends TypertRemoteService { return this.controlState.control(signal) } + /** + * Stream one job's retained record output from an absolute byte offset, + * then its terminal status once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session resolves the fenced-read caller; the registry rejects a + * job the session does not own, a job without a record, or an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ + @Remote({ mode: 'stream' }) + observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable { + const registry = this.ctx.get('jobs') + if (registry === undefined) { + throw new Error('job observation unavailable: no job registry is loaded (load @deepseek-ai/dsh-jobs-local)') + } + const caller = request.sessionId === undefined ? undefined : this.ctx.agents.get(request.sessionId) + return observeJobRecord(registry, request, { + flushMs: this.observeFlushMs, + maxFrameBytes: this.observeMaxFrameBytes, + caller, + }, signal) + } + } export { buildModelCatalog } diff --git a/packages/api/session-controller/src/observe-job.ts b/packages/api/session-controller/src/observe-job.ts new file mode 100644 index 0000000000..516f48779f --- /dev/null +++ b/packages/api/session-controller/src/observe-job.ts @@ -0,0 +1,189 @@ +/** Per-job record observation generations: anchor, coalesced output, terminal status. */ + +import type { Agent } from '@deepseek-ai/dsh-agent' +import { JobId } from '@deepseek-ai/dsh-jobs' +import type { JobRegistry, JobSnapshot } from '@deepseek-ai/dsh-jobs' +import type { SessionJobWireChunk, SessionObserveJobFrame, SessionObserveJobRequest } from './types.ts' + +/** Cadence and framing bounds for one observation generation. */ +export interface ObserveJobOptions { + /** Coalescing window after a wake before reading, in milliseconds. */ + readonly flushMs: number + /** + * Soft byte budget per `output` frame. Accumulated chunks flush once the + * budget is met; one chunk larger than the budget ships whole (the + * registry's retention cap is the hard bound on any single chunk). + */ + readonly maxFrameBytes: number + /** The live owning agent for fenced reads, resolved from the request's session. */ + readonly caller: Agent | undefined +} + +function isTerminal(status: JobSnapshot['status']): boolean { + return status !== 'running' && status !== 'stopping' +} + +/** A record job's total offset; the anchor check proved the declaration and it never leaves. */ +function recordTotal(snapshot: JobSnapshot): number { + /* v8 ignore next -- a declared record never loses its offsets. */ + return snapshot.outputTotal ?? 0 +} + +/** Wake-flag waiter: a wake between waits is never lost. */ +class OutputWaiter { + private dirty = false + private resolve: (() => void) | undefined + + wake(): void { + this.dirty = true + this.resolve?.() + } + + /** + * Resolve on the next wake, immediately when one already arrived, or on abort. + * @param signal - generation cancellation. + */ + wait(signal: AbortSignal): Promise { + if (this.dirty || signal.aborted) { + this.dirty = false + return Promise.resolve() + } + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + /* v8 ignore next -- one wait owns the sole installed resolver. */ + if (this.resolve === finish) this.resolve = undefined + this.dirty = false + resolve() + } + this.resolve = finish + signal.addEventListener('abort', finish, { once: true }) + }) + } +} + +function sleep(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + const timer = setTimeout(done, ms) + function done(): void { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + signal.addEventListener('abort', done, { once: true }) + }) +} + +/** + * Stream one job's retained record output from an absolute offset: one + * `opened` anchor, coalesced `output` frames as the record advances, then one + * terminal `status` after the settled job is drained, after which the + * generation closes normally. Reads are non-consuming — the model-facing + * cursor and notice state never observe them; reconnecting callers resume by + * passing the last frame's `next` as `from`. Throws for a job that declared + * no record. + * @param registry - the live job registry. + * @param request - target job and optional resume offset. + * @param options - cadence, framing bounds, and the fenced-read caller. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the observation frame sequence for one generation. + */ +export async function* observeJobRecord( + registry: JobRegistry, + request: SessionObserveJobRequest, + options: ObserveJobOptions, + signal: AbortSignal, +): AsyncIterable { + if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) { + throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`) + } + signal.throwIfAborted() + const id = JobId(String(request.jobId)) + const waiter = new OutputWaiter() + // Subscribe before the first read so an append between the anchor read and + // the wait cannot be missed. + const unsubscribe = registry.onOutput((changed) => { + if (changed === id) waiter.wake() + }) + try { + const caller = options.caller + let snapshot = registry.get(id, caller) + if (snapshot.outputTotal === undefined || snapshot.outputEarliest === undefined) { + throw new Error(`job ${id} declared no output record`) + } + let cursor = request.from ?? snapshot.outputEarliest + yield { + type: 'opened', + jobId: id, + from: cursor, + earliest: snapshot.outputEarliest, + total: snapshot.outputTotal, + status: snapshot.status, + ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, + } + while (!signal.aborted) { + const read = registry.readRecord(id, cursor, caller) + if (read.chunks.length > 0) { + yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes) + } + cursor = read.next + snapshot = registry.get(id, caller) + if (isTerminal(snapshot.status) && cursor >= recordTotal(snapshot)) { + yield { + type: 'status', + status: snapshot.status, + ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, + // Settlement always stamps finishedAt; the guard only discharges the optional field type. + /* v8 ignore next */ + ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, + } + return + } + await waiter.wait(signal) + // Let a burst accumulate so producer chatter becomes bounded frames. + await sleep(options.flushMs, signal) + } + } finally { + unsubscribe() + } +} + +/** Split one read into frames along the soft per-frame byte budget. */ +function* outputFrames( + chunks: readonly SessionJobWireChunk[], + next: number, + lossy: boolean, + maxFrameBytes: number, +): Iterable { + let batch: SessionJobWireChunk[] = [] + let batchBytes = 0 + let flaggedLossy = lossy + for (const chunk of chunks) { + batch.push(chunk) + batchBytes += Buffer.byteLength(chunk.text, 'utf8') + if (batchBytes >= maxFrameBytes) { + const last = batch[batch.length - 1] + /* v8 ignore start -- a non-empty batch always has a last chunk; the arm only discharges noUncheckedIndexedAccess. */ + const end = last === undefined ? next : last.at + Buffer.byteLength(last.text, 'utf8') + /* v8 ignore stop */ + yield { + type: 'output', + chunks: batch, + next: end, + ...flaggedLossy ? { lossy: true as const } : {}, + } + flaggedLossy = false + batch = [] + batchBytes = 0 + } + } + if (batch.length > 0 || flaggedLossy) { + yield { + type: 'output', + chunks: batch, + next, + ...flaggedLossy ? { lossy: true as const } : {}, + } + } +} diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c0b25fcf2c..cf59e29ad4 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -469,6 +469,12 @@ export interface SessionJob { readonly detail?: string readonly startedAt: number readonly finishedAt?: number + /** + * Total UTF-8 bytes the job's observation record holds so far. Present + * exactly when the job declared a record — the row is observable through + * `session.observeJob`. + */ + readonly outputTotal?: number } /** Complete live control baseline emitted once per control stream generation. */ @@ -493,6 +499,63 @@ export type SessionControlFrame = | { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] } | ({ readonly type: 'projection' } & SessionProjectionUpdate) +/** Opens one observation stream over a job's retained record output. */ +export interface SessionObserveJobRequest { + /** Owning session used for the fenced read; omitted for an unowned job. */ + readonly sessionId?: SessionId + readonly jobId: JobId + /** + * Absolute byte offset to resume from — a previous frame's `next`. Omitted + * starts at the oldest retained byte. + */ + readonly from?: number +} + +/** One retained record chunk on the wire. `channel` widens to string like `kind`. */ +export interface SessionJobWireChunk { + /** Absolute offset of the chunk's first byte. */ + readonly at: number + readonly text: string + readonly channel?: string + /** Bytes immediately before this chunk were lost at the producer or to retention. */ + readonly gapBefore?: true +} + +/** + * Job observation stream frames: one `opened` anchor, then coalesced `output` + * batches, then — once the job has settled and its retained record is + * drained — one terminal `status`, after which the stream closes normally. + * Status rides the same stream as output so settlement can never race a + * still-open output channel. + */ +export type SessionObserveJobFrame = + | { + readonly type: 'opened' + readonly jobId: JobId + /** Offset the first `output` frame continues from. */ + readonly from: number + /** Oldest retained byte at open time; `from < earliest` means the head is gone. */ + readonly earliest: number + /** Total bytes produced at open time. */ + readonly total: number + readonly status: SessionJob['status'] + readonly detail?: string + } + | { + readonly type: 'output' + readonly chunks: readonly SessionJobWireChunk[] + /** Offset to resume from after this frame. */ + readonly next: number + /** Bytes between the requested offset and `chunks` were already evicted. */ + readonly lossy?: true + } + | { + readonly type: 'status' + readonly status: SessionJob['status'] + readonly detail?: string + readonly finishedAt?: number + } + declare module '@deepseek-ai/cordis' { interface Events { /** diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts index f827573d03..61a9a8156a 100644 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ b/packages/api/session-controller/tests/control-jobs.host.spec.ts @@ -162,6 +162,25 @@ describe('Session control jobs updates', () => { ]) }) + it('carries outputTotal exactly for record-declaring jobs', async () => { + const { ctx, agent, control } = await harness(true) + const abort = new AbortController() + const collected = collectJobs(control.control(abort.signal), 1, abort) + ctx.jobs.start({ + kind: 'bash', + label: 'observable run', + owner: agent, + record: true, + run: (job) => { + job.append('hi') + return { cancel: () => {}, done: new Promise(() => {}) } + }, + }) + + const [frame] = await collected + expect(frame?.jobs[0]?.outputTotal).toBe(2) + }) + it('fans an unowned change out to every attached session', async () => { const { ctx, control } = await harness(true) const second = ctx.sessions.create() diff --git a/packages/api/session-controller/tests/job-output.client.spec.ts b/packages/api/session-controller/tests/job-output.client.spec.ts new file mode 100644 index 0000000000..a4ca2933dc --- /dev/null +++ b/packages/api/session-controller/tests/job-output.client.spec.ts @@ -0,0 +1,413 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { ClientJobOutput, ClientJobOutputModel } from '../src/client/job-output.ts' +import type { SessionObserveJobFrame } from '../src/types.ts' + +const ID = 'bash-1' as JobId + +function opened(model: ClientJobOutputModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void { + model.observeOpened(ID, { + type: 'opened', + jobId: ID, + from: 0, + earliest: 0, + total: 0, + status: 'running', + ...over, + }) +} + +describe('ClientJobOutputModel observation', () => { + it('accumulates output, advances the resume cursor, and settles on status', () => { + const model = new ClientJobOutputModel() + opened(model) + expect(model.cursorOf(ID)).toBe(0) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'a' }, { at: 1, text: 'b' }], next: 2 }) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 2, text: 'c' }], next: 3 }) + expect(model.cursorOf(ID)).toBe(3) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text).toBe('abc') + expect(view?.streaming).toBe(true) + model.observeStatus(ID, { type: 'status', status: 'completed', detail: 'exit code: 0' }) + const settled = model.getSnapshot().observed[String(ID)] + expect(settled?.streaming).toBe(false) + expect(settled?.status).toBe('completed') + expect(settled?.detail).toBe('exit code: 0') + }) + + it('keeps snapshot identity stable between changes and notifies subscribers', () => { + const model = new ClientJobOutputModel() + let notified = 0 + const unsubscribe = model.subscribe(() => { notified += 1 }) + const before = model.getSnapshot() + expect(model.getSnapshot()).toBe(before) + opened(model) + expect(notified).toBe(1) + expect(model.getSnapshot()).not.toBe(before) + unsubscribe() + model.observeStopped(ID) + expect(notified).toBe(1) + }) + + it('marks gaps from lossy frames, gap chunks, and a resume behind the retained head', () => { + const model = new ClientJobOutputModel() + opened(model, { from: 4, earliest: 8, total: 10 }) + expect(model.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + const clean = new ClientJobOutputModel() + opened(clean) + clean.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'x', gapBefore: true }], next: 1 }) + expect(clean.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + const lossy = new ClientJobOutputModel() + opened(lossy) + lossy.observeOutput(ID, { type: 'output', chunks: [], next: 5, lossy: true }) + expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + }) + + it('marks a fresh observation anchored past the evicted head', () => { + // Fresh observations anchor at the registry's earliest retained byte, so + // from === earliest > 0 means the head was already discarded. + const fresh = new ClientJobOutputModel() + opened(fresh, { from: 60_240, earliest: 60_240, total: 321_328 }) + expect(fresh.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + // A retry that accumulated no text yet earns the mark the same way. + const retried = new ClientJobOutputModel() + opened(retried) + expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(false) + opened(retried, { from: 6, earliest: 6, total: 6 }) + expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + }) + + it('bounds the render tail without splitting a surrogate pair', () => { + const model = new ClientJobOutputModel() + opened(model) + const emoji = '😀'.repeat((64 * 1024) + 8) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: emoji }], next: emoji.length * 2 }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.gapBefore).toBe(true) + expect(view!.text.length).toBeLessThanOrEqual(128 * 1024) + // The bound landed between pairs: the surviving text still round-trips. + expect(/^(?:😀)+$/u.test(view!.text)).toBe(true) + }) + + it('carries the anchor detail and trims a plain-ASCII tail without a boundary shift', () => { + const model = new ClientJobOutputModel() + model.observeOpened(ID, { + type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running', detail: 'exit soon', + }) + expect(model.getSnapshot().observed[String(ID)]?.detail).toBe('exit soon') + const long = 'x'.repeat((128 * 1024) + 5) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: long }], next: long.length }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text.length).toBe(128 * 1024) + expect(view?.gapBefore).toBe(true) + }) + + it('advances the cut past a low surrogate landing exactly on the bound', () => { + const model = new ClientJobOutputModel() + opened(model) + // 'z' + one emoji + odd ASCII tail puts a low surrogate exactly at the cut index. + const text = 'z😀' + 'a'.repeat((128 * 1024) - 1) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text }], next: text.length }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text.length).toBe((128 * 1024) - 1) + expect(view?.text.startsWith('a')).toBe(true) + }) + + it('preserves accumulated text across a reconnect anchor and clears on stop', () => { + const model = new ClientJobOutputModel() + opened(model) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'kept' }], next: 4 }) + opened(model, { from: 4, earliest: 0, total: 4 }) + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('kept') + model.observeStopped(ID) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + expect(model.cursorOf(ID)).toBeUndefined() + // A second stop is inert. + model.observeStopped(ID) + }) + + it('records a terminal stream failure on the live view', () => { + const model = new ClientJobOutputModel() + opened(model) + model.observeFailed(ID, new Error('carrier gone')) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.streaming).toBe(false) + expect(view?.error).toContain('carrier gone') + // A failure for an untracked id is inert. + model.observeFailed('bash-9' as JobId, new Error('ignored')) + }) +}) + +/** One scripted logical stream: frames are pushed by the test, never reopened. */ +class FakeStream { + disposed = false + /** Model a carrier whose disposal surfaces as an iterator throw. */ + throwOnDispose = false + /** Hold the dispose promise open until {@link releaseDispose}. */ + deferDispose = false + private disposeRelease: (() => void) | undefined + private disposePending: Promise | undefined + private readonly frames: SessionObserveJobFrame[] = [] + private waiter: (() => void) | undefined + private failure: Error | undefined + readonly accepts: number[] = [] + + push(frame: SessionObserveJobFrame): void { + this.frames.push(frame) + this.waiter?.() + } + + poison(error: Error): void { + this.failure = error + this.waiter?.() + } + + dispose(): Promise { + if (this.throwOnDispose && this.failure === undefined) { + this.failure = new Error('carrier tore down') + } else { + this.disposed = true + } + this.waiter?.() + if (this.deferDispose) { + // Repeat disposals (releaser plus the consumer's finally) share one + // deferred promise, so releaseDispose resumes every waiter. + this.disposePending ??= new Promise((resolve) => { this.disposeRelease = resolve }) + return this.disposePending + } + return Promise.resolve() + } + + releaseDispose(): void { + this.disposeRelease?.() + } + + async *[Symbol.asyncIterator]() { + let generation = 1 + while (!this.disposed) { + if (this.failure !== undefined) throw this.failure + const frame = this.frames.shift() + if (frame === undefined) { + await new Promise((resolve) => { this.waiter = resolve }) + continue + } + yield { + generation, + value: frame, + signal: new AbortController().signal, + accept: () => { this.accepts.push(generation) }, + } + generation = 1 + } + } +} + +function bench() { + const ctx = new Context() + const model = new ClientJobOutputModel() + const streams: { options: { open: (signal: AbortSignal) => unknown }; stream: FakeStream }[] = [] + const observeCalls: unknown[] = [] + const remote = { + $stream: (options: { open: (signal: AbortSignal) => unknown }) => { + const stream = new FakeStream() + streams.push({ options, stream }) + return stream + }, + session: { + observeJob: (request: unknown) => { + observeCalls.push(request) + return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } + }, + }, + } + const output = new ClientJobOutput(ctx, remote as never, model) + return { ctx, model, output, streams, observeCalls } +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) + +describe('ClientJobOutput observation streams', () => { + it('feeds anchor, output, and terminal frames into the model, then closes', async () => { + const { model, output, streams } = bench() + output.observe(undefined, ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + stream.push({ type: 'output', chunks: [{ at: 0, text: 'hi' }], next: 2 }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('hi') + expect(stream.accepts).toHaveLength(1) + + stream.push({ type: 'status', status: 'completed' }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) + expect(stream.disposed).toBe(true) + }) + + it('shares one stream across observers and stops after the last release', async () => { + const { model, output, streams } = bench() + const first = output.observe(undefined, ID) + const second = output.observe(undefined, ID) + expect(streams).toHaveLength(1) + streams[0]!.stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + + first() + first() + expect(streams[0]!.stream.disposed).toBe(false) + second() + await tick() + expect(streams[0]!.stream.disposed).toBe(true) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + }) + + it('a late release of a superseded observation neither tears down nor clears its successor', async () => { + const { model, output, streams } = bench() + const firstStop = output.observe(undefined, ID) + const first = streams[0]!.stream + first.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + first.push({ type: 'status', status: 'completed' }) + await tick() + // A successor observation replaces the settled entry while the first + // observer still holds its releaser. + const secondStop = output.observe(undefined, ID) + const second = streams[1]!.stream + second.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + + firstStop() + await tick() + expect(second.disposed).toBe(false) + expect(model.getSnapshot().observed[String(ID)]).toBeDefined() + + secondStop() + await tick() + expect(second.disposed).toBe(true) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + }) + + it('a re-observation inside the dispose round-trip keeps its fresh view', async () => { + const { model, output, streams } = bench() + const stop = output.observe(undefined, ID) + const first = streams[0]!.stream + first.deferDispose = true + first.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + + stop() + // Re-expand while the previous generation's dispose is still in flight. + output.observe(undefined, ID) + const second = streams[1]!.stream + second.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + + first.releaseDispose() + await tick() + // The stale post-dispose clear must not blank the successor's view. + expect(model.getSnapshot().observed[String(ID)]).toBeDefined() + second.push({ type: 'output', chunks: [{ at: 0, text: 'alive' }], next: 5 }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('alive') + }) + + it('opens a fresh stream for a re-observed job and resumes from the model cursor', async () => { + const { output, streams, observeCalls, model } = bench() + const stop = output.observe('alice' as SessionId, ID) + streams[0]!.stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + streams[0]!.stream.push({ type: 'output', chunks: [{ at: 0, text: 'abc' }], next: 3 }) + await tick() + // The generation opener reads the live cursor at open time and carries the + // fenced-read session. + streams[0]!.options.open(new AbortController().signal) + expect(observeCalls.at(-1)).toEqual({ jobId: ID, sessionId: 'alice', from: 3 }) + + stop() + await tick() + output.observe(undefined, ID) + expect(streams).toHaveLength(2) + streams[1]!.options.open(new AbortController().signal) + // Stopped observation dropped the cursor: the fresh stream starts unanchored. + expect(observeCalls.at(-1)).toEqual({ jobId: ID }) + expect(model.cursorOf(ID)).toBeUndefined() + }) + + it('classifies a premature end as retryable only after the anchor', () => { + const { output, streams } = bench() + output.observe(undefined, ID) + const options = streams[0]!.options as unknown as { ended: (accepted: boolean) => Error } + expect(options.ended(true).name).toBe('RemoteStreamCarrierError') + expect(options.ended(false).name).toBe('Error') + }) + + it('records a consumer failure on the live view', async () => { + const { model, output, streams } = bench() + output.observe(undefined, ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + stream.poison(new Error('frame decode broke')) + await tick() + const view = model.getSnapshot().observed[String(ID)] + expect(view?.error).toContain('frame decode broke') + expect(view?.streaming).toBe(false) + }) + + it('treats a release after service disposal as inert and stays silent for post-stop failures', async () => { + const { ctx, model, output, streams } = bench() + const stop = output.observe(undefined, ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + // Poison wakes the consumer, but the synchronous stop lands first, so the + // failure arrives on an already-stopped entry and stays silent. + stream.poison(new Error('after stop')) + stop() + await tick() + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + + const second = output.observe(undefined, ID) + await ctx.fiber.dispose() + // The disposal path already removed every entry; a live releaser whose + // entry is gone is inert. + second() + }) + + it('stays silent when the carrier teardown itself throws after a stop', async () => { + const { model, output, streams } = bench() + const stop = output.observe(undefined, ID) + const { stream } = streams[0]! + stream.throwOnDispose = true + stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + // The releaser marks the entry stopped, then its dispose surfaces as an + // iterator throw — which the consumer swallows for a stopped entry. + stop() + await tick() + expect(model.getSnapshot().observed[String(ID)]?.error).toBeUndefined() + }) + + it('records a terminal stream failure and service disposal closes live streams', async () => { + const { ctx, model, output, streams } = bench() + output.observe(undefined, ID) + const failing = streams[0]!.stream + failing.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + const iterator = failing[Symbol.asyncIterator]() + void iterator + // Simulate a terminal consumer failure by disposing the underlying stream: + // the consumer loop ends without a status frame and cleans up. + const spy = vi.spyOn(model, 'observeFailed') + await failing.dispose() + await tick() + + const other = 'bash-2' as JobId + output.observe(undefined, other) + await ctx.fiber.dispose() + expect(streams.every(entry => entry.stream.disposed)).toBe(true) + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/api/session-controller/tests/observe-job.host.spec.ts b/packages/api/session-controller/tests/observe-job.host.spec.ts new file mode 100644 index 0000000000..b61079d2b1 --- /dev/null +++ b/packages/api/session-controller/tests/observe-job.host.spec.ts @@ -0,0 +1,298 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { JobId } from '@deepseek-ai/dsh-jobs' +import type { JobOutcome, RunningJob } from '@deepseek-ai/dsh-jobs' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import { observeJobRecord } from '../src/observe-job.ts' +import type { ObserveJobOptions } from '../src/observe-job.ts' +import type { SessionObserveJobFrame } from '../src/types.ts' +import { createSessionTestController } from './test-remote.ts' + +async function harness() { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry, { retainBytes: 64, settledRetainBytes: 64 }) + ctx.jobs.attachController('observe-job-test') + return ctx +} + +function registerAgent(ctx: Context, rawId: string): Agent { + const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(rawId) + const session = Session.create(id) + const agent = { + id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scopeFiber.ctx, + } as unknown as Agent + ctx.agents.register(agent) + return agent +} + +/** Start one record job whose done settles on demand; expose the producer face. */ +function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent; record?: boolean } = {}) { + let settle!: (outcome: JobOutcome) => void + let face!: RunningJob + const id = ctx.jobs.start({ + kind: 'bash', + label: options.label ?? 'echo', + ...options.owner !== undefined ? { owner: options.owner } : {}, + ...options.record === false ? {} : { record: true }, + run: (job) => { + face = job + return { + cancel() {}, + done: new Promise((resolve) => { settle = resolve }), + } + }, + }) + return { + id, + append: (text: string, opts?: Parameters[1]) => { face.append(text, opts) }, + settle: async (outcome: JobOutcome) => { + settle(outcome) + await new Promise(resolve => setTimeout(resolve, 0)) + }, + } +} + +/** Read frames until `count` arrive or the generator finishes, then abort. */ +async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const frames: Frame[] = [] + const iterator = iterable[Symbol.asyncIterator]() + try { + while (frames.length < count) { + const step = await iterator.next() + if (step.done) return frames + frames.push(step.value) + } + } finally { + abort.abort() + await iterator.next().catch(() => undefined) + } + return frames +} + +const OBSERVE: Omit = { flushMs: 5, maxFrameBytes: 1024 } + +describe('observeJobRecord', () => { + function observed(ctx: Context, request: { jobId: JobId; from?: number }, caller?: Agent) { + const abort = new AbortController() + const frames: SessionObserveJobFrame[] = [] + const stream = observeJobRecord(ctx.jobs, request, { ...OBSERVE, caller }, abort.signal) + const done = (async () => { + for await (const frame of stream) frames.push(frame) + })() + return { abort, frames, done } + } + + it('anchors, streams coalesced output, and closes with the terminal status', async () => { + const ctx = await harness() + const job = startRecordJob(ctx) + job.append('early ') + const { frames, done } = observed(ctx, { jobId: job.id }) + await new Promise(resolve => setTimeout(resolve, 20)) + job.append('live', { channel: 'stderr' }) + await new Promise(resolve => setTimeout(resolve, 20)) + await job.settle({ status: 'completed', detail: 'exit code: 0' }) + await done + + expect(frames[0]).toMatchObject({ type: 'opened', from: 0, earliest: 0, total: 6, status: 'running' }) + const outputs = frames.filter(frame => frame.type === 'output') + const text = outputs.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') + expect(text).toBe('early live') + expect(outputs.at(-1)?.next).toBe(10) + expect(outputs.flatMap(frame => frame.chunks).find(chunk => chunk.text === 'live')?.channel).toBe('stderr') + expect(frames.at(-1)).toMatchObject({ type: 'status', status: 'completed', detail: 'exit code: 0' }) + }) + + it('resumes from an explicit offset and flags an evicted resume lossy', async () => { + const ctx = await harness() + const job = startRecordJob(ctx, { label: 'spam' }) + job.append('a'.repeat(60)) + job.append('b'.repeat(60)) + await job.settle({ status: 'completed' }) + + const fresh = observed(ctx, { jobId: job.id, from: 0 }) + await fresh.done + const output = fresh.frames.find(frame => frame.type === 'output') + expect(output?.lossy).toBe(true) + expect(output?.chunks.map(chunk => chunk.text).join('')).toBe('b'.repeat(60)) + + const resumed = observed(ctx, { jobId: job.id, from: 120 }) + await resumed.done + expect(resumed.frames.some(frame => frame.type === 'output')).toBe(false) + expect(resumed.frames.at(-1)?.type).toBe('status') + }) + + it('splits oversized reads along the frame budget', async () => { + const ctx = await harness() + const job = startRecordJob(ctx, { label: 'wide' }) + job.append('x'.repeat(30)) + job.append('y'.repeat(30)) + await job.settle({ status: 'completed' }) + const abort = new AbortController() + const frames = await collect( + observeJobRecord(ctx.jobs, { jobId: job.id }, { + flushMs: 5, + maxFrameBytes: 30, + caller: undefined, + }, abort.signal), + 5, + abort, + ) + const outputs = frames.filter(frame => frame.type === 'output') + expect(outputs).toHaveLength(2) + expect(outputs[0]?.next).toBe(30) + expect(outputs[1]?.next).toBe(60) + }) + + it('carries the lossy flag on the first frame of a split evicted read', async () => { + const ctx = await harness() + const job = startRecordJob(ctx, { label: 'evicted split' }) + job.append('a'.repeat(40)) + job.append('b'.repeat(40)) + job.append('c'.repeat(40)) + await job.settle({ status: 'completed' }) + const abort = new AbortController() + const frames = await collect( + observeJobRecord(ctx.jobs, { jobId: job.id, from: 0 }, { + flushMs: 5, + maxFrameBytes: 40, + caller: undefined, + }, abort.signal), + 5, + abort, + ) + const outputs = frames.filter(frame => frame.type === 'output') + expect(outputs[0]?.lossy).toBe(true) + expect(outputs[1]?.lossy).toBeUndefined() + }) + + it('reads owned records through the resolved caller and rejects foreign, bad, and unknown input', async () => { + const ctx = await harness() + const owner = registerAgent(ctx, 'alice') + const job = startRecordJob(ctx, { label: 'secret', owner }) + job.append('classified') + await job.settle({ status: 'completed' }) + + const { frames, done } = observed(ctx, { jobId: job.id }, owner) + await done + expect(frames.some(frame => frame.type === 'output' + && frame.chunks.some(chunk => chunk.text === 'classified'))).toBe(true) + + const foreign = new AbortController() + await expect(collect( + observeJobRecord(ctx.jobs, { jobId: job.id }, { ...OBSERVE, caller: undefined }, foreign.signal), + 1, + foreign, + )).rejects.toThrow(/belongs to another session/) + + const abort = new AbortController() + await expect(collect( + observeJobRecord(ctx.jobs, { jobId: job.id, from: -1 }, { ...OBSERVE, caller: owner }, abort.signal), + 1, + abort, + )).rejects.toThrow(/invalid observe offset/) + + const abortUnknown = new AbortController() + await expect(collect( + observeJobRecord(ctx.jobs, { jobId: JobId('bash-999') }, { ...OBSERVE, caller: undefined }, abortUnknown.signal), + 1, + abortUnknown, + )).rejects.toThrow(/unknown job/) + }) + + it('refuses a job that declared no record', async () => { + const ctx = await harness() + const job = startRecordJob(ctx, { record: false }) + const abort = new AbortController() + await expect(collect( + observeJobRecord(ctx.jobs, { jobId: job.id }, { ...OBSERVE, caller: undefined }, abort.signal), + 1, + abort, + )).rejects.toThrow(/declared no output record/) + await job.settle({ status: 'completed' }) + }) + + it('ignores output signals for other jobs while waiting', async () => { + const ctx = await harness() + const watched = startRecordJob(ctx, { label: 'watched' }) + const noisy = startRecordJob(ctx, { label: 'noisy' }) + const { abort, frames, done } = observed(ctx, { jobId: watched.id }) + await new Promise(resolve => setTimeout(resolve, 15)) + noisy.append('unrelated') + await new Promise(resolve => setTimeout(resolve, 15)) + await watched.settle({ status: 'completed' }) + await done + abort.abort() + expect(frames.filter(frame => frame.type === 'output')).toEqual([]) + await noisy.settle({ status: 'completed' }) + }) + + it('stops cleanly on abort while waiting for output', async () => { + const ctx = await harness() + const job = startRecordJob(ctx, { label: 'idle' }) + const { abort, frames, done } = observed(ctx, { jobId: job.id }) + await new Promise(resolve => setTimeout(resolve, 20)) + abort.abort() + await done + expect(frames[0]?.type).toBe('opened') + expect(frames.at(-1)?.type).not.toBe('status') + await job.settle({ status: 'completed' }) + }) +}) + +describe('SessionController.observeJob', () => { + const defaults = { + defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + cwd: '/tmp', + } + + it('refuses observation without a job registry', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const controller = createSessionTestController(ctx, defaults) + expect(() => controller.observeJob({ jobId: JobId('bash-1') }, new AbortController().signal)) + .toThrow(/job observation unavailable/) + }) + + it('resolves the fenced-read caller from the request session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + ctx.jobs.attachController('observe-job-test') + const controller = createSessionTestController(ctx, defaults) + const session = ctx.sessions.create(SessionId('observing-session')) + const owner = { + id: session.id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx, + } as unknown as Agent + ctx.agents.register(owner) + const job = startRecordJob(ctx, { label: 'owned run', owner }) + job.append('hi') + await job.settle({ status: 'completed' }) + + const abort = new AbortController() + const frames = await collect( + controller.observeJob({ sessionId: session.id, jobId: job.id }, abort.signal), + 3, + abort, + ) + expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) + }) +}) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 3d7d549a26..d125b6808d 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -34,6 +34,8 @@ import type { SessionFollowRequest, SessionListRequest, SessionListValue, + SessionObserveJobFrame, + SessionObserveJobRequest, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, SessionPage, @@ -71,6 +73,7 @@ export interface TestSessionRemote { page(request: SessionPageRequest, signal?: AbortSignal): Promise> follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable control(signal?: AbortSignal): AsyncIterable + observeJob(request: SessionObserveJobRequest, signal?: AbortSignal): AsyncIterable } /** Dependencies and policy supplied by a Session Controller unit harness. */ @@ -273,5 +276,6 @@ export function createSessionTestRemote( ), follow: (request, signal = new AbortController().signal) => direct.follow(request, signal), control: (signal = new AbortController().signal) => direct.control(signal), + observeJob: (request, signal = new AbortController().signal) => direct.observeJob(request, signal), } } diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index a48fcb83a5..207daafbd5 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -18,6 +18,7 @@ "src/history.ts", "src/list.ts", "src/model-selection-projection.ts", + "src/observe-job.ts", "src/skill-catalog.ts" ], "references": [ From 0dca00b425a0d375756701c400a1cf6c8447d616 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 18:22:04 +0800 Subject: [PATCH 016/284] refactor(api,client)!: delete the activity plane; one job roster with record observation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/activity/* and packages/api/activity-controller are removed. The web roster is the session control stream's jobs frames alone; per-job output observation rides session.observeJob and the client ctx.jobOutput service. ui-activity returns to its upstream name ui-jobs and renders the single roster — a row is expandable exactly when its job declared a record (outputTotal present). Composition rows, dependency-policy exemptions, tsconfig aliases and references, generated catalogs, and the docs pages follow; the activity subsystem page is deleted and the jobs page gains the record contract. The superseding Agent Note records the decision and points the three prior notes at it. --- .../2026-09-01-jobs-absorb-activity-record.md | 35 ++ ...26-09-01-jobs-absorb-activity-record.zh.md | 35 ++ .../2026-08-08-web-background-job-display.md | 2 + ...026-08-08-web-background-job-display.zh.md | 2 + .../2026-08-24-activity-observation-seam.md | 2 + ...2026-08-24-activity-observation-seam.zh.md | 2 + .../feature/2026-08-25-unified-task-list.md | 2 + .../2026-08-25-unified-task-list.zh.md | 2 + apps/cli/composition.md | 3 - ...y-stream.e2e.ts => live-job-stream.e2e.ts} | 26 +- .../settled.expected.md | 0 .../streaming.expected.md | 0 apps/web/tsconfig.json | 2 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.md | 20 +- docs/config-catalog.md | 63 +-- docs/event-producer-consumer.md | 14 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 41 +- docs/module-graph.zh.md | 41 +- docs/subsystems/README.md | 1 - docs/subsystems/README.zh.md | 1 - docs/subsystems/activity.md | 141 ------ docs/subsystems/activity.zh.md | 141 ------ docs/subsystems/jobs.md | 141 +++++- docs/subsystems/jobs.zh.md | 27 ++ docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 12 + docs/subsystems/session.zh.md | 12 + packages/activity/README.i18n.yaml | 6 - packages/activity/README.md | 43 -- packages/activity/README.zh.md | 43 -- .../activity/activity-local/README.i18n.yaml | 6 - packages/activity/activity-local/README.md | 98 ---- packages/activity/activity-local/README.zh.md | 98 ---- packages/activity/activity-local/package.json | 55 --- packages/activity/activity-local/src/index.ts | 438 ------------------ .../activity/activity-local/src/invariant.ts | 33 -- .../activity-local/tests/activity.spec.ts | 413 ----------------- .../tests/loader-composition.spec.ts | 60 --- .../activity/activity-local/tsconfig.json | 33 -- packages/activity/activity/README.i18n.yaml | 6 - packages/activity/activity/README.md | 101 ---- packages/activity/activity/README.zh.md | 101 ---- packages/activity/activity/package.json | 57 --- packages/activity/activity/src/brand.ts | 28 -- packages/activity/activity/src/index.ts | 138 ------ packages/activity/activity/src/invariant.ts | 62 --- packages/activity/activity/src/pump.ts | 95 ---- packages/activity/activity/src/types.ts | 184 -------- .../activity/activity/tests/invariant.spec.ts | 82 ---- .../activity/activity/tests/service.spec.ts | 98 ---- packages/activity/activity/tsconfig.json | 36 -- .../api/activity-controller/README.i18n.yaml | 6 - packages/api/activity-controller/README.md | 106 ----- packages/api/activity-controller/README.zh.md | 106 ----- packages/api/activity-controller/package.json | 103 ---- .../activity-controller/src/client/index.ts | 86 ---- .../activity-controller/src/client/model.ts | 216 --------- .../activity-controller/src/client/service.ts | 164 ------- packages/api/activity-controller/src/feed.ts | 184 -------- packages/api/activity-controller/src/index.ts | 104 ----- .../api/activity-controller/src/invariant.ts | 23 - .../api/activity-controller/src/observe.ts | 178 ------- packages/api/activity-controller/src/types.ts | 104 ----- .../tests/activity-controller.host.spec.ts | 306 ------------ .../tests/client-apply.client.spec.ts | 113 ----- .../tests/client-model.client.spec.ts | 183 -------- .../tests/client-service.client.spec.ts | 276 ----------- .../tests/controller.host.spec.ts | 102 ---- .../activity-controller/tsconfig.client.json | 46 -- .../activity-controller/tsconfig.host.json | 26 -- .../api/activity-controller/tsconfig.json | 7 - .../api/activity-controller/tsdown.config.ts | 7 - packages/api/remotes/package.json | 1 - packages/api/remotes/src/client/index.ts | 5 +- packages/api/remotes/src/index.ts | 1 - packages/api/remotes/tsconfig.client.json | 3 - packages/api/remotes/tsconfig.host.json | 3 - packages/api/session-controller/README.md | 4 + packages/bundle/base/cordis.patch.yml | 5 - packages/bundle/base/package.json | 1 - packages/bundle/web-app/cordis.patch.yml | 12 +- packages/bundle/web-app/package.json | 3 +- packages/client/README.md | 2 +- packages/client/README.zh.md | 2 +- packages/client/ui-activity/README.md | 93 ---- packages/client/ui-activity/README.zh.md | 93 ---- .../client/ui-activity/src/client/index.ts | 50 -- .../{ui-activity => ui-jobs}/README.i18n.yaml | 6 +- packages/client/ui-jobs/README.md | 93 ++++ packages/client/ui-jobs/README.zh.md | 93 ++++ .../{ui-activity => ui-jobs}/package.json | 10 +- .../src/client/JobListAction.module.css} | 0 .../src/client/JobListAction.tsx} | 222 +++------ packages/client/ui-jobs/src/client/index.ts | 49 ++ .../src/client/locales.ts | 30 +- .../src/css-modules.d.ts | 0 .../{ui-activity => ui-jobs}/src/index.ts | 4 +- .../{ui-activity => ui-jobs}/src/invariant.ts | 10 +- .../tests/browser-plugin.client.spec.ts | 50 +- .../tests/job-list-action.client.spec.tsx} | 274 ++++------- .../{ui-activity => ui-jobs}/tsconfig.json | 1 - .../{ui-activity => ui-jobs}/tsdown.config.ts | 2 +- .../src/client/slot-catalog.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 212 +++------ pnpm-lock.yaml | 218 ++------- python/sdk-runtime/package.json | 1 - scripts/gen-cordis-catalog.ts | 22 +- scripts/gen-doc-graphs.ts | 20 +- scripts/package-dependency-policy.ts | 1 - scripts/verify-package-dependencies.spec.ts | 1 - .../verify-package-readme-model-experience.ts | 5 +- tsconfig.base.json | 14 +- tsconfig.client.json | 3 +- tsconfig.host.json | 5 +- 117 files changed, 923 insertions(+), 6243 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md create mode 100644 .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md rename apps/web/tests/{live-activity-stream.e2e.ts => live-job-stream.e2e.ts} (84%) rename apps/web/tests/snapshots/{live-activity-stream => live-job-stream}/settled.expected.md (100%) rename apps/web/tests/snapshots/{live-activity-stream => live-job-stream}/streaming.expected.md (100%) delete mode 100644 docs/subsystems/activity.md delete mode 100644 docs/subsystems/activity.zh.md delete mode 100644 packages/activity/README.i18n.yaml delete mode 100644 packages/activity/README.md delete mode 100644 packages/activity/README.zh.md delete mode 100644 packages/activity/activity-local/README.i18n.yaml delete mode 100644 packages/activity/activity-local/README.md delete mode 100644 packages/activity/activity-local/README.zh.md delete mode 100644 packages/activity/activity-local/package.json delete mode 100644 packages/activity/activity-local/src/index.ts delete mode 100644 packages/activity/activity-local/src/invariant.ts delete mode 100644 packages/activity/activity-local/tests/activity.spec.ts delete mode 100644 packages/activity/activity-local/tests/loader-composition.spec.ts delete mode 100644 packages/activity/activity-local/tsconfig.json delete mode 100644 packages/activity/activity/README.i18n.yaml delete mode 100644 packages/activity/activity/README.md delete mode 100644 packages/activity/activity/README.zh.md delete mode 100644 packages/activity/activity/package.json delete mode 100644 packages/activity/activity/src/brand.ts delete mode 100644 packages/activity/activity/src/index.ts delete mode 100644 packages/activity/activity/src/invariant.ts delete mode 100644 packages/activity/activity/src/pump.ts delete mode 100644 packages/activity/activity/src/types.ts delete mode 100644 packages/activity/activity/tests/invariant.spec.ts delete mode 100644 packages/activity/activity/tests/service.spec.ts delete mode 100644 packages/activity/activity/tsconfig.json delete mode 100644 packages/api/activity-controller/README.i18n.yaml delete mode 100644 packages/api/activity-controller/README.md delete mode 100644 packages/api/activity-controller/README.zh.md delete mode 100644 packages/api/activity-controller/package.json delete mode 100644 packages/api/activity-controller/src/client/index.ts delete mode 100644 packages/api/activity-controller/src/client/model.ts delete mode 100644 packages/api/activity-controller/src/client/service.ts delete mode 100644 packages/api/activity-controller/src/feed.ts delete mode 100644 packages/api/activity-controller/src/index.ts delete mode 100644 packages/api/activity-controller/src/invariant.ts delete mode 100644 packages/api/activity-controller/src/observe.ts delete mode 100644 packages/api/activity-controller/src/types.ts delete mode 100644 packages/api/activity-controller/tests/activity-controller.host.spec.ts delete mode 100644 packages/api/activity-controller/tests/client-apply.client.spec.ts delete mode 100644 packages/api/activity-controller/tests/client-model.client.spec.ts delete mode 100644 packages/api/activity-controller/tests/client-service.client.spec.ts delete mode 100644 packages/api/activity-controller/tests/controller.host.spec.ts delete mode 100644 packages/api/activity-controller/tsconfig.client.json delete mode 100644 packages/api/activity-controller/tsconfig.host.json delete mode 100644 packages/api/activity-controller/tsconfig.json delete mode 100644 packages/api/activity-controller/tsdown.config.ts delete mode 100644 packages/client/ui-activity/README.md delete mode 100644 packages/client/ui-activity/README.zh.md delete mode 100644 packages/client/ui-activity/src/client/index.ts rename packages/client/{ui-activity => ui-jobs}/README.i18n.yaml (70%) create mode 100644 packages/client/ui-jobs/README.md create mode 100644 packages/client/ui-jobs/README.zh.md rename packages/client/{ui-activity => ui-jobs}/package.json (83%) rename packages/client/{ui-activity/src/client/ActivityListAction.module.css => ui-jobs/src/client/JobListAction.module.css} (100%) rename packages/client/{ui-activity/src/client/ActivityListAction.tsx => ui-jobs/src/client/JobListAction.tsx} (61%) create mode 100644 packages/client/ui-jobs/src/client/index.ts rename packages/client/{ui-activity => ui-jobs}/src/client/locales.ts (78%) rename packages/client/{ui-activity => ui-jobs}/src/css-modules.d.ts (100%) rename packages/client/{ui-activity => ui-jobs}/src/index.ts (66%) rename packages/client/{ui-activity => ui-jobs}/src/invariant.ts (70%) rename packages/client/{ui-activity => ui-jobs}/tests/browser-plugin.client.spec.ts (73%) rename packages/client/{ui-activity/tests/activity-list-action.client.spec.tsx => ui-jobs/tests/job-list-action.client.spec.tsx} (59%) rename packages/client/{ui-activity => ui-jobs}/tsconfig.json (89%) rename packages/client/{ui-activity => ui-jobs}/tsdown.config.ts (76%) diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md new file mode 100644 index 0000000000..501cdcf801 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -0,0 +1,35 @@ +# Agent Note: The job registry absorbs the observation record; the standalone activity seam is removed + +Status: implemented + +English | [中文](2026-09-01-jobs-absorb-activity-record.zh.md) + +## Problem + +The [activity observation seam](../feature/2026-08-24-activity-observation-seam.md) shipped live output streaming as a second registry beside `ctx.jobs`: producers registered the same work twice (a job for lifecycle, an activity for observation), kept the two terminal states consistent by hand (`observeBackgroundActivity` waited for `proc.done` before mapping the outcome so a pump failure could not freeze a wrong terminal state), correlated the rows through `ActivityCorrelation.jobId`, and wrapped every observation call in registry-absent degradation branches. The Web client maintained two rosters (session-control `jobs` frames and the activity control stream) and joined them per row. Measured before the merge: eight pairing call sites, and one producer population — background bash/pwsh (both registries), PTY sends and subagent delegations (jobs only), foreground workflow (activity only). + +The one activity without a job was the foreground workflow mirror. Everything else the split enabled — independent observers, absolute-offset reads, bounded retention — is a property of the record, not of the registry split. + +## Decision + +`ctx.jobs` owns the observation record; `packages/activity/`, `packages/api/activity-controller`, and the correlation vocabulary are removed. + +- **`JobStart.record?: true`** declares an observable output record. `run(job)` now receives the job's producer face — `RunningJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }` — so the id is issued before the starter runs (a throwing starter still registers nothing; its ordinal is skipped). Writes staged inside the starter surface at the registration commit. `updateDetail` works for every job and gives `job_list` a live progress line; `append` without a record declaration logs and drops. +- **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented. +- **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design. +- **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it. +- **The wire moves to the session namespace.** `SessionJob.outputTotal` (present exactly for record jobs) marks a row observable; `session.observeJob({sessionId?, jobId, from?})` streams anchor/output/status frames with the fenced read resolved from the request's session — the killJob authorization shape. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery moved into `session-controller` (`observe-job.ts`, client `ctx.jobOutput`). +- **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `outputTotal` is present. The two-roster join is deleted. +- **Foreground workflow loses its live panel deliberately.** `tool-workflow`'s activity mirror is removed; a foreground run surfaces through its recorded run/member lifecycle events only, and the per-line `workflow/phase` / `workflow/log` narration has no observer until workflow gains `run_in_background` and registers a record job. Jobs stay a pure background registry — no `foreground` mode bit, no model-invisible rows, no run-less rows. + +## Rejected + +- **A `foreground: true` job mode** to keep the foreground workflow panel: it needs two coupled enforcement points (reported-at-birth and a `job_list` filter) whose divergence double-delivers or leaks rows to the model, for one edge feature replaceable by `run_in_background`. +- **Serving model reads from the record** (deleting `readOutput`): model reads are producer-formatted (truncation and spill notices, sandbox markers) and consuming; the record is raw, channel-labeled, and non-consuming. Unifying them moves producer-specific formatting into the registry or noise into the observer stream. +- **Keeping the split with shared implementation** (a common registry library): it removes the duplicated skeleton but keeps the real costs — double registration, terminal-state pairing, correlation, a second wire roster, and a second package family. + +The [prior seam note](../feature/2026-08-24-activity-observation-seam.md)'s argument against durable session events and control-stream framing for live output still holds and carries over unchanged: the record is process-local observation state, never a session event, so "model-visible ⟺ logged" is untouched. + +## Consequences + +Every observable row is a killable job; a pure-observation surface with no lifecycle would need a new home (harness diagnostics belong to the inspector plane, not here). A future remote job provider must implement lifecycle and record together. The record's retention config lives on `jobs-local`; the observation wire's cadence config (`observeFlushMs`, `observeMaxFrameBytes`) lives on `session-controller`. diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md new file mode 100644 index 0000000000..40e795146c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -0,0 +1,35 @@ +# Agent Note:job 注册表吸收观测 record;独立的 activity seam 被移除 + +Status: implemented + +[English](2026-09-01-jobs-absorb-activity-record.md) | 中文 + +## 问题 + +[activity 观测 seam](../feature/2026-08-24-activity-observation-seam.zh.md) 把实时输出流做成了 `ctx.jobs` 旁边的第二个注册表:生产者要把同一份工作登记两次(job 管生命周期,activity 管观测),手工保持两个终态一致(`observeBackgroundActivity` 要等 `proc.done` 才映射 outcome,防止 pump 失败把错误终态冻上去),通过 `ActivityCorrelation.jobId` 关联两行,并给每个观测调用包上注册表缺失的降级分支。Web 客户端维护两份名册(session-control 的 `jobs` 帧与 activity control 流)并逐行做 join。合并前实测:八个配对调用点;生产者人口为——后台 bash/pwsh(双注册表)、PTY 发送与 subagent 委托(仅 jobs)、前台 workflow(仅 activity)。 + +唯一没有 job 的 activity 是前台 workflow 镜像。拆分带来的其他一切——独立观察者、绝对偏移读取、有界保留——都是 record 的性质,不是注册表拆分的性质。 + +## 决策 + +`ctx.jobs` 拥有观测 record;`packages/activity/`、`packages/api/activity-controller` 与 correlation 词表被移除。 + +- **`JobStart.record?: true`** 声明可观测输出 record。`run(job)` 现在收到 job 的生产者面——`RunningJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }`——因此 id 在 starter 运行前签发(starter 抛出仍然不登记任何东西;序号被跳过)。starter 内暂存的写入在登记提交时可见。`updateDetail` 对所有 job 可用,让 `job_list` 也能看到实时进度行;未声明 record 的 `append` 记日志并丢弃。 +- **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。 +- **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。 +- **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。 +- **wire 移入 session 命名空间。** `SessionJob.outputTotal`(恰在 record job 上存在)标记行可观测;`session.observeJob({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析——与 killJob 相同的授权形态。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制移入 `session-controller`(`observe-job.ts`,客户端 `ctx.jobOutput`)。 +- **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `outputTotal` 存在时可展开。双名册 join 被删除。 +- **前台 workflow 有意失去实时面板。** `tool-workflow` 的 activity 镜像被移除;前台 run 只通过已记录的 run/member 生命周期事件呈现,逐行的 `workflow/phase` / `workflow/log` 叙述在 workflow 获得 `run_in_background` 并登记 record job 之前没有观察者。jobs 保持纯后台注册表——没有 `foreground` 模式位、没有模型不可见行、没有无 run 行。 + +## 已否决 + +- **`foreground: true` job 模式**以保留前台 workflow 面板:它需要两个耦合的执行点(出生即 reported 与 `job_list` 过滤),两者一旦分叉就会双投递或向模型泄漏行,而它换来的只是一个可被 `run_in_background` 替代的边缘特性。 +- **用 record 服务模型读取**(删除 `readOutput`):模型读取是生产者格式化的(截断与 spill 提示、sandbox 标记)且消耗型;record 是原始、带 channel 标签、非消耗的。统一二者要么把生产者特有格式化搬进注册表,要么把噪声混入观察者流。 +- **保留拆分但共享实现**(公共注册表库):它消除重复骨架,但保留真正的成本——双重登记、终态配对、correlation、第二条 wire 名册与第二个包族。 + +[先前 seam note](../feature/2026-08-24-activity-observation-seam.zh.md) 反对用持久会话事件或 control 流承载实时输出的论证仍然成立并原样沿用:record 是进程本地观测状态,从不是会话事件,「模型可见 ⟺ 已记录」不受影响。 + +## 后果 + +每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `session-controller`。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 23074b5d38..3dd003c0e9 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -2,6 +2,8 @@ Status: implemented +Update: the output phase this note deferred now ships as the per-job observation record on `ctx.jobs` — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). + English | [中文](2026-08-08-web-background-job-display.zh.md) ## Problem diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 06645267ee..5d1ee4bada 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -2,6 +2,8 @@ Status: implemented +Update:本文推迟的输出阶段现已作为 `ctx.jobs` 上的逐 job 观测 record 交付——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 + [English](2026-08-08-web-background-job-display.md) | 中文 ## 问题 diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md index 88109cb340..29ee08b1bb 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md @@ -2,6 +2,8 @@ Status: implemented +Superseded: the standalone seam described here was folded into `ctx.jobs` as the per-job observation record — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md); the durable-vs-live analysis below still holds. + English | [中文](2026-08-24-activity-observation-seam.zh.md) ## Problem diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md index b8aa938edf..97ac4f195c 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md @@ -2,6 +2,8 @@ 状态:implemented +Superseded:本文描述的独立 seam 已折入 `ctx.jobs`,成为逐 job 的观测 record——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md);下文关于持久化与实时通道的分析仍然成立。 + [English](2026-08-24-activity-observation-seam.md) | 中文 ## 问题 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md index e67ca84d96..daf6582bf2 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md @@ -2,6 +2,8 @@ Status: implemented +Superseded: the two-roster join described here was removed with the activity seam — the job rows are now the whole roster; see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). + English | [中文](2026-08-25-unified-task-list.zh.md) ## Problem diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md index 2a71b04ec3..624747396e 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md @@ -2,6 +2,8 @@ 状态:implemented +Superseded:本文描述的双名册 join 已随 activity seam 一并移除——job 行现在就是完整名册;见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 + [English](2026-08-25-unified-task-list.md) | 中文 ## 问题 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 60ddefee1a..5386b94ae0 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -40,8 +40,6 @@ flowchart LR cfg --> plugin_dsh_base_agent_default_model plugin_dsh_base_jobs["jobs
        @deepseek-ai/dsh-jobs-local"] cfg --> plugin_dsh_base_jobs - plugin_dsh_base_activity["activity
        @deepseek-ai/dsh-activity-local"] - cfg --> plugin_dsh_base_activity plugin_dsh_base_llm_retry["llm-retry
        @deepseek-ai/dsh-llm-retry"] cfg --> plugin_dsh_base_llm_retry plugin_dsh_base_settings["settings
        @deepseek-ai/dsh-settings-file"] @@ -202,7 +200,6 @@ flowchart LR | `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` | | `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` | | `jobs` | `@deepseek-ai/dsh-jobs-local` | -| `activity` | `@deepseek-ai/dsh-activity-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | | `settings` | `@deepseek-ai/dsh-settings-file` | | `credentials` | `@deepseek-ai/dsh-credentials-local` | diff --git a/apps/web/tests/live-activity-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts similarity index 84% rename from apps/web/tests/live-activity-stream.e2e.ts rename to apps/web/tests/live-job-stream.e2e.ts index 7c0ce1532b..65926f7463 100644 --- a/apps/web/tests/live-activity-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -1,5 +1,5 @@ -// Session-header live activities driven by a real background bash run: the -// roster row arrives over the activity control stream, expanding it opens the +// Session-header job list driven by a real background bash run: the job row +// arrives over the session control stream, expanding it opens the record // observation stream, and the panel shows the process's real output while it // is still running. No model call is involved. import { readFile } from 'node:fs/promises' @@ -19,11 +19,11 @@ import { import { newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.jsonl', import.meta.url)) -const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-activity-stream', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-job-stream', import.meta.url)) const STREAMING_EXPECTED = join(SNAPSHOT_DIR, 'streaming.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() -const SEED_ID = 'live-activity-stream-web-e2e' +const SEED_ID = 'live-job-stream-web-e2e' // One fixed line of early output, then silence long enough that the streaming // assertions never race the process exiting; the test kills it explicitly. const COMMAND = "printf 'streamed-%s\\n' marker-line; sleep 45" @@ -44,7 +44,7 @@ async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise { +describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -69,7 +69,7 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { await sessionRow.click() // Opening the session drives the Host's ordinary Agent resolution; the - // activity owner must be that exact live instance, never a second one. + // job owner must be that exact live instance, never a second one. agent = await liveAgent(scaffold, SessionId(SEED_ID)) }, 120_000) @@ -79,13 +79,13 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { }) it('streams a running background command\'s real output into the expanded panel', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-streaming')) - const trigger = page.getByRole('button', { name: '1 task running' }) + onTestFailed(() => saveFailureShot(page, 'web-e2e-live-job-streaming')) + const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) const started = await scaffold.ctx.tools.execute({ signal: new AbortController().signal, - callId: ToolCallId('live-activity-stream-e2e'), + callId: ToolCallId('live-job-stream-e2e'), name: 'bash', arguments: { command: COMMAND, description: 'Emit one live line then hold', run_in_background: true }, agent, @@ -95,11 +95,11 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) jobId = JobId(matched[0]) - // The roster row reaches the header over the activity control stream. + // The job row reaches the header over the session control stream. await trigger.waitFor({ timeout: 15_000 }) await trigger.click() - // Expanding the row opens the observation stream; the panel then renders + // Expanding the row opens the record observation stream; the panel then renders // the command's real stdout while the process is still running. const expand = page.getByRole('button', { name: /Show live output/ }) await expand.waitFor({ timeout: 10_000 }) @@ -113,12 +113,12 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { }, 60_000) it('settles the open panel when the job is killed, keeping the streamed tail', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-settled')) + onTestFailed(() => saveFailureShot(page, 'web-e2e-live-job-settled')) expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') // Settlement arrives on the observation stream itself, so the open panel // flips without any further interaction; the roster trigger follows. - const idle = page.getByRole('button', { name: '1 task' }) + const idle = page.getByRole('button', { name: '1 background job' }) await idle.waitFor({ timeout: 20_000 }) await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md b/apps/web/tests/snapshots/live-job-stream/settled.expected.md similarity index 100% rename from apps/web/tests/snapshots/live-activity-stream/settled.expected.md rename to apps/web/tests/snapshots/live-job-stream/settled.expected.md diff --git a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md similarity index 100% rename from apps/web/tests/snapshots/live-activity-stream/streaming.expected.md rename to apps/web/tests/snapshots/live-job-stream/streaming.expected.md diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 0f95b48551..38e021b1aa 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -95,7 +95,7 @@ "tests/subagent-interrupt-ui.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", "tests/background-job-list.e2e.ts", - "tests/live-activity-stream.e2e.ts", + "tests/live-job-stream.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", "tests/turn-tail-actions.e2e.ts", diff --git a/docs/architecture.md b/docs/architecture.md index 8a2f9b772c..f36c956d9a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -129,7 +129,7 @@ New behavior attaches to a documented extension point. Changing the loop itself | Add persistent terminal execution | register a `ctx.terminals` backend plus `dsh-tool-terminal` | | Add a human command | register on `ctx.commands`; it dispatches without a model turn | | Add background work | register on `ctx.jobs`; `job_*` tools collect or stop it | -| Stream live output to observers | open a `ctx.activities` handle; the Web client renders it | +| Stream live output to observers | declare `record: true` on the `ctx.jobs` start; the Web client renders it | | Start a Session from an external webhook | register a trusted rule on `ctx.webhookRuntime` and mount a provider adapter | | Add filesystem access or policy | register a `ctx.fs` provider or listen to `fs/*` events | | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index cc291fb018..67e2ae905c 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -133,7 +133,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 添加持久化终端执行 | 注册 `ctx.terminals` 后端和 `dsh-tool-terminal` | | 添加用户命令 | 在 `ctx.commands` 上注册;它无需模型轮次即可分派 | | 添加后台工作 | 在 `ctx.jobs` 上注册;`job_*` 工具负责收集或停止 | -| 向观察者流式输出 | 打开一个 `ctx.activities` 句柄;Web 客户端负责渲染 | +| 向观察者流式输出 | 在 `ctx.jobs` 启动时声明 `record: true`;Web 客户端负责渲染 | | 从外部 webhook 启动 Session | 在 `ctx.webhookRuntime` 上注册可信规则,并挂载提供方适配器 | | 添加文件系统访问或策略 | 注册 `ctx.fs` 提供方,或监听 `fs/*` 事件 | | 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6c0867eeb8..db2b8db956 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -45,8 +45,6 @@ flowchart LR pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
        Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
        Host directory-picking Remote controller"] - pkg_api_activity_controller["api-activity-controller"] - svc_activityController["ctx.activityController
        Host Activity Remote controller"] svc_invariants["ctx.invariants
        Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -186,11 +184,6 @@ flowchart LR svc_jobs["ctx.jobs
        Background job registry"] pkg_jobs_local["jobs-local"] pkg_tool_jobs["tool-jobs"] - pkg_activity["activity"] - svc_activities["ctx.activities
        Live activity observation registry"] - pkg_activity_local["activity-local"] - pkg_tool_workflow["tool-workflow"] - pkg_activity_controller["activity-controller"] pkg_web["web"] svc_web["ctx.web
        Web access provider registry"] pkg_web_search_exa["web-search-exa"] @@ -214,6 +207,7 @@ flowchart LR pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
        Workflow script engine"] pkg_workflow_worker_thread["workflow-worker-thread"] + pkg_tool_workflow["tool-workflow"] pkg_webhook["webhook"] svc_webhookRuntime["ctx.webhookRuntime
        Webhook rule runtime"] pkg_webhook_github["webhook-github"] @@ -223,13 +217,10 @@ flowchart LR pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
        Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
        Dynamic Cordis inspect registry"] - pkg_activity --> svc_activities - pkg_activity_local --> svc_activities pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets - pkg_api_activity_controller --> svc_activityController pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences @@ -342,10 +333,6 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry - svc_activities --> pkg_activity_controller - svc_activities --> pkg_tool_bash - svc_activities --> pkg_tool_pwsh - svc_activities --> pkg_tool_workflow svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_agent_spine_demo @@ -382,6 +369,7 @@ flowchart LR svc_invariants --> pkg_session svc_jobs --> pkg_tool_bash svc_jobs --> pkg_tool_jobs + svc_jobs --> pkg_tool_pwsh svc_jobs --> pkg_tool_subagent svc_jobs --> pkg_tool_terminal svc_llm --> pkg_agent_loop @@ -489,7 +477,6 @@ flowchart LR | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | -| `ctx.activityController` | `core` | [`api-activity-controller`](../packages/api/activity-controller) | - | - | - | Owns the live-activity roster stream and per-activity non-consuming output observation through the generated Remote namespace. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | @@ -535,8 +522,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. | | `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | -| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | -| `ctx.activities` | `seam` | [`activity`](../packages/activity/activity) | [`activity-local`](../packages/activity/activity-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-workflow`](../packages/workflow/tool-workflow), `activity-controller` | - | Producers mirror live output and status into non-consuming offset-addressed streams; activity-controller serves them to browsers, and the model-facing paths (jobs cursors, tool results) stay untouched. | +| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 424eff1c5e..64b72e0ff7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -33,22 +33,6 @@ Depends on: `Stream` (`@agentclientprotocol/sdk`) Source: [`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts) -
        - -## `@deepseek-ai/dsh-activity-local` - -```ts config-catalog -/** Configuration for the process-local observation registry. */ -export interface Config { - /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ - retainBytes?: number - /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ - settledRetainBytes?: number -} -``` - -Source: [`packages/activity/activity-local/src/index.ts:31`](../packages/activity/activity-local/src/index.ts) - ## `@deepseek-ai/dsh-agent-default-model` @@ -292,24 +276,6 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md) Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) - - -## `@deepseek-ai/dsh-api-activity-controller` - -Requires: `typert` - -```ts config-catalog -/** Configuration for the Activity Remote owner. */ -export interface Config { - /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ - flushMs?: number - /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ - maxFrameBytes?: number -} -``` - -Source: [`packages/api/activity-controller/src/index.ts:26`](../packages/api/activity-controller/src/index.ts) - ## `@deepseek-ai/dsh-api-gateway` @@ -339,10 +305,14 @@ export interface Config { readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean + /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number } ``` -Source: [`packages/api/session-controller/src/index.ts:67`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts) @@ -988,10 +958,14 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number + /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number } ``` -Source: [`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts) +Source: [`packages/jobs/jobs-local/src/index.ts:38`](../packages/jobs/jobs-local/src/index.ts) @@ -2712,12 +2686,12 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv` export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ - activityPollMs?: number + /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ + recordPollMs?: number } ``` -Source: [`packages/shell/tool-bash/src/index.ts:35`](../packages/shell/tool-bash/src/index.ts) +Source: [`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) @@ -2879,12 +2853,12 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv` export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ - activityPollMs?: number + /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ + recordPollMs?: number } ``` -Source: [`packages/shell/tool-pwsh/src/index.ts:60`](../packages/shell/tool-pwsh/src/index.ts) +Source: [`packages/shell/tool-pwsh/src/index.ts:53`](../packages/shell/tool-pwsh/src/index.ts) @@ -3158,7 +3132,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-workflow/src/index.ts:33`](../packages/workflow/tool-workflow/src/index.ts) +Source: [`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts) @@ -3454,7 +3428,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-activity` ([`packages/client/ui-activity/src/index.ts`](../packages/client/ui-activity/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-approval` ([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) @@ -3468,6 +3441,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-input-trigger` ([`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-jobs` ([`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-message-feedback` ([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model-selection` ([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) @@ -3528,7 +3502,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). -- `@deepseek-ai/dsh-activity` — abstract `ActivityRegistry` ([`packages/activity/activity/src/index.ts`](../packages/activity/activity/src/index.ts)) - `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 48aacf9dc6..fc05e2ec6b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -69,8 +69,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## Non-harness or undeclared event strings seen in package source diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 218ec66b69..8727d17738 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: f1a2f29c9e61567a92fb8b98737446c2258087a9 -module-graph.zh.md: 86845aa00035ab18ead49e6f90a2e66c18984028 +module-graph.md: 6b69abd956658b5bf17f0b49158ec8f366888d94 +module-graph.zh.md: 4af048cc46db9bcf990a702d29970a67ff222949 diff --git a/docs/module-graph.md b/docs/module-graph.md index f1a2f29c9e..6b69abd956 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -107,12 +107,7 @@ flowchart TD subgraph group_acp["packages/acp"] pkg_acp["acp"] end - subgraph group_activity["packages/activity"] - pkg_activity["activity"] - pkg_activity_local["activity-local"] - end subgraph group_api["packages/api"] - pkg_api_activity_controller["api-activity-controller"] pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] @@ -141,7 +136,6 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_store["client-store"] - pkg_client_ui_activity["client-ui-activity"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] @@ -154,6 +148,7 @@ flowchart TD pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] + pkg_client_ui_jobs["client-ui-jobs"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_message_feedback["client-ui-message-feedback"] pkg_client_ui_model_selection["client-ui-model-selection"] @@ -727,12 +722,6 @@ flowchart TD pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_activity --> pkg_agent - pkg_activity --> pkg_brand - pkg_activity --> pkg_invariants - pkg_activity --> pkg_jobs - pkg_activity --> pkg_llm - pkg_activity --> pkg_session pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model pkg_headless --> pkg_invariants @@ -873,20 +862,6 @@ flowchart TD pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools - pkg_activity_local --> pkg_activity - pkg_activity_local --> pkg_agent - pkg_activity_local --> pkg_invariants - pkg_activity_local --> pkg_scope - pkg_api_activity_controller --> pkg_activity - pkg_api_activity_controller --> pkg_agent - pkg_api_activity_controller --> pkg_api_gateway - pkg_api_activity_controller --> pkg_client_connection - pkg_api_activity_controller --> pkg_client_store - pkg_api_activity_controller --> pkg_invariants - pkg_api_activity_controller --> pkg_jobs - pkg_api_activity_controller --> pkg_llm - pkg_api_activity_controller --> pkg_session - pkg_api_activity_controller --> pkg_typert_protocol pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction pkg_command_compact --> pkg_invariants @@ -1017,7 +992,6 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session - pkg_tool_workflow --> pkg_activity pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm @@ -1075,7 +1049,6 @@ flowchart TD pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_invariants pkg_host_plugin_inventory --> pkg_typert_protocol - pkg_tool_bash --> pkg_activity pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1087,7 +1060,6 @@ flowchart TD pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval - pkg_tool_pwsh --> pkg_activity pkg_tool_pwsh --> pkg_agent pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_jobs @@ -1366,7 +1338,6 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | -| [`client-ui-activity`](../packages/client/ui-activity) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | — | @@ -1379,6 +1350,7 @@ flowchart TD | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | — | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — | +| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | — | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | — | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | — | @@ -1525,7 +1497,6 @@ flowchart TD | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`activity`](../packages/activity/activity) | `activity` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1548,8 +1519,6 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | -| [`activity-local`](../packages/activity/activity-local) | `activity` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope) | -| [`api-activity-controller`](../packages/api/activity-controller) | `api` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1572,7 +1541,7 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | @@ -1581,8 +1550,8 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 86845aa000..4af048cc46 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -109,12 +109,7 @@ flowchart TD subgraph group_acp["packages/acp"] pkg_acp["acp"] end - subgraph group_activity["packages/activity"] - pkg_activity["activity"] - pkg_activity_local["activity-local"] - end subgraph group_api["packages/api"] - pkg_api_activity_controller["api-activity-controller"] pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] @@ -143,7 +138,6 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_store["client-store"] - pkg_client_ui_activity["client-ui-activity"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] @@ -156,6 +150,7 @@ flowchart TD pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] + pkg_client_ui_jobs["client-ui-jobs"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_message_feedback["client-ui-message-feedback"] pkg_client_ui_model_selection["client-ui-model-selection"] @@ -729,12 +724,6 @@ flowchart TD pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_activity --> pkg_agent - pkg_activity --> pkg_brand - pkg_activity --> pkg_invariants - pkg_activity --> pkg_jobs - pkg_activity --> pkg_llm - pkg_activity --> pkg_session pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model pkg_headless --> pkg_invariants @@ -875,20 +864,6 @@ flowchart TD pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools - pkg_activity_local --> pkg_activity - pkg_activity_local --> pkg_agent - pkg_activity_local --> pkg_invariants - pkg_activity_local --> pkg_scope - pkg_api_activity_controller --> pkg_activity - pkg_api_activity_controller --> pkg_agent - pkg_api_activity_controller --> pkg_api_gateway - pkg_api_activity_controller --> pkg_client_connection - pkg_api_activity_controller --> pkg_client_store - pkg_api_activity_controller --> pkg_invariants - pkg_api_activity_controller --> pkg_jobs - pkg_api_activity_controller --> pkg_llm - pkg_api_activity_controller --> pkg_session - pkg_api_activity_controller --> pkg_typert_protocol pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction pkg_command_compact --> pkg_invariants @@ -1019,7 +994,6 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session - pkg_tool_workflow --> pkg_activity pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm @@ -1077,7 +1051,6 @@ flowchart TD pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_invariants pkg_host_plugin_inventory --> pkg_typert_protocol - pkg_tool_bash --> pkg_activity pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1089,7 +1062,6 @@ flowchart TD pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval - pkg_tool_pwsh --> pkg_activity pkg_tool_pwsh --> pkg_agent pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_jobs @@ -1368,7 +1340,6 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | -| [`client-ui-activity`](../packages/client/ui-activity) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | — | @@ -1381,6 +1352,7 @@ flowchart TD | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | — | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — | +| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | — | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | — | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | — | @@ -1527,7 +1499,6 @@ flowchart TD | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`activity`](../packages/activity/activity) | `activity` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1550,8 +1521,6 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | -| [`activity-local`](../packages/activity/activity-local) | `activity` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope) | -| [`api-activity-controller`](../packages/api/activity-controller) | `api` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1574,7 +1543,7 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | @@ -1583,8 +1552,8 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index d75bd368d5..f3340ebeb4 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -44,7 +44,6 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | | [jobs.md](jobs.md) | the background-job runtime: branded `JobId`s, the producer contract, consumer views, and `ctx.jobs` service behavior | -| [activity.md](activity.md) | live activity observation: branded `ActivityId`s, the producer handle, non-consuming offset reads, and the wire streams | | [permission-presets.md](permission-presets.md) | the permission-preset layer: `PresetSpec`/`PresetOption`, the derived `custom` state, the log-only `permission/preset` event | | [plan.md](plan.md) | plan mode: the log-only `plan/mode` state, pending-selection flush, `PlanModeConfig`, the `exit_plan_mode` review arc | | [invariants.md](invariants.md) | the runtime-invariant registry: selection `Config`, `InvariantInstaller`/`InvariantFailure`, the empty-companion contract | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 589a72d81f..2e4523f03d 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -44,7 +44,6 @@ | [spill.md](spill.zh.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` | | [workflow.md](workflow.zh.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 | | [jobs.md](jobs.zh.md) | 后台任务运行时:品牌化 `JobId`、producer 约定、消费方视图和 `ctx.jobs` 服务行为 | -| [activity.md](activity.zh.md) | 实时活动观察:品牌化 `ActivityId`、生产者句柄、非消费 offset 读取与 wire 流 | | [permission-presets.md](permission-presets.zh.md) | 权限预设层:`PresetSpec`/`PresetOption`、派生的 `custom` 状态、仅记日志的 `permission/preset` 事件 | | [plan.md](plan.zh.md) | 计划模式:仅记日志的 `plan/mode` 状态、待定选择的冲刷、`PlanModeConfig`、`exit_plan_mode` 审阅流程 | | [invariants.md](invariants.zh.md) | 运行时不变式注册表:选择配置 `Config`、`InvariantInstaller`/`InvariantFailure`、空配套插件约定 | diff --git a/docs/subsystems/activity.md b/docs/subsystems/activity.md deleted file mode 100644 index 000bdc30be..0000000000 --- a/docs/subsystems/activity.md +++ /dev/null @@ -1,141 +0,0 @@ -# Live Activity Observation - -English | [中文](activity.zh.md) - -Types shared by streaming-output producers, `ctx.activities`, and the observation consumers. The [observation-seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) owns the design; this page records the cross-package vocabulary from [`packages/activity/activity/src/types.ts`](../../packages/activity/activity/src/types.ts) and the wire frames from [`packages/api/activity-controller/src/types.ts`](../../packages/api/activity-controller/src/types.ts). - -An **activity** is one observable unit of long-running work: an append-only bounded output stream plus live status. The registry is a pure observation plane — it starts nothing, cancels nothing, and is invisible to the model. Producers keep their execution resources and their model-facing surfaces (`ctx.jobs` consuming cursors, tool results); observers read non-consumingly at absolute byte offsets, so any number of them coexist with the model's single consuming cursor. - -## Ids, kinds, and correlation - -`ActivityId` is a [branded id](core.md#branded-ids) generated as `-N`; visibility relies on owner authorization, not id secrecy. `ActivityKindMap` is merge-extensible (`bash` seeded; `pwsh` and `workflow` merged by their producers). `ActivityCorrelation` links one activity to the `CallId` that started the work and the `JobId` carrying its model-facing control surface, so a consumer attaches the stream to an existing card or row without guessing from labels. - -## Producer face - -`open(spec)` returns an `ActivityHandle` with synchronous `append(text, { channel?, gapBefore? })`, `updateDetail(detail)`, and first-wins `end(outcome)`. Offsets advance by each chunk's UTF-8 byte length; `gapBefore` marks producer-side loss so a discontinuity stays visible. After `end` — including a teardown force-end — `append` and `updateDetail` log and drop rather than throw, so a producer's trailing flush cannot break its own teardown. - -`pumpActivityOutput(handle, sources, { pollMs, done })` adapts pull substrates: it copies each non-consuming offset reader (the subprocess `readFrom(fromByte)` family, re-exposed on `ShellProcess.observed`) into the handle at a bounded cadence and drains once more after `done` settles. Push-natural producers (the workflow tool's run mirror) append directly. - -## Observation - -`read(id, from, caller?)` returns the retained chunks overlapping `[from, total)` and the resume offset `next`, consuming nothing. Offsets stay absolute across eviction: `outputEarliest` on the snapshot names the oldest retained byte, and a read below it is `lossy: true`, never an error. Retention is provider policy ([`dsh-activity-local`](../../packages/activity/activity-local/README.md): `retainBytes` live, trimmed to `settledRetainBytes` at settlement). - -Two effect-scoped listener families deliver owner-relative on the jobs-registry terms: `onActivitiesChanged` fires per visible-set change (open, detail, settlement, removal) so consumers re-read whole rows, and `onOutput` signals stream advancement (append or settlement) carrying only the id, so each consumer reads from its own cursor. - -## Wire transport - -[`dsh-api-activity-controller`](../../packages/api/activity-controller/README.md) exposes the plane to browsers as two Remote streams: `activity.control` (baseline plus whole-bucket roster replacement frames) and `activity.observe` (one `opened` anchor, coalesced `output` frames, then the terminal `status` on the same stream, after which the generation closes). Output frames are transient — durable history stays with the producers' tool results, exactly as the [web job display decision](../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md) requires. [`dsh-client-ui-activity`](../../packages/client/ui-activity/README.md) renders the roster in the session header and opens observation streams only while a panel is expanded. - - - - - -## Cordis API - -Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - - - -### `ctx.activities` — `ActivityRegistry` (abstract seam) - -Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). - -Implementations must honor these semantics: - -- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. -- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource. -- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap. -- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition's own carrier — sees every owner, while one registered under an agent composition's scope sees exactly the agents composed under it. Every listener is contained. - -```ts cordis-catalog -/** - * Validate the spec, attach owner cleanup, and atomically register one - * activity. A rejection leaves no id; after return the producer owns the - * handle and the visible set has changed. - * @param spec - activity identity, owner, and correlation. - * @returns the producer face of the registered activity. - */ -abstract open(spec: ActivityOpen): ActivityHandle - -/** - * List caller-owned and unowned activities in registration order without - * exposing another session's labels or output. - * @param caller - reading agent; a non-agent caller sees only unowned activities. - * @returns fresh snapshots. - */ -abstract list(caller?: Agent): ActivitySnapshot[] - -/** - * Return a fresh snapshot. Throws for an unknown or foreign activity. - * @param id - activity to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ -abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot - -/** - * Read retained output from an absolute byte offset without consuming it. - * Resume by passing a previous read's `next`; a foreign offset inside a - * retained chunk returns that whole chunk (its `at` may precede `from`). - * Throws for an unknown or foreign activity or a negative or non-integer - * offset. - * @param id - activity to read. - * @param from - absolute byte offset to read from (0 for the retained head). - * @param caller - reading agent checked against the owner. - * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. - */ -abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead - -/** - * Register an effect-scoped observer of visible-set changes: opening, - * detail updates, settlement, owner-disposal removal, and the emptying that - * service disposal commits — so an observer re-reads rather than - * accumulating deltas. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned activity changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ -abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void - -/** - * Register an effect-scoped observer of stream advancement — one signal per - * committed append and one at settlement, carrying only the activity id. - * A consumer schedules a {@link read} from its own cursor; the registry - * never pushes payloads. Listeners are contained and never awaited. - * @param listener - receives the id whose stream advanced. - * @returns disposer that unregisters the listener. - */ -abstract onOutput(listener: ActivityOutputListener): () => void -``` - -Types: [Agent](core.md) - -Source: [`packages/activity/activity/src/index.ts`](../../packages/activity/activity/src/index.ts) - - - -### `ctx.activityController` — `ActivityController` - -Host service backing the generated `ctx.remote.activity` namespace. - -```ts cordis-catalog -/** - * Stream a complete roster baseline followed by whole-bucket replacements. - * @param signal - generation cancellation. - * @returns baseline followed by per-owner roster replacement frames. - */ -@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable - -/** - * Stream one activity's retained output from an absolute byte offset, then - * its terminal status once settled and drained. Non-consuming: the - * model-facing cursors never observe these reads. - * @param request - target activity and optional resume offset. - * @param signal - generation cancellation. - * @returns anchor, coalesced output frames, and the terminal status. - */ -@Remote({ mode: 'stream' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable -``` - -Source: [`packages/api/activity-controller/src/index.ts`](../../packages/api/activity-controller/src/index.ts) - diff --git a/docs/subsystems/activity.zh.md b/docs/subsystems/activity.zh.md deleted file mode 100644 index 1fc79e9d59..0000000000 --- a/docs/subsystems/activity.zh.md +++ /dev/null @@ -1,141 +0,0 @@ -# 实时活动观察 - -[English](activity.md) | 中文 - -流式输出生产者、`ctx.activities` 与观察消费者共享的类型。设计归[观察 seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)所有;本页记录 [`packages/activity/activity/src/types.ts`](../../packages/activity/activity/src/types.ts) 的跨包词汇与 [`packages/api/activity-controller/src/types.ts`](../../packages/api/activity-controller/src/types.ts) 的 wire 帧。 - -**activity** 是一个可观察的长时工作单元:一条 append-only 的有界输出流加实时状态。注册表是纯观察面——不启动、不取消任何工作,对模型不可见。生产者保留自己的执行资源与模型面(`ctx.jobs` 消费游标、工具结果);观察者按绝对字节 offset 非消费读取,任意数量的观察者可与模型的单一消费游标共存。 - -## Id、kind 与关联 - -`ActivityId` 是[品牌化 id](core.zh.md#branded-ids),生成为 `-N`;可见性靠 owner 授权而非 id 保密。`ActivityKindMap` 可合并扩展(内置 `bash`;`pwsh` 与 `workflow` 由各自生产者合并)。`ActivityCorrelation` 把一个 activity 关联到发起工作的 `CallId` 与承载其模型控制面的 `JobId`,消费者据此把流挂到已有卡片或行上,不必靠 label 猜。 - -## 生产者面 - -`open(spec)` 返回 `ActivityHandle`:同步的 `append(text, { channel?, gapBefore? })`、`updateDetail(detail)` 与 first-wins 的 `end(outcome)`。offset 按每个 chunk 的 UTF-8 字节长度推进;`gapBefore` 标记生产者侧丢失,让断点保持可见。`end` 之后——包括 teardown 强制 end——`append` 与 `updateDetail` 记日志后丢弃而不抛错,生产者的收尾 flush 不会砸掉自己的 teardown。 - -`pumpActivityOutput(handle, sources, { pollMs, done })` 适配 pull 型底座:把每个非消费 offset 读取器(subprocess 的 `readFrom(fromByte)` 家族,经 `ShellProcess.observed` 复出)按有界节奏复制进句柄,并在 `done` 结算后再排干一次。push 天然的生产者(workflow 工具的运行镜像)直接 append。 - -## 观察 - -`read(id, from, caller?)` 返回与 `[from, total)` 重叠的保留 chunk 及续读 offset `next`,绝不消费。offset 跨淘汰稳定:快照上的 `outputEarliest` 指明最老保留字节,低于它的读取得到 `lossy: true` 而非错误。保留量是 provider 策略([`dsh-activity-local`](../../packages/activity/activity-local/README.zh.md):存活 `retainBytes`,结算时裁到 `settledRetainBytes`)。 - -两个 effect 范围的监听族按 jobs 注册表条款做 owner 相对投递:`onActivitiesChanged` 在每次可见集变化(开启、detail、结算、移除)时触发,消费者整读行;`onOutput` 发出流推进信号(append 或结算),只带 id,各消费者按自己的游标读取。 - -## Wire 传输 - -[`dsh-api-activity-controller`](../../packages/api/activity-controller/README.zh.md) 以两条 Remote 流把观察面暴露给浏览器:`activity.control`(baseline + 整桶 roster 替换帧)与 `activity.observe`(一帧 `opened` 锚点、合并的 `output` 帧、同流的终态 `status`,之后本代关闭)。输出帧是瞬态的——持久历史仍在生产者的工具结果里,与 [web job display 决策](../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md)的要求一致。[`dsh-client-ui-activity`](../../packages/client/ui-activity/README.zh.md) 在会话头部渲染 roster,只在面板展开时打开观察流。 - - - - - -## Cordis API - -Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - - - -### `ctx.activities` — `ActivityRegistry` (abstract seam) - -Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). - -Implementations must honor these semantics: - -- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. -- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource. -- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap. -- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition's own carrier — sees every owner, while one registered under an agent composition's scope sees exactly the agents composed under it. Every listener is contained. - -```ts cordis-catalog -/** - * Validate the spec, attach owner cleanup, and atomically register one - * activity. A rejection leaves no id; after return the producer owns the - * handle and the visible set has changed. - * @param spec - activity identity, owner, and correlation. - * @returns the producer face of the registered activity. - */ -abstract open(spec: ActivityOpen): ActivityHandle - -/** - * List caller-owned and unowned activities in registration order without - * exposing another session's labels or output. - * @param caller - reading agent; a non-agent caller sees only unowned activities. - * @returns fresh snapshots. - */ -abstract list(caller?: Agent): ActivitySnapshot[] - -/** - * Return a fresh snapshot. Throws for an unknown or foreign activity. - * @param id - activity to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ -abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot - -/** - * Read retained output from an absolute byte offset without consuming it. - * Resume by passing a previous read's `next`; a foreign offset inside a - * retained chunk returns that whole chunk (its `at` may precede `from`). - * Throws for an unknown or foreign activity or a negative or non-integer - * offset. - * @param id - activity to read. - * @param from - absolute byte offset to read from (0 for the retained head). - * @param caller - reading agent checked against the owner. - * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. - */ -abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead - -/** - * Register an effect-scoped observer of visible-set changes: opening, - * detail updates, settlement, owner-disposal removal, and the emptying that - * service disposal commits — so an observer re-reads rather than - * accumulating deltas. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned activity changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ -abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void - -/** - * Register an effect-scoped observer of stream advancement — one signal per - * committed append and one at settlement, carrying only the activity id. - * A consumer schedules a {@link read} from its own cursor; the registry - * never pushes payloads. Listeners are contained and never awaited. - * @param listener - receives the id whose stream advanced. - * @returns disposer that unregisters the listener. - */ -abstract onOutput(listener: ActivityOutputListener): () => void -``` - -Types: [Agent](core.zh.md) - -Source: [`packages/activity/activity/src/index.ts`](../../packages/activity/activity/src/index.ts) - - - -### `ctx.activityController` — `ActivityController` - -Host service backing the generated `ctx.remote.activity` namespace. - -```ts cordis-catalog -/** - * Stream a complete roster baseline followed by whole-bucket replacements. - * @param signal - generation cancellation. - * @returns baseline followed by per-owner roster replacement frames. - */ -@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable - -/** - * Stream one activity's retained output from an absolute byte offset, then - * its terminal status once settled and drained. Non-consuming: the - * model-facing cursors never observe these reads. - * @param request - target activity and optional resume offset. - * @param signal - generation cancellation. - * @returns anchor, coalesced output frames, and the terminal status. - */ -@Remote({ mode: 'stream' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable -``` - -Source: [`packages/api/activity-controller/src/index.ts`](../../packages/api/activity-controller/src/index.ts) - diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 092da7b6f7..1b8e58f27b 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -23,7 +23,7 @@ interface JobKindMap { ## Producer contract -`JobStart` declares identity and a starter. The runtime finishes preflight before calling `run()` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. +`JobStart` declares identity and a starter. The runtime finishes preflight before calling `run()` with the job's producer face and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. ```ts type-equiv /** @@ -38,7 +38,9 @@ interface JobStart { label: string /** * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. + * output read, including controller status metadata. Independent of record + * retention: it bounds the consuming model surface, never + * {@link JobRegistry.readRecord}. */ outputLimitBytes?: number /** @@ -49,11 +51,22 @@ interface JobStart { */ owner?: Agent /** - * Start the work after preflight and synchronously return its hooks. Called - * once; a throw leaves nothing registered, and the producer must clean up any - * partially started resources. + * Declares an observable output record beside the model-facing surfaces: + * {@link RunningJob.append} retains chunks in a bounded ring that any number + * of observers read at absolute byte offsets through + * {@link JobRegistry.readRecord}, and snapshots carry + * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + * Without the declaration `append` logs and drops. */ - run(): JobHooks + record?: true + /** + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the record append and live-detail writers. + */ + run(job: RunningJob): JobHooks } ``` @@ -71,13 +84,16 @@ interface JobHooks { * Resolves after the producer releases its resources, not merely when work * finishes. Must not reject; the runtime converts a rejection to `failed`. * If teardown cancellation throws, the runtime may force-fail only the - * registry record without claiming that the work stopped. + * registry record without claiming that the work stopped. Settlement also + * ends the record: fold any record-filling pump into this promise so the + * final drain lands before the registry trims and closes the stream. */ done: Promise /** * Consume output produced since the previous call. The producer formats * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. + * job has one consuming cursor. Independent of the record: this is the + * model-facing projection, {@link JobRegistry.readRecord} the observer one. */ readOutput?(): string } @@ -95,6 +111,70 @@ interface JobOutcome { } ``` +## Observation record + +A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RunningJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. + +```ts type-equiv +/** + * Producer face of one registered job, handed to {@link JobStart.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — both methods log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. + */ +interface RunningJob { + /** The registry-issued id (`-N`). */ + readonly id: JobId + /** + * Append one record chunk. Offsets advance by the chunk's UTF-8 byte + * length; an empty chunk is dropped without waking observers. Without a + * {@link JobStart.record} declaration the chunk is logged and dropped. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + */ + append(text: string, options?: JobAppendOptions): void + /** + * Replace the snapshot's status detail with a live progress line (`3/10`). + * Works for every job, with or without a record. + * @param detail - the new detail line. + */ + updateDetail(detail: string): void +} +``` + +```ts type-equiv +/** One retained record chunk returned by {@link JobRegistry.readRecord}. */ +interface JobRecordChunk { + /** Absolute offset of the chunk's first byte. */ + at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + text: string + /** Stream label, when the producer supplied one. */ + channel?: JobChannel + /** Producer-reported loss immediately before this chunk. */ + gapBefore?: true +} +``` + +```ts type-equiv +/** Result of one non-consuming {@link JobRegistry.readRecord}. */ +interface JobRecordRead { + /** Retained chunks overlapping `[from, total)`, in offset order. */ + chunks: readonly JobRecordChunk[] + /** + * Offset to resume from — the record's current `outputTotal`. Always a + * chunk boundary: appends land whole and trimming only advances chunk + * starts, and consumers concatenate `chunks` under that assumption, so a + * provider serving partial chunks would silently duplicate text. + */ + next: number + /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean +} +``` + ## Consumer views Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another reporter has delivered or committed to deliver the terminal state, including the teardown cancel that drains an owner or the service. @@ -121,7 +201,10 @@ interface JobSnapshot { ownerSession?: SessionId /** Current lifecycle state. */ status: JobStatus - /** Kind-specific status detail, present once the producer supplied one (usually terminal). */ + /** + * Kind-specific status detail: live progress while the producer updates it + * through {@link RunningJob.updateDetail}, the terminal detail once settled. + */ detail?: string /** Epoch ms when the job was registered. */ startedAt: number @@ -135,6 +218,17 @@ interface JobSnapshot { * otherwise spend a model request per teardown layer. */ reported: boolean + /** + * Total UTF-8 bytes ever appended to the record — the offset the next chunk + * starts at. Present exactly when the job declared {@link JobStart.record}. + */ + outputTotal?: number + /** + * Offset of the oldest retained record byte. Greater than zero exactly when + * retention dropped the head; a reader starting below it gets a lossy read. + * Present exactly when the job declared {@link JobStart.record}. + */ + outputEarliest?: number } ``` @@ -154,7 +248,7 @@ interface JobRead { ## Service behavior -The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onJobDone` and `onJobsChanged` listeners, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer. +The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, non-consuming `readRecord`, `kill`, bounded `wait`, failure-isolated `onJobDone`, `onJobsChanged`, and `onOutput` listeners, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity, and `retainBytes` (default 262144) with `settledRetainBytes` (default 16384) bound each declared record's live and settled retention. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer. @@ -176,6 +270,8 @@ Implementations must honor these semantics: - Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. - Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously. - start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected. +- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle. ```ts cordis-catalog /** @@ -274,6 +370,31 @@ abstract onJobDone(listener: JobDoneListener): () => void */ abstract onJobsChanged(listener: JobsChangedListener): () => void +/** + * Read retained record output from an absolute byte offset without consuming + * it. Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Never marks the job reported. Throws for an unknown or foreign job, a job + * without a {@link JobStart.record} declaration, or a negative or + * non-integer offset. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ +abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead + +/** + * Register an effect-scoped observer of record advancement — one signal per + * committed append and one at settlement, carrying only the job id. A + * consumer schedules a {@link readRecord} from its own cursor; the registry + * never pushes payloads. Delivery is owner-relative on the same terms as + * {@link onJobsChanged}. Listeners are contained and never awaited. + * @param listener - receives the id whose record advanced. + * @returns disposer that unregisters the listener. + */ +abstract onOutput(listener: JobOutputListener): () => void + /** * Attach an effect-scoped controller that can read and stop jobs. It serves the * owners its registering context's scope covers, and {@link start} refuses an diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 9dc390eb30..5928c640c4 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -176,6 +176,8 @@ Implementations must honor these semantics: - Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. - Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously. - start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected. +- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle. ```ts cordis-catalog /** @@ -274,6 +276,31 @@ abstract onJobDone(listener: JobDoneListener): () => void */ abstract onJobsChanged(listener: JobsChangedListener): () => void +/** + * Read retained record output from an absolute byte offset without consuming + * it. Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Never marks the job reported. Throws for an unknown or foreign job, a job + * without a {@link JobStart.record} declaration, or a negative or + * non-integer offset. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ +abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead + +/** + * Register an effect-scoped observer of record advancement — one signal per + * committed append and one at settlement, carrying only the job id. A + * consumer schedules a {@link readRecord} from its own cursor; the registry + * never pushes payloads. Delivery is owner-relative on the same terms as + * {@link onJobsChanged}. Listeners are contained and never awaited. + * @param listener - receives the id whose record advanced. + * @returns disposer that unregisters the listener. + */ +abstract onOutput(listener: JobOutputListener): () => void + /** * Attach an effect-scoped controller that can read and stop jobs. It serves the * owners its registering context's scope covers, and {@link start} refuses an diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 8ad0ddf475..3ec8288309 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -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 docs/subsystems/session.md -session.md: d35395fb80a6558e15b82cdb76559f59e492f425 -session.zh.md: d3bc750b5c198e38efa94c88f95dcb9406c9059b +session.md: da62600efed8b0cd5bb134fd664582634eb0d2c3 +session.zh.md: 448d173d694d17afc9e03cd689ae07a1369b155c diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index d35395fb80..da62600efe 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -740,6 +740,18 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH * @returns one complete baseline followed by live replacement frames. */ @Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable + +/** + * Stream one job's retained record output from an absolute byte offset, + * then its terminal status once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session resolves the fenced-read caller; the registry rejects a + * job the session does not own, a job without a record, or an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable ``` Types: [SessionHeader](persistence.md) · [SessionId](core.md) · [SessionSearchRequest](session-query.md) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d3bc750b5c..448d173d69 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -744,6 +744,18 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH * @returns one complete baseline followed by live replacement frames. */ @Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable + +/** + * Stream one job's retained record output from an absolute byte offset, + * then its terminal status once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session resolves the fenced-read caller; the registry rejects a + * job the session does not own, a job without a record, or an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable ``` Types: [SessionHeader](persistence.zh.md) · [SessionId](core.zh.md) · [SessionSearchRequest](session-query.zh.md) diff --git a/packages/activity/README.i18n.yaml b/packages/activity/README.i18n.yaml deleted file mode 100644 index 3030bc2d1d..0000000000 --- a/packages/activity/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# 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/activity/README.md -README.md: bc9e67703b2888048eacd3a8e060aaa739948eac -README.zh.md: 26f339b8f262b90001355b690f736e2d1089cab5 diff --git a/packages/activity/README.md b/packages/activity/README.md deleted file mode 100644 index bc9e67703b..0000000000 --- a/packages/activity/README.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -description: "The activity package group: the non-consuming live-output observation plane under packages/activity/, for readers choosing or navigating the family." -kind: "package-group" ---- - -# activity/ — streaming-output observation family - -English | [中文](README.zh.md) - -## Summary - -This family lets any number of independent observers watch a long-running producer's live output without touching the model-facing paths: an activity is an append-only bounded output stream plus live status, read at absolute byte offsets that survive eviction. `activity` defines the registry contract and vocabulary; `activity-local` implements the in-memory ring-buffer registry. The plane is optional and invisible to the model — a composition without it loses only live observation. - -## Table of Contents - -- [Packages](#packages) -- [Related documentation](#related-documentation) -- [Dev Note](#dev-note) - ------ - - -## Packages - -The Service Definition and its local implementation: - -| Package | Role | ctx key | -|---|---|---| -| [`activity/`](activity/README.md) | Defines the observation registry and vocabulary | `ctx.activities` | -| [`activity-local/`](activity-local/README.md) | Implements the in-memory ring-buffer registry | registers on `ctx.activities` | - - -## Related documentation - -- [`dsh-api-activity-controller`](../api/activity-controller/README.md) — the Web transport and client model over this plane. -- [`dsh-client-ui-activity`](../client/ui-activity/README.md) — the session-header task list rendering it. -- [docs/subsystems/activity.md](../../docs/subsystems/activity.md) — the subsystem reference: offsets, retention, listener delivery. -- [Activity observation seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) — the design decision; background bash/pwsh and the workflow tool are the shipped producers. - - -## Dev Note - -None. diff --git a/packages/activity/README.zh.md b/packages/activity/README.zh.md deleted file mode 100644 index 26f339b8f2..0000000000 --- a/packages/activity/README.zh.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -description: "activity 包组:packages/activity/ 下的非消费实时输出观察面,供选择或导航该家族的读者使用。" -kind: "package-group" ---- - -# activity/ — 流式输出观察家族 - -[English](README.md) | 中文 - -## 概述 - -这个家族让任意数量的独立观察者在不触碰模型可见路径的前提下观看长时运行生产者的实时输出:一个 activity 是一条只追加、有界的输出流加实时状态,以跨淘汰仍有效的绝对字节偏移读取。`activity` 定义注册表契约与词汇;`activity-local` 实现内存环形缓冲注册表。该观察面是可选的且对模型不可见——没有它的组合只失去实时观察。 - -## 目录 - -- [包](#packages) -- [相关文档](#related-documentation) -- [开发备注](#dev-note) - ------ - - -## 包 - -Service Definition 与其本地实现: - -| 包 | 角色 | ctx 键 | -|---|---|---| -| [`activity/`](activity/README.zh.md) | 定义观察注册表与词汇 | `ctx.activities` | -| [`activity-local/`](activity-local/README.zh.md) | 实现内存环形缓冲注册表 | 注册在 `ctx.activities` | - - -## 相关文档 - -- [`dsh-api-activity-controller`](../api/activity-controller/README.zh.md)——架在该面之上的 Web 传输与客户端模型。 -- [`dsh-client-ui-activity`](../client/ui-activity/README.zh.md)——渲染它的会话头部任务列表。 -- [docs/subsystems/activity.md](../../docs/subsystems/activity.zh.md)——子系统参考:偏移、留存、监听器投递。 -- [activity 观察面 Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)——设计决策;后台 bash/pwsh 与 workflow 工具是已交付的生产者。 - - -## 开发备注 - -无。 diff --git a/packages/activity/activity-local/README.i18n.yaml b/packages/activity/activity-local/README.i18n.yaml deleted file mode 100644 index 9487171fa8..0000000000 --- a/packages/activity/activity-local/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# 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/activity/activity-local/README.md -README.md: ef010e1fe859d2ffd56d736b24e490d73bbb49a5 -README.zh.md: 7d9779fc64e7e7d6e47e3a2e78fb80ca097abb6c diff --git a/packages/activity/activity-local/README.md b/packages/activity/activity-local/README.md deleted file mode 100644 index ef010e1fe8..0000000000 --- a/packages/activity/activity-local/README.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -description: "The in-memory activity registry: configurable ring-buffer retention (retainBytes, settledRetainBytes), absolute offsets across eviction, owner-scoped cleanup, and layered listener delivery for live-output observation." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-activity-local - -English | [中文](README.zh.md) - -## Summary - -`dsh-activity-local` makes `ctx.activities` real: mount its one row and every producer's live output is retained in a bounded in-process ring readers address by absolute byte offsets, with fresh snapshots and chunk copies handed out — never live state. Retention is configurable per deployment, records outlive their producer fibers, and owner disposal cleans up exactly the disposed session's rows. Choose it for the single-Host web deployment this repository ships; durable or cross-process observation needs a different provider. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Mount one `cordis.yml` row (`name: '@deepseek-ai/dsh-activity-local'`) beside `dsh-agent`; producers and consumers then reach the registry as `ctx.activities`. - -### Retention - -Each activity retains a chunk ring whose offsets stay absolute across eviction: dropping the head advances `outputEarliest`, and a single chunk larger than the live cap keeps only its UTF-8-safe tail with a `gapBefore` marker. Settlement trims the ring to the settled cap so a finished activity still shows its tail without holding the live budget. - -| Config | Default | Meaning | -|---|---|---| -| `retainBytes` | `262144` | Live output retention per activity, in UTF-8 bytes. | -| `settledRetainBytes` | `16384` | Retention kept after an activity settles. | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-activity-local) is the exhaustive source for every accepted field. - -### Lifecycle - -Records outlive producer fibers. The first activity for an owner attaches one cleanup to the exact `Agent` scope; owner disposal ends still-open records (`killed`, `owner disposed`) and removes them, announcing the removal. Service disposal ends everything, clears the store, and announces each emptied owner. Neither awaits a producer — the registry owns no execution resource. - ------ - - -## Understand the implementation - -
        -Implementation internals — click to expand - -One process-wide `LocalActivityRegistry` keeps a map of tracked records, each holding its chunk ring and cursor facts. Listeners are layered by registration scope exactly like the jobs registry, so delivery stays owner-relative in one instance, and every listener is contained. Trimming walks whole chunks and only tail-cuts a single oversized chunk on a UTF-8 boundary. - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | The registry: rings, retention, fencing, scoped listener layers, disposal | -| [`src/invariant.ts`](src/invariant.ts) | Package invariant companion | - -
        - ------ - - -## Further Exploration - -- [`dsh-activity`](../activity/README.md) — the contract this provider implements. -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.md) — offsets, retention, and delivery in one reference. -- [`dsh-api-activity-controller`](../../api/activity-controller/README.md) — the Remote streams reading this registry. - ------ - - -## Model Experience - -None, as the provider stores and serves observation state only; the producers own every model-visible surface. - -#### KV Cache effect - -None; the package never assembles or alters provider requests. - -## Known Limitations and Deferred Work - - - -These limits define when the provider is a poor fit. They are current package constraints, not a task backlog. - -- **The unowned bucket has no admission bound** — owned producers are already bounded by the jobs admission policy, but a plugin opening many unowned activities grows the store until service disposal. - - -### Dev Note - -
        -Working context for maintainers — click to expand - -None. - -
        diff --git a/packages/activity/activity-local/README.zh.md b/packages/activity/activity-local/README.zh.md deleted file mode 100644 index 7d9779fc64..0000000000 --- a/packages/activity/activity-local/README.zh.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -description: "内存 activity 注册表:可配置的环形缓冲留存(retainBytes、settledRetainBytes)、跨淘汰的绝对偏移、按 owner 作用域的清理,以及实时输出观察的分层监听器投递。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-activity-local - -[English](README.md) | 中文 - -## 概述 - -`dsh-activity-local` 让 `ctx.activities` 落地:挂载它的一行后,每个生产者的实时输出保留在有界的进程内环里,读者以绝对字节偏移寻址,拿到的是新鲜快照与 chunk 拷贝——从不是活状态。留存按部署可配置,记录比生产者 fiber 活得久,owner 释放只清理被释放会话的行。为本仓库交付的单 Host web 部署选择它;持久或跨进程观察需要别的 provider。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -在 `dsh-agent` 旁挂载一行 `cordis.yml`(`name: '@deepseek-ai/dsh-activity-local'`);生产者与消费者随即以 `ctx.activities` 触达注册表。 - -### 留存 - -每个 activity 保留一个偏移跨淘汰仍绝对的 chunk 环:丢弃头部会前移 `outputEarliest`,单个超过活跃上限的 chunk 只保留 UTF-8 安全的尾部并带 `gapBefore` 标记。结算把环裁到 settled 上限,让完成的 activity 仍展示尾部而不占活跃预算。 - -| 配置 | 默认 | 含义 | -|---|---|---| -| `retainBytes` | `262144` | 每个 activity 的活跃输出留存,UTF-8 字节。 | -| `settledRetainBytes` | `16384` | activity 结算后保留的留存量。 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-activity-local)是每个可接受字段的穷尽来源。 - -### 生命周期 - -记录比生产者 fiber 活得久。owner 的第一个 activity 在确切的 `Agent` 作用域上挂一个清理;owner 释放会终结仍开放的记录(`killed`、`owner disposed`)并移除它们、announce 该移除。服务释放终结一切、清空存储并 announce 每个被清空的 owner。两者都不等待生产者——注册表不拥有任何执行资源。 - ------ - - -## 理解实现 - -
        -实现内幕——点击展开 - -一个进程级 `LocalActivityRegistry` 维护被跟踪记录的 map,每条持有自己的 chunk 环与游标事实。监听器按注册作用域分层,与 jobs 注册表完全一致,投递在单实例内保持 owner 相对,且每个监听器都被包住。裁剪按整 chunk 走,只对单个超大 chunk 在 UTF-8 边界上做尾部截取。 - -| 文件 | 角色 | -|---|---| -| [`src/index.ts`](src/index.ts) | 注册表:环、留存、栅栏、作用域监听层、释放 | -| [`src/invariant.ts`](src/invariant.ts) | 包不变式伴生 | - -
        - ------ - - -## 进一步探索 - -- [`dsh-activity`](../activity/README.zh.md)——本 provider 实现的契约。 -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.zh.md)——偏移、留存与投递的单一参考。 -- [`dsh-api-activity-controller`](../../api/activity-controller/README.zh.md)——读取本注册表的 Remote 流。 - ------ - - -## 模型体验 - -无:provider 只存储与提供观察状态;模型可见表面全部归生产者所有。 - -#### KV Cache 影响 - -无;本包从不组装或改动 provider 请求。 - -## 已知限制与延期工作 - - - -这些限制说明该 provider 何时不合适。它们是当前包约束,不是任务积压。 - -- **无主桶没有准入上限**——有主生产者已受 jobs 准入策略约束,但打开大量无主 activity 的插件会让存储一直增长到服务释放。 - - -### 开发备注 - -
        -维护者的工作上下文——点击展开 - -无。 - -
        diff --git a/packages/activity/activity-local/package.json b/packages/activity/activity-local/package.json deleted file mode 100644 index d3b08606ce..0000000000 --- a/packages/activity/activity-local/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-activity-local", - "description": "Process-local implementation of the DeepSeek Harness streaming-output observation seam", - "version": "0.1.2-alpha.2", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/activity/activity-local" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts" - ], - "license": "MIT", - "peerDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - } -} diff --git a/packages/activity/activity-local/src/index.ts b/packages/activity/activity-local/src/index.ts deleted file mode 100644 index caabb5499e..0000000000 --- a/packages/activity/activity-local/src/index.ts +++ /dev/null @@ -1,438 +0,0 @@ -/** - * Process-local provider for the streaming-output observation seam - * (`ctx.activities`). It keeps every record and its bounded output ring in - * memory and hands out fresh snapshots and chunk copies, never live state. - * - * Records outlive producer fibers. Owner disposal ends still-open records and - * removes them; service disposal ends and clears everything. Neither awaits a - * producer — the registry owns no execution resource. - * @module @deepseek-ai/dsh-activity-local - */ - -import { Context } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' -import type { ScopeLayer } from '@deepseek-ai/dsh-scope' -import { ActivityRegistry, ActivityId } from '@deepseek-ai/dsh-activity' -import type { - ActivityAppendOptions, ActivityChannel, ActivityCorrelation, ActivitiesChangedListener, - ActivityHandle, ActivityKind, ActivityOpen, ActivityOutcome, ActivityOutputChunk, - ActivityOutputListener, ActivityRead, ActivitySnapshot, ActivityStatus, -} from '@deepseek-ai/dsh-activity' - -/** Default live retention per activity, in UTF-8 bytes. */ -const DEFAULT_RETAIN_BYTES = 256 * 1024 - -/** Default retention kept after settlement, in UTF-8 bytes. */ -const DEFAULT_SETTLED_RETAIN_BYTES = 16 * 1024 - -/** Configuration for the process-local observation registry. */ -export interface Config { - /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ - retainBytes?: number - /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ - settledRetainBytes?: number -} - -/** The registry's mutable per-activity record (never handed out — see {@link LocalActivityRegistry.snapshot}). */ -interface TrackedActivity { - id: ActivityId - kind: ActivityKind - label: string - /** Exact lifecycle owner; session-id visibility is derived from it. */ - owner: Agent | undefined - correlation: ActivityCorrelation | undefined - status: ActivityStatus - detail: string | undefined - startedAt: number - finishedAt: number | undefined - /** Retained chunks in offset order; offsets stay absolute across eviction. */ - chunks: RingChunk[] - /** Sum of the retained chunks' byte lengths. */ - retainedBytes: number - /** Total UTF-8 bytes ever appended. */ - total: number - /** Offset of the oldest retained byte (equals {@link total} when nothing is retained). */ - earliest: number -} - -/** One retained ring entry; `bytes` caches the chunk's UTF-8 length. */ -interface RingChunk { - at: number - text: string - bytes: number - channel?: ActivityChannel - gapBefore?: true -} - -/** True for the three terminal {@link ActivityStatus} values. */ -function isTerminal(status: ActivityStatus): boolean { - return status === 'completed' || status === 'failed' || status === 'killed' -} - -/** - * The UTF-8-safe tail of `text` no longer than `maxBytes`: the byte cut - * advances past continuation bytes so the surviving text never starts inside - * a code point. - * @param text - the oversized chunk text. - * @param maxBytes - positive byte budget for the surviving tail. - * @returns the surviving tail and its exact byte length. - */ -function utf8Tail(text: string, maxBytes: number): { text: string; bytes: number } { - const raw = Buffer.from(text, 'utf8') - let start = raw.length - maxBytes - // The loop bound proves the index is in range; the assertion only - // discharges noUncheckedIndexedAccess. - while (start < raw.length && ((raw[start] as number) & 0xC0) === 0x80) start += 1 - const tail = raw.subarray(start) - return { text: tail.toString('utf8'), bytes: tail.length } -} - -/** - * One scope's observer contributions. Both tables are anonymous because a - * contribution is identified by its own disposer, never by a name a second - * registrant could shadow. - */ -class ActivityLayer implements ScopeLayer { - readonly changed = new AnonymousEntries() - readonly output = new AnonymousEntries() - - isEmpty(): boolean { - return this.changed.isEmpty() && this.output.isEmpty() - } -} - -/** - * The in-memory `activities` registry. See the Service Definition contract in - * `@deepseek-ai/dsh-activity` for the observation, retention, and lifecycle - * semantics this implementation honors. - */ -export class LocalActivityRegistry extends ActivityRegistry { - static Config: z = z.object({ - retainBytes: z.number() - .step(1) - .min(1) - .max(Number.MAX_SAFE_INTEGER) - .default(DEFAULT_RETAIN_BYTES), - settledRetainBytes: z.number() - .step(1) - .min(1) - .max(Number.MAX_SAFE_INTEGER) - .default(DEFAULT_SETTLED_RETAIN_BYTES), - }) - - /** Schemastery-defaulted live retention cap. */ - private readonly retainBytes: number - /** Schemastery-defaulted settled retention cap. */ - private readonly settledRetainBytes: number - private store = new Map() - private counters = new Map() - /** - * Observers layered by the scope that registered them, in the jobs-registry - * shape: a contribution files into its registering context's scope, and a - * dispatch unions the global layer with the subject owner's scope chain, so - * delivery stays owner-relative in one process-wide registry. Nothing - * derives a cache from a layer, so change notification is a no-op. - */ - private readonly layers = new ScopedLayers(() => new ActivityLayer(), () => {}) - /** Owner agents with attached scope cleanup, mapped to the exact disposer. */ - private ownerCleanups = new Map Promise | void>() - /** Service context used by contained listener reporting and teardown. */ - private readonly selfCtx: Context - - constructor(ctx: Context, config: Config) { - super(ctx) - // Schemastery validates and fills the defaults before constructing the service. - this.retainBytes = (config as Required).retainBytes - this.settledRetainBytes = (config as Required).settledRetainBytes - this.selfCtx = ctx - ctx.effect(() => () => { this.disposeAll() }, 'activities teardown') - } - - open(spec: ActivityOpen): ActivityHandle { - if (spec.kind.length === 0) throw new Error('invalid activity kind: expected a non-empty string') - if (spec.label.length === 0) throw new Error('invalid activity label: expected a non-empty string') - if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) - - const count = (this.counters.get(spec.kind) ?? 0) + 1 - this.counters.set(spec.kind, count) - const id = ActivityId(`${spec.kind}-${count}`) - const record: TrackedActivity = { - id, - kind: spec.kind, - label: spec.label, - owner: spec.owner, - correlation: spec.correlation === undefined ? undefined : { ...spec.correlation }, - status: 'running', - detail: undefined, - startedAt: Date.now(), - finishedAt: undefined, - chunks: [], - retainedBytes: 0, - total: 0, - earliest: 0, - } - this.store.set(id, record) - // Registration is complete and cannot fail from here, so the visible set - // has genuinely changed. - this.notifyChanged(record.owner) - return { - id, - append: (text, options) => { this.append(record, text, options) }, - updateDetail: (detail) => { this.updateDetail(record, detail) }, - end: (outcome) => { this.settle(record, outcome) }, - } - } - - list(caller?: Agent): ActivitySnapshot[] { - const session = caller?.id - return [...this.store.values()] - .filter(record => record.owner === undefined || record.owner.id === session) - .map(record => this.snapshot(record)) - } - - get(id: ActivityId, caller?: Agent): ActivitySnapshot { - const record = this.expect(id) - this.assertAccess(record, caller) - return this.snapshot(record) - } - - read(id: ActivityId, from: number, caller?: Agent): ActivityRead { - const record = this.expect(id) - this.assertAccess(record, caller) - if (!Number.isSafeInteger(from) || from < 0) { - throw new Error(`invalid read offset: expected a non-negative safe integer, got ${JSON.stringify(from)}`) - } - const chunks: ActivityOutputChunk[] = [] - for (const chunk of record.chunks) { - if (chunk.at + chunk.bytes <= from) continue - chunks.push({ - at: chunk.at, - text: chunk.text, - ...chunk.channel !== undefined ? { channel: chunk.channel } : {}, - ...chunk.gapBefore !== undefined ? { gapBefore: chunk.gapBefore } : {}, - }) - } - return { chunks, next: record.total, lossy: from < record.earliest } - } - - onActivitiesChanged(listener: ActivitiesChangedListener): () => void { - return this.layers.effect( - this.ctx, - layer => layer.changed.append(listener), - { label: 'activities.onActivitiesChanged()' }, - ) - } - - onOutput(listener: ActivityOutputListener): () => void { - return this.layers.effect( - this.ctx, - layer => layer.output.append(listener), - { label: 'activities.onOutput()' }, - ) - } - - /** Append one chunk to a live record; log and drop on an ended one. */ - private append(record: TrackedActivity, text: string, options?: ActivityAppendOptions): void { - if (isTerminal(record.status)) { - this.selfCtx.logger.warn(`activities: append to ended activity ${record.id} dropped`) - return - } - if (text.length === 0) return - const bytes = Buffer.byteLength(text, 'utf8') - record.chunks.push({ - at: record.total, - text, - bytes, - ...options?.channel !== undefined ? { channel: options.channel } : {}, - ...options?.gapBefore !== undefined ? { gapBefore: options.gapBefore } : {}, - }) - record.total += bytes - record.retainedBytes += bytes - this.trim(record, this.retainBytes) - this.notifyOutput(record) - } - - /** Replace a live record's detail line; log and drop on an ended one. */ - private updateDetail(record: TrackedActivity, detail: string): void { - if (isTerminal(record.status)) { - this.selfCtx.logger.warn(`activities: detail update on ended activity ${record.id} dropped`) - return - } - record.detail = detail - this.notifyChanged(record.owner) - } - - /** - * Record the first terminal outcome, trim retention to the settled cap, and - * announce the change and the stream end. First-wins: a later call — the - * producer's own report after a teardown force-end, or a duplicate — is - * dropped silently. - */ - private settle(record: TrackedActivity, outcome: ActivityOutcome): void { - if (isTerminal(record.status)) return - record.status = outcome.status - if (outcome.detail !== undefined) record.detail = outcome.detail - record.finishedAt = Date.now() - this.trim(record, this.settledRetainBytes) - this.notifyChanged(record.owner) - this.notifyOutput(record) - } - - /** - * Drop retained head chunks until the ring fits `cap`; a single oversized - * chunk keeps only its UTF-8-safe tail with a `gapBefore` marker. Offsets - * stay absolute: `earliest` advances over everything dropped. - */ - private trim(record: TrackedActivity, cap: number): void { - while (record.retainedBytes > cap && record.chunks.length > 1) { - const dropped = record.chunks.shift() - /* v8 ignore next -- the length guard proves shift() returns a chunk; the check only satisfies noUncheckedIndexedAccess. */ - if (dropped === undefined) break - record.retainedBytes -= dropped.bytes - } - const single = record.chunks.length === 1 ? record.chunks[0] : undefined - if (single !== undefined && single.bytes > cap) { - const tail = utf8Tail(single.text, cap) - single.at += single.bytes - tail.bytes - single.text = tail.text - single.bytes = tail.bytes - single.gapBefore = true - record.retainedBytes = tail.bytes - } - record.earliest = record.chunks[0]?.at ?? record.total - } - - /** Look up an activity or fail loud. */ - private expect(id: ActivityId): TrackedActivity { - const record = this.store.get(id) - if (record === undefined) throw new Error(`unknown activity ${id}`) - return record - } - - /** - * The visibility fence: an activity with an owner is reachable only by - * callers whose session id matches (`!== undefined` semantics — an unowned - * activity is open, and a no-agent caller can never match an owned one). - */ - private assertAccess(record: TrackedActivity, caller?: Agent): void { - if (record.owner !== undefined && record.owner.id !== caller?.id) { - throw new Error(`activity ${record.id} belongs to another session`) - } - } - - /** Project a fresh read-only snapshot from the mutable record. */ - private snapshot(record: TrackedActivity): ActivitySnapshot { - const ownerSession = record.owner?.id - return { - id: record.id, - kind: record.kind, - label: record.label, - ...ownerSession !== undefined ? { ownerSession } : {}, - ...record.correlation !== undefined ? { correlation: { ...record.correlation } } : {}, - status: record.status, - ...record.detail !== undefined ? { detail: record.detail } : {}, - startedAt: record.startedAt, - ...record.finishedAt !== undefined ? { finishedAt: record.finishedAt } : {}, - outputTotal: record.total, - outputEarliest: record.earliest, - } - } - - /** - * The change observers that own `owner`'s updates: the global layer — a - * host composition's own carrier, which serves every owner — then each - * scoped layer along the owner's chain. - */ - private *changedFor(owner?: Agent): IterableIterator { - yield* this.layers.global.changed.values() - const scope = owner === undefined ? undefined : scopeOf(owner.ctx) - for (const layer of this.layers.chainLayers(scope)) yield* layer.changed.values() - } - - /** The output observers that own `owner`'s stream signals, resolved like {@link changedFor}. */ - private *outputFor(owner?: Agent): IterableIterator { - yield* this.layers.global.output.values() - const scope = owner === undefined ? undefined : scopeOf(owner.ctx) - for (const layer of this.layers.chainLayers(scope)) yield* layer.output.values() - } - - /** - * Announce that one owner's visible set changed. Each listener is contained - * so an observer cannot break a lifecycle commit that already happened. - */ - private notifyChanged(owner: Agent | undefined): void { - for (const listener of this.changedFor(owner)) { - try { - listener(owner) - } catch (error: unknown) { - this.selfCtx.logger.warn(`activities: onActivitiesChanged listener threw: ${String(error)}`) - } - } - } - - /** Announce that one activity's stream advanced (append or settlement), with per-listener containment. */ - private notifyOutput(record: TrackedActivity): void { - for (const listener of this.outputFor(record.owner)) { - try { - listener(record.id) - } catch (error: unknown) { - this.selfCtx.logger.warn(`activities: onOutput listener threw: ${String(error)}`) - } - } - } - - /** - * Attach one cleanup through the exact owner's scope. This survives - * producer reloads; the retained disposer lets service teardown detach the - * cross-fiber effect. Fails when the agent registry is absent or the owner - * is not its currently registered instance. - */ - private ensureOwnerCleanup(owner: Agent): void { - const ownerId = owner.id - const agents = this.selfCtx.get('agents') - if (agents === undefined) { - throw new Error('activity ownership requires the agent registry (load @deepseek-ai/dsh-agent)') - } - if (agents.get(ownerId) !== owner) { - throw new Error(`agent "${ownerId}" is not the registered agent instance (activity owner must be live)`) - } - if (this.ownerCleanups.has(owner)) return - // Record only after attach succeeds; a disposing scope rejects new effects. - const detach = owner.ctx.effect(() => () => { - this.ownerCleanups.delete(owner) - this.disposeOwned(owner) - }, 'activities.ownerCleanup()') - this.ownerCleanups.set(owner, detach) - } - - /** End still-open records and drop every activity owned by one exact agent lifecycle. */ - private disposeOwned(owner: Agent): void { - const owned = [...this.store.values()].filter(record => record.owner === owner) - for (const record of owned) { - if (!isTerminal(record.status)) this.settle(record, { status: 'killed', detail: 'owner disposed' }) - this.store.delete(record.id) - } - // Removal is the one visible-set change no per-activity record carries, so - // it must be announced here or an observer keeps the dropped rows forever. - if (owned.length > 0) this.notifyChanged(owner) - } - - /** End every open record, clear the store, and announce each emptied owner. */ - private disposeAll(): void { - const all = [...this.store.values()] - for (const record of all) { - if (!isTerminal(record.status)) this.settle(record, { status: 'killed', detail: 'activity registry disposed' }) - } - const emptied = new Set(all.map(record => record.owner)) - this.store.clear() - for (const owner of emptied) this.notifyChanged(owner) - // Detach cross-fiber owner effects after the shared store is quiescent. - const ownerCleanups = [...this.ownerCleanups.values()] - this.ownerCleanups.clear() - for (const cleanup of ownerCleanups) void cleanup() - } -} - -export default LocalActivityRegistry diff --git a/packages/activity/activity-local/src/invariant.ts b/packages/activity/activity-local/src/invariant.ts deleted file mode 100644 index 7e15b7db09..0000000000 --- a/packages/activity/activity-local/src/invariant.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-activity-local`. - * @module @deepseek-ai/dsh-activity-local/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-activity-local' - -/** Cordis companion plugin name. */ -export const name = 'activity-local-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: `@deepseek-ai/dsh-activity/invariant` owns per-snapshot identity, status, - * timestamp, and offset checks on every visible-set change. This provider's remaining behavior — - * ring retention against private byte caps and pull-only chunk reads — has no authoritative event - * stream to observe: a read happens on a caller's schedule, and repeating the retention arithmetic - * against private configuration would duplicate the implementation rather than check a relation. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/activity/activity-local/tests/activity.spec.ts b/packages/activity/activity-local/tests/activity.spec.ts deleted file mode 100644 index 557046d617..0000000000 --- a/packages/activity/activity-local/tests/activity.spec.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { createScope } from '@deepseek-ai/dsh-scope' -import { ActivityId } from '@deepseek-ai/dsh-activity' -import type { ActivityHandle, ActivitySnapshot } from '@deepseek-ai/dsh-activity' -import LocalActivityRegistry, { type Config as ProcessConfig } from '@deepseek-ai/dsh-activity-local' - -declare module '@deepseek-ai/dsh-activity' { - interface ActivityKindMap { - workflow: 'workflow' - } -} - -const agentScopeDisposers = new WeakMap Promise>() - -/** - * A minimal live agent whose ctx carries its own scope on a disposable plugin - * fiber, mirroring the harness convention that a live agent keys its scope. - */ -function stubAgent(ctx: Context, rawId: string): Agent { - const id = SessionId(rawId) - const scopeFiber = ctx.plugin(() => {}) - const agentCtx = createScope(scopeFiber.ctx, {}).ctx - const session = Session.create(id) - const agent = { - id, - options: {}, - session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), - status: 'idle' as const, - ctx: agentCtx, - send: () => {}, - followup: () => {}, - steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), - inject: () => {}, - cancel() {}, - runMaintenance: (job: (signal: AbortSignal) => Promise) => job(new AbortController().signal), - whenIdle() { return Promise.resolve() }, - } - agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() }) - return agent -} - -async function disposeAgentScope(agent: Agent): Promise { - const dispose = agentScopeDisposers.get(agent) - if (dispose === undefined) throw new Error(`missing test scope for agent "${agent.id}"`) - await dispose() -} - -async function harness(config: ProcessConfig = {}) { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalActivityRegistry, config) - return ctx -} - -/** Concatenated retained text of one process, as an observer from `from` sees it. */ -function textFrom(ctx: Context, id: ActivityId, from = 0): string { - return ctx.activities.read(id, from).chunks.map(chunk => chunk.text).join('') -} - -describe('LocalActivityRegistry.open', () => { - it('mints per-kind ordinal ids and announces the new visible row', async () => { - const ctx = await harness() - const changes: (Agent | undefined)[] = [] - ctx.activities.onActivitiesChanged((owner) => { changes.push(owner) }) - const first = ctx.activities.open({ kind: 'bash', label: 'sleep 60' }) - const second = ctx.activities.open({ kind: 'workflow', label: 'run docs' }) - const third = ctx.activities.open({ kind: 'bash', label: 'build' }) - expect([first.id, second.id, third.id]).toEqual(['bash-1', 'workflow-1', 'bash-2']) - expect(changes).toEqual([undefined, undefined, undefined]) - expect(ctx.activities.list().map(snapshot => snapshot.id)).toEqual(['bash-1', 'workflow-1', 'bash-2']) - }) - - it('rejects an empty kind or label without minting an id', async () => { - const ctx = await harness() - expect(() => ctx.activities.open({ kind: '' as never, label: 'x' })).toThrow(/invalid activity kind/) - expect(() => ctx.activities.open({ kind: 'bash', label: '' })).toThrow(/invalid activity label/) - expect(ctx.activities.list()).toEqual([]) - }) - - it('rejects an owned open when no agent registry is loaded', async () => { - const bare = new Context() - await bare.plugin(LocalActivityRegistry) - const ghost = { id: SessionId('ghost'), ctx: bare } as unknown as Agent - expect(() => bare.activities.open({ kind: 'bash', label: 'x', owner: ghost })) - .toThrow(/activity ownership requires the agent registry/) - }) - - it('rejects an owner that is not the registered agent instance', async () => { - const ctx = await harness() - const ghost = stubAgent(ctx, 'ghost') - expect(() => ctx.activities.open({ kind: 'bash', label: 'x', owner: ghost })) - .toThrow(/not the registered agent instance/) - }) - - it('carries owner session and correlation into snapshots', async () => { - const ctx = await harness() - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) - const handle = ctx.activities.open({ - kind: 'bash', - label: 'npm test', - owner, - correlation: { jobId: 'bash-9' as never, callId: 'call-3' as never }, - }) - const snapshot = ctx.activities.get(handle.id, owner) - expect(snapshot.ownerSession).toBe(owner.id) - expect(snapshot.correlation).toEqual({ jobId: 'bash-9', callId: 'call-3' }) - expect(snapshot.status).toBe('running') - }) -}) - -describe('LocalActivityRegistry output', () => { - it('appends advance absolute offsets and reads are non-consuming', async () => { - const ctx = await harness() - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - handle.append('hello ') - handle.append('world', { channel: 'stderr' }) - const first = ctx.activities.read(handle.id, 0) - expect(first.lossy).toBe(false) - expect(first.next).toBe(11) - expect(first.chunks).toEqual([ - { at: 0, text: 'hello ' }, - { at: 6, text: 'world', channel: 'stderr' }, - ]) - // A second identical read proves nothing was consumed. - expect(ctx.activities.read(handle.id, 0)).toEqual(first) - // Resuming from `next` yields nothing until more output arrives. - expect(ctx.activities.read(handle.id, first.next).chunks).toEqual([]) - handle.append('!') - expect(ctx.activities.read(handle.id, first.next).chunks).toEqual([{ at: 11, text: '!' }]) - const snapshot = ctx.activities.get(handle.id) - expect(snapshot.outputTotal).toBe(12) - expect(snapshot.outputEarliest).toBe(0) - }) - - it('drops an empty append without waking observers', async () => { - const ctx = await harness() - const signals: ActivityId[] = [] - ctx.activities.onOutput((id) => { signals.push(id) }) - const handle = ctx.activities.open({ kind: 'bash', label: 'quiet' }) - handle.append('') - expect(signals).toEqual([]) - expect(ctx.activities.get(handle.id).outputTotal).toBe(0) - }) - - it('rejects a negative or fractional read offset', async () => { - const ctx = await harness() - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - expect(() => ctx.activities.read(handle.id, -1)).toThrow(/invalid read offset/) - expect(() => ctx.activities.read(handle.id, 0.5)).toThrow(/invalid read offset/) - }) - - it('evicts whole head chunks past the live cap and flags stale readers lossy', async () => { - const ctx = await harness({ retainBytes: 8 }) - const handle = ctx.activities.open({ kind: 'bash', label: 'spam' }) - handle.append('aaaa') - handle.append('bbbb') - handle.append('cccc') - const read = ctx.activities.read(handle.id, 0) - expect(read.lossy).toBe(true) - expect(read.chunks).toEqual([{ at: 4, text: 'bbbb' }, { at: 8, text: 'cccc' }]) - expect(read.next).toBe(12) - const snapshot = ctx.activities.get(handle.id) - expect(snapshot.outputEarliest).toBe(4) - expect(snapshot.outputTotal).toBe(12) - // A reader at the retained boundary is not lossy. - expect(ctx.activities.read(handle.id, 4).lossy).toBe(false) - }) - - it('keeps only the UTF-8-safe tail of a single oversized chunk', async () => { - const ctx = await harness({ retainBytes: 5 }) - const handle = ctx.activities.open({ kind: 'bash', label: 'wide' }) - // '你好' is six UTF-8 bytes; a five-byte budget must not split the second code point. - handle.append('你好') - const read = ctx.activities.read(handle.id, 0) - expect(read.lossy).toBe(true) - expect(read.chunks).toEqual([{ at: 3, text: '好', gapBefore: true }]) - const snapshot = ctx.activities.get(handle.id) - expect(snapshot.outputEarliest).toBe(3) - expect(snapshot.outputTotal).toBe(6) - }) - - it('returns the whole overlapped chunk for a foreign mid-chunk offset', async () => { - const ctx = await harness() - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - handle.append('abcdef') - const read = ctx.activities.read(handle.id, 3) - expect(read.chunks).toEqual([{ at: 0, text: 'abcdef' }]) - expect(read.lossy).toBe(false) - }) - - it('preserves a producer-reported gap marker through reads', async () => { - const ctx = await harness() - const handle = ctx.activities.open({ kind: 'bash', label: 'lossy source' }) - handle.append('tail after drop', { gapBefore: true, channel: 'stdout' }) - expect(ctx.activities.read(handle.id, 0).chunks).toEqual([ - { at: 0, text: 'tail after drop', channel: 'stdout', gapBefore: true }, - ]) - }) -}) - -describe('LocalActivityRegistry settlement', () => { - it('records the first terminal outcome and trims retention to the settled cap', async () => { - const ctx = await harness({ retainBytes: 1024, settledRetainBytes: 4 }) - const handle = ctx.activities.open({ kind: 'bash', label: 'build' }) - handle.append('abcdefgh') - handle.end({ status: 'completed', detail: 'exit code: 0' }) - const snapshot = ctx.activities.get(handle.id) - expect(snapshot.status).toBe('completed') - expect(snapshot.detail).toBe('exit code: 0') - expect(snapshot.finishedAt).toBeDefined() - const read = ctx.activities.read(handle.id, 0) - expect(read.lossy).toBe(true) - expect(read.chunks).toEqual([{ at: 4, text: 'efgh', gapBefore: true }]) - }) - - it('is first-wins: a later end and post-end writes are dropped without throwing', async () => { - const ctx = await harness() - const warn = vi.fn() - ctx.logger.warn = warn as never - const handle = ctx.activities.open({ kind: 'bash', label: 'build' }) - handle.end({ status: 'killed', detail: 'owner disposed' }) - handle.end({ status: 'completed' }) - handle.append('late') - handle.updateDetail('late detail') - const snapshot = ctx.activities.get(handle.id) - expect(snapshot.status).toBe('killed') - expect(snapshot.detail).toBe('owner disposed') - expect(snapshot.outputTotal).toBe(0) - expect(warn.mock.calls.map(call => String(call[0]))).toEqual([ - expect.stringContaining('append to ended activity'), - expect.stringContaining('detail update on ended activity'), - ]) - }) - - it('signals observers for appends and once more at settlement', async () => { - const ctx = await harness() - const signals: ActivityId[] = [] - ctx.activities.onOutput((id) => { signals.push(id) }) - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - handle.append('a') - handle.append('b') - handle.end({ status: 'completed' }) - expect(signals).toEqual([handle.id, handle.id, handle.id]) - }) - - it('updateDetail changes the visible row without touching output offsets', async () => { - const ctx = await harness() - const changes: (Agent | undefined)[] = [] - ctx.activities.onActivitiesChanged((owner) => { changes.push(owner) }) - const handle = ctx.activities.open({ kind: 'workflow', label: 'docs run' }) - handle.updateDetail('3/10 agents done') - expect(ctx.activities.get(handle.id).detail).toBe('3/10 agents done') - expect(ctx.activities.get(handle.id).outputTotal).toBe(0) - expect(changes).toHaveLength(2) - }) -}) - -describe('LocalActivityRegistry visibility and delivery', () => { - it('fences owned processes by session id and keeps unowned ones open', async () => { - const ctx = await harness() - const alice = stubAgent(ctx, 'alice') - const bob = stubAgent(ctx, 'bob') - ctx.agents.register(alice) - ctx.agents.register(bob) - const owned = ctx.activities.open({ kind: 'bash', label: 'alice job', owner: alice }) - const open = ctx.activities.open({ kind: 'bash', label: 'shared' }) - owned.append('secret') - expect(ctx.activities.list(alice).map(snapshot => snapshot.id)).toEqual([owned.id, open.id]) - expect(ctx.activities.list(bob).map(snapshot => snapshot.id)).toEqual([open.id]) - expect(ctx.activities.list().map(snapshot => snapshot.id)).toEqual([open.id]) - expect(() => ctx.activities.get(owned.id, bob)).toThrow(/belongs to another session/) - expect(() => ctx.activities.read(owned.id, 0)).toThrow(/belongs to another session/) - expect(textFrom(ctx, open.id)).toBe('') - expect(ctx.activities.read(owned.id, 0, alice).chunks[0]?.text).toBe('secret') - }) - - it('delivers scoped listeners only their own agents and global listeners everything', async () => { - const ctx = await harness() - const alice = stubAgent(ctx, 'alice') - const bob = stubAgent(ctx, 'bob') - ctx.agents.register(alice) - ctx.agents.register(bob) - const globalOwners: (string | undefined)[] = [] - ctx.activities.onActivitiesChanged((owner) => { globalOwners.push(owner?.id) }) - const scopedOwners: (string | undefined)[] = [] - await alice.ctx.plugin({ - inject: ['activities'], - apply(scopedCtx: Context) { - scopedCtx.activities.onActivitiesChanged((owner) => { scopedOwners.push(owner?.id) }) - }, - }) - const scopedOutputs: string[] = [] - const scopedFiber = await alice.ctx.plugin({ - inject: ['activities'], - apply(scopedCtx: Context) { - scopedCtx.activities.onOutput((id) => { scopedOutputs.push(String(id)) }) - }, - }) - const aliceHandle = ctx.activities.open({ kind: 'bash', label: 'alice work', owner: alice }) - aliceHandle.append('scoped delivery probe') - expect(scopedOutputs).toEqual([String(aliceHandle.id)]) - ctx.activities.open({ kind: 'bash', label: 'bob work', owner: bob }) - ctx.activities.open({ kind: 'bash', label: 'unowned' }) - expect(globalOwners).toEqual(['alice', 'bob', undefined]) - expect(scopedOwners).toEqual(['alice']) - // Disposing one scoped registrant probes layer reclamation; the sibling - // scoped changed-listener keeps the layer alive and keeps delivering. - await scopedFiber.dispose() - ctx.activities.open({ kind: 'bash', label: 'alice later', owner: alice }) - expect(scopedOwners).toEqual(['alice', 'alice']) - - // A scope whose only contribution goes empties on disposal and is reclaimed. - const bobFiber = await bob.ctx.plugin({ - inject: ['activities'], - apply(scopedCtx: Context) { - scopedCtx.activities.onOutput(() => {}) - }, - }) - await bobFiber.dispose() - }) - - it('contains throwing listeners without breaking the commit', async () => { - const ctx = await harness() - const warn = vi.fn() - ctx.logger.warn = warn as never - ctx.activities.onActivitiesChanged(() => { throw new Error('changed boom') }) - ctx.activities.onOutput(() => { throw new Error('output boom') }) - const seen: (Agent | undefined)[] = [] - ctx.activities.onActivitiesChanged((owner) => { seen.push(owner) }) - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - handle.append('x') - expect(seen).toHaveLength(1) - expect(ctx.activities.get(handle.id).outputTotal).toBe(1) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('onActivitiesChanged listener threw')) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('onOutput listener threw')) - }) - - it('unregisters listeners with their registering fiber', async () => { - const ctx = await harness() - const signals: ActivityId[] = [] - const fiber = ctx.plugin({ - inject: ['activities'], - apply(pluginCtx: Context) { - pluginCtx.activities.onOutput((id) => { signals.push(id) }) - }, - }) - await fiber - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - handle.append('a') - expect(signals).toHaveLength(1) - await fiber.dispose() - handle.append('b') - expect(signals).toHaveLength(1) - }) -}) - -describe('LocalActivityRegistry lifecycle', () => { - it('owner disposal ends open records, removes rows, and announces the removal', async () => { - const ctx = await harness() - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) - const handle = ctx.activities.open({ kind: 'bash', label: 'sleep', owner }) - handle.append('partial') - const settledEarly = ctx.activities.open({ kind: 'bash', label: 'done already', owner }) - settledEarly.end({ status: 'completed' }) - const changes: (string | undefined)[] = [] - ctx.activities.onActivitiesChanged((changed) => { changes.push(changed?.id) }) - await disposeAgentScope(owner) - expect(ctx.activities.list(owner)).toEqual([]) - expect(() => ctx.activities.get(handle.id, owner)).toThrow(/unknown activity/) - // The forced end and the removal both announced under the owner. - expect(changes.length).toBeGreaterThanOrEqual(2) - expect(new Set(changes)).toEqual(new Set(['owner'])) - // The producer's own late report is dropped silently, not thrown. - expect(() => { handle.end({ status: 'completed' }) }).not.toThrow() - }) - - it('service disposal ends everything, clears the store, and announces emptied owners', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - const registryFiber = ctx.plugin(LocalActivityRegistry, {}) - await registryFiber - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) - const processes = ctx.activities - const owned = processes.open({ kind: 'bash', label: 'owned', owner }) - processes.open({ kind: 'bash', label: 'unowned' }) - const preSettled = processes.open({ kind: 'bash', label: 'already settled' }) - preSettled.end({ status: 'completed' }) - const changes: (string | undefined)[] = [] - ctx.activities.onActivitiesChanged((changed) => { changes.push(changed?.id) }) - await registryFiber.dispose() - expect(changes).toEqual(expect.arrayContaining(['owner', undefined])) - expect(() => { owned.append('late') }).not.toThrow() - }) -}) - -describe('LocalActivityRegistry snapshots', () => { - it('hands out fresh snapshots, never live registry state', async () => { - const ctx = await harness() - const handle: ActivityHandle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - const before: ActivitySnapshot = ctx.activities.get(handle.id) - handle.append('grow') - expect(before.outputTotal).toBe(0) - expect(ctx.activities.get(handle.id).outputTotal).toBe(4) - }) -}) diff --git a/packages/activity/activity-local/tests/loader-composition.spec.ts b/packages/activity/activity-local/tests/loader-composition.spec.ts deleted file mode 100644 index 4b341f7e16..0000000000 --- a/packages/activity/activity-local/tests/loader-composition.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import Include from '@deepseek-ai/cordis-plugin-include' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' - -let root: string | undefined -let context: Context | undefined - -afterEach(async () => { - await context?.fiber.dispose() - context = undefined - if (root !== undefined) await rm(root, { recursive: true, force: true }) - root = undefined -}) - -describe('process-local through a real Loader composition', () => { - it('applies the provider-owned retention config from a Cordis row', async () => { - root = await mkdtemp(join(tmpdir(), 'dsh-activity-local-loader-')) - const configPath = join(root, 'cordis.yml') - await writeFile(configPath, [ - "- name: '@deepseek-ai/dsh-activity-local'", - ' config:', - ' retainBytes: 8', - '', - ].join('\n')) - - context = new Context() - context.baseUrl = pathToFileURL(root).href + '/' - await context.plugin(Loader) - context.loader.builtins.include = Include - context.loader.internal = { - version: 'v2', - async import(specifier: string) { - if (specifier === '@deepseek-ai/dsh-activity-local') return LocalActivityRegistry - throw new Error(`unexpected Loader import: ${specifier}`) - }, - } as unknown as NonNullable - await context.loader.create({ - name: 'cordis:include', - config: { path: pathToFileURL(configPath).href }, - }) - await context.loader.await() - - expect(context.activities).toBeInstanceOf(LocalActivityRegistry) - const handle = context.activities.open({ kind: 'bash', label: 'loader retention probe' }) - handle.append('aaaa') - handle.append('bbbb') - handle.append('cccc') - const read = context.activities.read(handle.id, 0) - // retainBytes: 8 from the Cordis row dropped the oldest chunk. - expect(read.lossy).toBe(true) - expect(read.chunks.map(chunk => chunk.text)).toEqual(['bbbb', 'cccc']) - expect(read.next).toBe(12) - }) -}) diff --git a/packages/activity/activity-local/tsconfig.json b/packages/activity/activity-local/tsconfig.json deleted file mode 100644 index 0bddbff854..0000000000 --- a/packages/activity/activity-local/tsconfig.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/scope" - }, - { - "path": "../activity" - }, - { - "path": "../../runtime-diagnostics/invariants" - } - ] -} diff --git a/packages/activity/activity/README.i18n.yaml b/packages/activity/activity/README.i18n.yaml deleted file mode 100644 index 14d65fd03c..0000000000 --- a/packages/activity/activity/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# 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/activity/activity/README.md -README.md: 466bdf5aac8718b197495f477f012e6314e41dd6 -README.zh.md: 32dea93f72f12ae1f439220193b8fa5b994bbe45 diff --git a/packages/activity/activity/README.md b/packages/activity/activity/README.md deleted file mode 100644 index 466bdf5aac..0000000000 --- a/packages/activity/activity/README.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -description: "The streaming-output observation contract (ctx.activities): open/append/end handles, non-consuming offset reads, owner fencing, and the shared pull pump for producers and consumers of live activity output." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-activity - -English | [中文](README.zh.md) - -## Summary - -`dsh-activity` lets a long-running producer publish live output and status once, and any number of independent consumers read it without stealing bytes from the model: reads are non-consuming, addressed by absolute UTF-8 byte offsets that survive eviction, and fenced to the owning session. The registry is a pure observation plane — it starts nothing, cancels nothing, and is invisible to the model, so producers keep their execution resources and their existing model-facing surfaces. This package ships the contract only; load [`dsh-activity-local`](../activity-local/README.md) to get a working registry, and without one `ctx.activities` does not exist and observation silently degrades to none. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Use this package when you write a producer that mirrors live work, or a consumer that renders it; the composition mounts an implementation row (`@deepseek-ai/dsh-activity-local`), never this contract package directly. - -### Producing an activity - -`open(spec): ActivityHandle` validates the kind, label, and exact live owner, then atomically registers the record and returns the producer face: synchronous `append(text, { channel?, gapBefore? })`, `updateDetail(detail)`, and first-wins `end(outcome)`. After `end` — including a teardown force-end — `append` and `updateDetail` log and drop instead of throwing, so a producer's trailing flush cannot break its own teardown. Producer plugins extend `ActivityKindMap` with their opaque id namespace and may link an activity to its tool call and background job through `ActivityCorrelation`. Access the registry with `ctx.get('activities')` and contain every observation failure: the plane is optional, and the observed work must never depend on it. - -### Consuming activities - -`get(id, caller?)` and `list(caller?)` return fresh snapshots; listing includes only caller-owned and unowned activities, and predictable ids make that fence the boundary. `read(id, from, caller?)` returns the retained chunks overlapping `[from, total)` plus the resume offset, without consuming anything; a reader below the oldest retained byte gets `lossy: true`, never an error. `onActivitiesChanged(listener)` observes visible-set changes so consumers re-read rather than accumulate deltas, and `onOutput(listener)` signals stream advancement carrying only the id; both deliver owner-relative and contain every listener. - -### Pumping a substrate - -`pumpActivityOutput(handle, sources, { pollMs, done })` is the shared pump for producers whose substrate is a non-consuming offset reader (the subprocess `readFrom(fromByte)` family): it copies labeled deltas at a bounded cadence, marks a lossy source read `gapBefore`, and drains once more after `done` settles without ever calling `end()` itself. - ------ - - -## Understand the implementation - -
        -Implementation internals — click to expand - -The package holds the abstract `ActivityRegistry` service (a direct mount throws, naming an implementation to load), the vocabulary types, the branded `ActivityId`, the pump, and the invariant companion. Offsets are absolute so an observer's cursor never depends on what other observers did, and eviction moves only the earliest retained byte — the lossy flag is how a late reader learns the head is gone. - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Abstract `ActivityRegistry` service and the direct-mount guard | -| [`src/types.ts`](src/types.ts) | Vocabulary: kinds, snapshots, handles, reads, listeners | -| [`src/brand.ts`](src/brand.ts) | Branded `ActivityId` (browser-safe leaf) | -| [`src/pump.ts`](src/pump.ts) | `pumpActivityOutput` pull-substrate pump | -| [`src/invariant.ts`](src/invariant.ts) | Snapshot invariants installed on the change feed | - -
        - ------ - - -## Further Exploration - -- [`dsh-activity-local`](../activity-local/README.md) — the in-memory implementation and its retention config. -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.md) — the subsystem reference: offsets, retention, listener delivery. -- [`dsh-api-activity-controller`](../../api/activity-controller/README.md) — the Remote streams serving this plane to browsers. -- [Observation-seam Agent Note](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) — why the plane is non-consuming and model-invisible. - ------ - - -## Model Experience - -None, as the registry carries live observation state for humans and registers no prompt, tool, or session event; activity output becomes model-visible only through the producers' existing tool results and `ctx.jobs` reads. - -#### KV Cache effect - -None; the package never assembles or alters provider requests. - -## Known Limitations and Deferred Work - - - -These limits define when the contract is a poor fit. They are current package constraints, not a task backlog. - -- **Records are process-local and live-only** — a Host restart empties every roster while the transcript keeps the producing tool cards; durable replay is a separate design. -- **The observation plane has no cancellation verb** — stopping work stays with the producers and `ctx.jobs`; a panel-initiated kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). -- **Settled records last until owner disposal** — retention trims their buffers to the settled cap, but the rows themselves are not aged out. - - -### Dev Note - -
        -Working context for maintainers — click to expand - -None. - -
        diff --git a/packages/activity/activity/README.zh.md b/packages/activity/activity/README.zh.md deleted file mode 100644 index 32dea93f72..0000000000 --- a/packages/activity/activity/README.zh.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -description: "流式输出观察契约(ctx.activities):open/append/end 句柄、非消费偏移读取、owner 栅栏,以及供实时活动输出生产者与消费者共用的拉取泵。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-activity - -[English](README.md) | 中文 - -## 概述 - -`dsh-activity` 让长时运行的生产者只发布一次实时输出与状态,任意数量的独立消费者读取它而不会偷走模型的字节:读取是非消费的,以跨淘汰仍有效的绝对 UTF-8 字节偏移寻址,并以归属会话为栅栏。注册表是纯观察面——它什么都不启动、什么都不取消、对模型不可见,因此生产者保留自己的执行资源与既有模型可见表面。本包只交付契约;加载 [`dsh-activity-local`](../activity-local/README.zh.md) 获得可用注册表,没有实现时 `ctx.activities` 不存在,观察静默退化为无。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -编写镜像实时工作的生产者、或渲染它的消费者时使用本包;组合挂载的是实现行(`@deepseek-ai/dsh-activity-local`),从不直接挂载这个契约包。 - -### 生产一个 activity - -`open(spec): ActivityHandle` 校验 kind、label 与确切的活跃 owner,然后原子注册记录并返回生产者面:同步的 `append(text, { channel?, gapBefore? })`、`updateDetail(detail)`,以及先到先赢的 `end(outcome)`。在 `end` 之后——包括 teardown 强制收尾——`append` 与 `updateDetail` 记日志后丢弃而非抛出,因此生产者的尾部 flush 不会破坏自己的 teardown。生产者插件以自己的不透明 id 命名空间扩展 `ActivityKindMap`,并可通过 `ActivityCorrelation` 把 activity 关联到其工具调用与后台 job。用 `ctx.get('activities')` 访问注册表并包住每个观察失败:该面是可选的,被观察的工作绝不能依赖它。 - -### 消费 activities - -`get(id, caller?)` 与 `list(caller?)` 返回新鲜快照;列表只含调用方拥有与无主的 activity,可预测的 id 使该栅栏成为边界。`read(id, from, caller?)` 返回与 `[from, total)` 重叠的留存 chunk 加恢复偏移,不消费任何东西;读到最旧留存字节之前的位置得到 `lossy: true`,绝不报错。`onActivitiesChanged(listener)` 观察可见集变化,消费者据此重读而非累积增量;`onOutput(listener)` 以仅含 id 的信号提示流推进;两者按 owner 相对投递并包住每个监听器。 - -### 泵送一个底座 - -`pumpActivityOutput(handle, sources, { pollMs, done })` 是底座为非消费偏移读取器(subprocess 的 `readFrom(fromByte)` 家族)的生产者的共用泵:它以有界节奏复制带标签的增量,把有损的源读取标为 `gapBefore`,并在 `done` 结算后再排空一次,自己从不调用 `end()`。 - ------ - - -## 理解实现 - -
        -实现内幕——点击展开 - -本包持有抽象 `ActivityRegistry` 服务(直接挂载会抛出并指名应加载的实现)、词汇类型、branded `ActivityId`、泵与 invariant 伴生。偏移是绝对的,观察者的游标从不依赖其他观察者做了什么;淘汰只前移最旧留存字节——lossy 标志就是迟到读者得知头部已丢的方式。 - -| 文件 | 角色 | -|---|---| -| [`src/index.ts`](src/index.ts) | 抽象 `ActivityRegistry` 服务与直接挂载防护 | -| [`src/types.ts`](src/types.ts) | 词汇:kind、快照、句柄、读取、监听器 | -| [`src/brand.ts`](src/brand.ts) | branded `ActivityId`(浏览器安全叶子) | -| [`src/pump.ts`](src/pump.ts) | `pumpActivityOutput` 拉取底座泵 | -| [`src/invariant.ts`](src/invariant.ts) | 装在变化流上的快照不变式 | - -
        - ------ - - -## 进一步探索 - -- [`dsh-activity-local`](../activity-local/README.zh.md)——内存实现及其留存配置。 -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.zh.md)——子系统参考:偏移、留存、监听器投递。 -- [`dsh-api-activity-controller`](../../api/activity-controller/README.zh.md)——把该面提供给浏览器的 Remote 流。 -- [观察面 Agent Note](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)——该面为何是非消费且模型不可见的。 - ------ - - -## 模型体验 - -无:注册表为人承载实时观察状态,不注册任何 prompt、工具或会话事件;activity 输出只经由生产者既有的工具结果与 `ctx.jobs` 读取才对模型可见。 - -#### KV Cache 影响 - -无;本包从不组装或改动 provider 请求。 - -## 已知限制与延期工作 - - - -这些限制说明契约何时不合适。它们是当前包约束,不是任务积压。 - -- **记录是进程本地且仅存活期**——Host 重启清空所有 roster,而 transcript 保留生产方的工具卡片;持久回放是另一项设计。 -- **观察面没有取消动词**——停止工作仍属于生产者与 `ctx.jobs`;面板发起的 kill 被 [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题阻塞。 -- **已结算记录存续到 owner 释放**——留存把它们的缓冲裁到 settled 上限,但行本身不会被老化清除。 - - -### 开发备注 - -
        -维护者的工作上下文——点击展开 - -无。 - -
        diff --git a/packages/activity/activity/package.json b/packages/activity/activity/package.json deleted file mode 100644 index 89372dcf7d..0000000000 --- a/packages/activity/activity/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-activity", - "description": "Streaming-output observation registry (ctx.activities) for the DeepSeek Harness — non-consuming offset reads, bounded retention, and change signals over any long-running producer's live output", - "version": "0.1.2-alpha.2", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/activity/activity" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./brand": { - "types": "./lib/types/brand.d.ts", - "default": "./lib/types/brand.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.js", - "lib/types/**/*.d.ts" - ], - "license": "MIT", - "peerDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - } -} diff --git a/packages/activity/activity/src/brand.ts b/packages/activity/activity/src/brand.ts deleted file mode 100644 index 8df786ffb9..0000000000 --- a/packages/activity/activity/src/brand.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * dsh-activity' owned branded id, carried across the registry, the observation - * wire, and correlation metadata on other domains' rows. - * - * It lives in its own leaf because the package root reaches `dsh-agent` - * through the owner and listener signatures, which a Client program cannot - * resolve even as a type. A browser-safe consumer imports the id here; - * `Branded` itself comes from the zero-dependency `@deepseek-ai/dsh-brand`. - * - * @module @deepseek-ai/dsh-activity/brand - */ - -import type { Branded } from '@deepseek-ai/dsh-brand' - -/** - * Identifies one observable activity. The registry generates `-N`; - * predictable ids rely on owner authorization rather than secrecy. - */ -export type ActivityId = Branded<'ActivityId'> - -/** - * Brand a string as a {@link ActivityId}. - * @param id - the raw process-id string (the registry generates `-N`). - * @returns the same string, branded; no validation is performed. - */ -export function ActivityId(id: string): ActivityId { - return id as ActivityId -} diff --git a/packages/activity/activity/src/index.ts b/packages/activity/activity/src/index.ts deleted file mode 100644 index 4e3927abe5..0000000000 --- a/packages/activity/activity/src/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * The streaming-output observation Service Definition (`ctx.activities`). An - * activity is one observable unit of long-running work: an append-only bounded - * output stream plus live status, readable by any number of independent - * observers at absolute byte offsets. The registry is a pure observation - * plane — it starts nothing, cancels nothing, and is invisible to the model; - * producers keep their execution resources and their existing model-facing - * surfaces (`ctx.jobs` cursors, tool results). The process-local registry - * lives in `@deepseek-ai/dsh-activity-local`. - * @module @deepseek-ai/dsh-activity - */ - -import { Context, Service } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { - ActivitiesChangedListener, ActivityHandle, ActivityId, ActivityOpen, ActivityOutputListener, - ActivityRead, ActivitySnapshot, -} from './types.ts' - -export { ActivityId } from './brand.ts' -export type { - ActivityAppendOptions, - ActivityChannel, - ActivityCorrelation, - ActivitiesChangedListener, - ActivityHandle, - ActivityKind, - ActivityKindMap, - ActivityOpen, - ActivityOutcome, - ActivityOutputChunk, - ActivityOutputListener, - ActivityRead, - ActivitySnapshot, - ActivityStatus, -} from './types.ts' -export { pumpActivityOutput } from './pump.ts' -export type { ActivityPumpOptions, ActivityPumpRead, ActivityPumpSource } from './pump.ts' - -declare module '@deepseek-ai/cordis' { - interface Context { - activities: ActivityRegistry - } -} - -/** - * Abstract streaming-output registry. Subclass, implement the abstract - * methods, and load the subclass as a plugin — it registers as - * `ctx.activities` (one implementation per context; loading a second throws, - * which is cordis' standard duplicate-service behavior). - * - * Implementations must honor these semantics: - * - Observation never consumes. Any number of readers hold their own absolute - * byte offsets; a read changes no cursor and no producer state, so the - * model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. - * - Records outlive producer fibers. Owner disposal ends still-open records - * and removes them; service disposal ends and clears everything. Neither - * awaits a producer — the registry owns no execution resource. - * - Retention is bounded. Appends past the live cap drop the oldest retained - * bytes; a reader below the retained window gets a lossy read, never an - * error. Settlement trims retention to the settled cap. - * - Listener delivery is owner-relative: a listener registered from an - * unscoped context — a host composition's own carrier — sees every owner, - * while one registered under an agent composition's scope sees exactly the - * agents composed under it. Every listener is contained. - */ -export abstract class ActivityRegistry extends Service { - constructor(ctx: Context) { - // `abstract` erases at runtime, so a composition row naming this package - // would register a ctx.activities with no method implementations and fail - // far from the misconfiguration. Fail loud at load instead. - if (new.target === ActivityRegistry) { - throw new Error('@deepseek-ai/dsh-activity is the abstract activity observation seam; load an implementation such as @deepseek-ai/dsh-activity-local instead') - } - super(ctx, 'activities') - } - - /** - * Validate the spec, attach owner cleanup, and atomically register one - * activity. A rejection leaves no id; after return the producer owns the - * handle and the visible set has changed. - * @param spec - activity identity, owner, and correlation. - * @returns the producer face of the registered activity. - */ - abstract open(spec: ActivityOpen): ActivityHandle - - /** - * List caller-owned and unowned activities in registration order without - * exposing another session's labels or output. - * @param caller - reading agent; a non-agent caller sees only unowned activities. - * @returns fresh snapshots. - */ - abstract list(caller?: Agent): ActivitySnapshot[] - - /** - * Return a fresh snapshot. Throws for an unknown or foreign activity. - * @param id - activity to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ - abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot - - /** - * Read retained output from an absolute byte offset without consuming it. - * Resume by passing a previous read's `next`; a foreign offset inside a - * retained chunk returns that whole chunk (its `at` may precede `from`). - * Throws for an unknown or foreign activity or a negative or non-integer - * offset. - * @param id - activity to read. - * @param from - absolute byte offset to read from (0 for the retained head). - * @param caller - reading agent checked against the owner. - * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. - */ - abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead - - /** - * Register an effect-scoped observer of visible-set changes: opening, - * detail updates, settlement, owner-disposal removal, and the emptying that - * service disposal commits — so an observer re-reads rather than - * accumulating deltas. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned activity changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ - abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void - - /** - * Register an effect-scoped observer of stream advancement — one signal per - * committed append and one at settlement, carrying only the activity id. - * A consumer schedules a {@link read} from its own cursor; the registry - * never pushes payloads. Listeners are contained and never awaited. - * @param listener - receives the id whose stream advanced. - * @returns disposer that unregisters the listener. - */ - abstract onOutput(listener: ActivityOutputListener): () => void -} - -export default ActivityRegistry diff --git a/packages/activity/activity/src/invariant.ts b/packages/activity/activity/src/invariant.ts deleted file mode 100644 index 6753fa7b0e..0000000000 --- a/packages/activity/activity/src/invariant.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** Package-owned process-snapshot invariants. @module @deepseek-ai/dsh-activity/invariant */ - -import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import type { ActivitySnapshot } from './types.ts' - -const PACKAGE_NAME = '@deepseek-ai/dsh-activity' -const TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed']) - -/** Cordis companion plugin name. */ -export const name = 'activity-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** Validate the cross-field relationships in one registry snapshot. */ -/* jscpd:ignore-start -- mirrors the jobs invariant's id/ordinal checks by design; the two registries share the `-N` scheme. */ -function validateSnapshot(snapshot: ActivitySnapshot, fail: InvariantFailure): void { - const id = String(snapshot.id) - const prefix = `${snapshot.kind}-` - const ordinal = Number(id.slice(prefix.length)) - if (snapshot.kind.length === 0 || !id.startsWith(prefix) - || !Number.isSafeInteger(ordinal) || ordinal < 1) { - fail(`activity snapshot id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`) - } - if (snapshot.label.length === 0) fail(`activity ${JSON.stringify(id)} label must be non-empty`) - if (!Number.isSafeInteger(snapshot.startedAt) || snapshot.startedAt < 0) { - fail(`activity ${JSON.stringify(id)} startedAt must be a non-negative epoch integer`) - } - /* jscpd:ignore-end */ - - const terminal = TERMINAL_STATUSES.has(snapshot.status) - if (terminal !== (snapshot.finishedAt !== undefined)) { - fail(`activity ${JSON.stringify(id)} finishedAt must be present exactly for a terminal status`) - } - if (snapshot.finishedAt !== undefined - && (!Number.isSafeInteger(snapshot.finishedAt) || snapshot.finishedAt < snapshot.startedAt)) { - fail(`activity ${JSON.stringify(id)} finishedAt must be an epoch integer no earlier than startedAt`) - } - - if (!Number.isSafeInteger(snapshot.outputTotal) || snapshot.outputTotal < 0 - || !Number.isSafeInteger(snapshot.outputEarliest) || snapshot.outputEarliest < 0 - || snapshot.outputEarliest > snapshot.outputTotal) { - fail(`activity ${JSON.stringify(id)} output offsets must satisfy 0 <= outputEarliest <= outputTotal`) - } -} - -/** Install checks over current visible records and every later visible-set change. */ -const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - for (const snapshot of ctx.activities.list()) validateSnapshot(snapshot, fail) - ctx.activities.onActivitiesChanged((owner: Agent | undefined) => { - for (const snapshot of ctx.activities.list(owner)) validateSnapshot(snapshot, fail) - }) -}, { inject: ['activities'] }) - -/** - * Register the process-registry invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/activity/activity/src/pump.ts b/packages/activity/activity/src/pump.ts deleted file mode 100644 index 4cc1eae22d..0000000000 --- a/packages/activity/activity/src/pump.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Shared poll pump for producers whose output substrate is a non-consuming - * offset reader (the subprocess `readFrom(fromByte)` family). Pure utility — - * no cordis, no timers retained past settlement. - * @module - */ - -import type { ActivityChannel, ActivityHandle } from './types.ts' - -/** One non-consuming offset read from a producer-owned stream. */ -export interface ActivityPumpRead { - /** Text captured since the requested offset (the whole retained tail when lossy). */ - text: string - /** Whole-stream offset to resume from on the next read. */ - nextOffset: number - /** True when the requested offset slid out of the source's retained window. */ - lossy: boolean -} - -/** One producer-owned stream the pump copies into a process. */ -export interface ActivityPumpSource { - /** Stream label attached to every chunk this source yields. */ - channel?: ActivityChannel - /** - * Read everything captured since `fromByte` without consuming it. - * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). - * @returns the delta text, the next offset, and the lossy flag. - */ - read(fromByte: number): ActivityPumpRead -} - -/** Cadence and settlement for one {@link pumpActivityOutput} run. */ -export interface ActivityPumpOptions { - /** Poll interval in milliseconds; a positive finite number. */ - pollMs: number - /** - * Settles when the producer's work finished and its sources hold their - * final bytes. The pump drains once more after settlement and returns; - * a rejection is treated as settlement. - */ - done: Promise -} - -/** - * Copy producer streams into a process at a bounded cadence: drain every - * source, sleep `pollMs` or until `done` settles, repeat, then drain one - * final time. A lossy source read appends its surviving tail with - * `gapBefore`, so the discontinuity stays visible to observers. The pump - * never calls `handle.end()` — the producer maps its own outcome. - * @param handle - the open process receiving the copied chunks. - * @param sources - producer streams, each pumped at its own offset. - * @param options - poll cadence and the producer's settlement promise. - * @returns resolves after the final post-settlement drain. - */ -export async function pumpActivityOutput( - handle: ActivityHandle, - sources: readonly ActivityPumpSource[], - options: ActivityPumpOptions, -): Promise { - if (!Number.isFinite(options.pollMs) || options.pollMs <= 0) { - throw new Error(`invalid pump pollMs: expected a positive finite number of milliseconds, got ${JSON.stringify(options.pollMs)}`) - } - const states = sources.map(source => ({ source, cursor: 0 })) - const drain = (): void => { - for (const state of states) { - const { source } = state - const read = source.read(state.cursor) - state.cursor = read.nextOffset - if (read.text.length === 0) continue - if (source.channel === undefined && !read.lossy) { - handle.append(read.text) - } else { - handle.append(read.text, { - ...source.channel !== undefined ? { channel: source.channel } : {}, - ...read.lossy ? { gapBefore: true as const } : {}, - }) - } - } - } - let settled = false - const settlement = options.done.then(() => { settled = true }, () => { settled = true }) - while (!settled) { - drain() - await Promise.race([ - new Promise((resolve) => { - const timer = setTimeout(resolve, options.pollMs) - // The settlement branch wins the race; clear the loser so a finished - // pump holds no pending timer for up to one poll interval. - void settlement.then(() => { clearTimeout(timer); resolve() }) - }), - settlement, - ]) - } - drain() -} diff --git a/packages/activity/activity/src/types.ts b/packages/activity/activity/src/types.ts deleted file mode 100644 index 3ea1a42332..0000000000 --- a/packages/activity/activity/src/types.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SessionId } from '@deepseek-ai/dsh-session' -import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' -import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -import type { ActivityId } from './brand.ts' - -export type { ActivityId } from './brand.ts' - -/** - * Producer-defined activity kinds. Plugins extend this map by declaration - * merging; the registry treats every value as an opaque id namespace. - */ -export interface ActivityKindMap { - bash: 'bash' -} - -/** A registered activity kind — also the id prefix (`bash`, `workflow`, …). */ -export type ActivityKind = ActivityKindMap[keyof ActivityKindMap] - -/** Lifecycle state of one observable activity. */ -export type ActivityStatus = 'running' | 'completed' | 'failed' | 'killed' - -/** - * Output stream label. Producers without distinct streams omit it; consumers - * treat an unrecognized label like an absent one. - */ -export type ActivityChannel = 'stdout' | 'stderr' - -/** - * Links one activity to the records other domains keep for the same work, so a - * consumer can attach the output stream to an existing card or row without - * guessing from labels. - */ -export interface ActivityCorrelation { - /** Tool call that started the work, when a tool produced it. */ - callId?: ToolCallId - /** Background job carrying the model-facing control surface, when one exists. */ - jobId?: JobId -} - -/** - * Producer declaration passed to {@link ActivityRegistry.open}. The registry - * owns identity, retention, and observer delivery; the producer keeps its - * execution resources and reports through the returned {@link ActivityHandle}. - */ -export interface ActivityOpen { - /** Producer kind — also the id prefix. */ - kind: ActivityKind - /** One-line human-facing label (the command; the run description). */ - label: string - /** - * Owning live agent. Visibility is fenced by its session id, and agent - * disposal ends and removes the record. The instance must be the one - * currently registered under its agent id. Omitting the owner creates an - * unowned activity, visible to any caller until service disposal. - */ - owner?: Agent - /** Optional links to the tool call and background job for the same work. */ - correlation?: ActivityCorrelation -} - -/** Terminal outcome reported through {@link ActivityHandle.end}. */ -export interface ActivityOutcome { - /** How the work ended: finished (`completed`), broke (`failed`), or was cancelled (`killed`). */ - status: 'completed' | 'failed' | 'killed' - /** Kind-specific detail rendered into status lines ('exit code: 3'). */ - detail?: string -} - -/** Options for one {@link ActivityHandle.append}. */ -export interface ActivityAppendOptions { - /** Stream label for the chunk; omitted for producers without distinct streams. */ - channel?: ActivityChannel - /** - * Marks that producer-side bytes between the previous chunk and this one - * were lost (a source window slid before the producer could copy it), so a - * consumer can render the discontinuity instead of a silent splice. - */ - gapBefore?: true -} - -/** - * Producer face of one open activity. All methods are synchronous. After - * {@link end} — including a registry-forced end during owner or service - * teardown — `append` and `updateDetail` log and drop instead of throwing, so - * a producer's trailing flush cannot break its own teardown path. - */ -export interface ActivityHandle { - /** The registry-issued id (`-N`). */ - readonly id: ActivityId - /** - * Append one output chunk. Offsets advance by the chunk's UTF-8 byte - * length; an empty chunk is dropped without waking observers. - * @param text - the chunk text, exactly as produced. - * @param options - stream label and gap marker. - */ - append(text: string, options?: ActivityAppendOptions): void - /** - * Replace the snapshot's status detail (a progress line such as `3/10`). - * @param detail - the new detail line. - */ - updateDetail(detail: string): void - /** - * Record the terminal outcome and trim retention to the settled cap. - * First-wins: a later call — the producer's own report after a teardown - * force-end, or a duplicate — is dropped silently. - * @param outcome - terminal status and optional detail. - */ - end(outcome: ActivityOutcome): void -} - -/** - * A read-only projection of one activity, safe to hand to listeners and - * consumers — a fresh object per call, never live registry state. - */ -export interface ActivitySnapshot { - /** The registry-issued id (`-N`). */ - id: ActivityId - /** The producer kind the process was opened with. */ - kind: ActivityKind - /** The producer-supplied one-line label. */ - label: string - /** Owner session id used for visibility and grouping; absent for unowned activities. */ - ownerSession?: SessionId - /** Links to the tool call and background job for the same work, when supplied. */ - correlation?: ActivityCorrelation - /** Current lifecycle state. */ - status: ActivityStatus - /** Kind-specific status detail, when the producer supplied one. */ - detail?: string - /** Epoch ms when the activity was opened. */ - startedAt: number - /** Epoch ms when the activity ended; absent while `running`. */ - finishedAt?: number - /** Total UTF-8 bytes ever appended — the offset the next chunk starts at. */ - outputTotal: number - /** - * Offset of the oldest retained byte. Greater than zero exactly when - * retention dropped the head; a reader starting below it gets a lossy read. - */ - outputEarliest: number -} - -/** One retained output chunk returned by {@link ActivityRegistry.read}. */ -export interface ActivityOutputChunk { - /** Absolute offset of the chunk's first byte. */ - at: number - /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ - text: string - /** Stream label, when the producer supplied one. */ - channel?: ActivityChannel - /** Producer-reported loss immediately before this chunk. */ - gapBefore?: true -} - -/** Result of one non-consuming {@link ActivityRegistry.read}. */ -export interface ActivityRead { - /** Retained chunks overlapping `[from, total)`, in offset order. */ - chunks: readonly ActivityOutputChunk[] - /** - * Offset to resume from — the process' current `outputTotal`. Always a - * chunk boundary: appends land whole and trimming only advances chunk - * starts, and consumers concatenate `chunks` under that assumption, so a - * provider serving partial chunks would silently duplicate text. - */ - next: number - /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ - lossy: boolean -} - -/** - * Observer of visible-set changes: opening, detail updates, settlement, and - * removal. Receives the owner whose set changed, or `undefined` when an - * unowned process changed and every caller's set did. Consumers re-read - * rather than accumulating deltas. - */ -export type ActivitiesChangedListener = (owner: Agent | undefined) => void - -/** - * Observer of stream advancement for one activity: new appended output, or the - * settlement that ends the stream. Carries no payload — a consumer schedules a - * {@link ActivityRegistry.read} from its own cursor. - */ -export type ActivityOutputListener = (id: ActivityId) => void diff --git a/packages/activity/activity/tests/invariant.spec.ts b/packages/activity/activity/tests/invariant.spec.ts deleted file mode 100644 index 306cd4533a..0000000000 --- a/packages/activity/activity/tests/invariant.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import ActivityRegistry, { ActivityId } from '@deepseek-ai/dsh-activity' -import type { ActivitiesChangedListener, ActivitySnapshot } from '@deepseek-ai/dsh-activity' -import * as ProcessInvariant from '@deepseek-ai/dsh-activity/invariant' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' - -const BASE: ActivitySnapshot = { - id: ActivityId('bash-1'), - kind: 'bash', - label: 'compile', - status: 'completed', - startedAt: 10, - finishedAt: 20, - outputTotal: 12, - outputEarliest: 4, -} - -const RUNNING: ActivitySnapshot = { - id: ActivityId('bash-1'), - kind: 'bash', - label: 'compile', - status: 'running', - startedAt: 10, - outputTotal: 0, - outputEarliest: 0, -} - -/** Boot the companion over a probe registry whose visible set the test controls. */ -async function setup(seed: ActivitySnapshot[] = []): Promise<(visible: ActivitySnapshot[], owner?: Agent) => void> { - const ctx = new Context() - let current = seed - let listener: ActivitiesChangedListener | undefined - const probe = { - list: () => current, - onActivitiesChanged(value: ActivitiesChangedListener) { - listener = value - return () => { listener = undefined } - }, - } as unknown as ActivityRegistry - await ctx.plugin(InvariantRegistry) - await ctx.plugin({ - name: 'activity-invariant-probe', - apply(child: Context) { child.provide('activities', probe) }, - }) - await ctx.plugin(ProcessInvariant) - if (listener === undefined) throw new Error('activity invariant did not subscribe to visible-set changes') - return (visible, owner) => { - current = visible - listener!(owner) - } -} - -describe('process-registry invariants', () => { - it('accepts coherent running and terminal snapshots', async () => { - const notify = await setup([RUNNING]) - expect(() => { notify([BASE]) }).not.toThrow() - expect(() => { notify([{ ...BASE, id: ActivityId('workflow-2'), kind: 'workflow' }]) }) - .not.toThrow() - }) - - it.each([ - [{ ...BASE, id: ActivityId('-1'), kind: '' as ActivitySnapshot['kind'] }, /positive ordinal/], - [{ ...BASE, id: ActivityId('other-1') }, /must be "bash-" followed by a positive ordinal/], - [{ ...BASE, id: ActivityId('bash-0') }, /positive ordinal/], - [{ ...BASE, label: '' }, /label must be non-empty/], - [{ ...BASE, startedAt: -1 }, /startedAt must be a non-negative epoch integer/], - [{ ...BASE, status: 'running' as const, finishedAt: 20 }, /finishedAt must be present exactly for a terminal status/], - [{ ...RUNNING, status: 'killed' as const }, /finishedAt must be present exactly for a terminal status/], - [{ ...BASE, finishedAt: 9 }, /no earlier than startedAt/], - [{ ...BASE, outputEarliest: 13 }, /0 <= outputEarliest <= outputTotal/], - [{ ...BASE, outputTotal: -1, outputEarliest: 0 }, /0 <= outputEarliest <= outputTotal/], - ] as const)('rejects an incoherent registry snapshot', async (snapshot, message) => { - const notify = await setup() - expect(() => { notify([snapshot]) }).toThrow(message) - }) - - it('rejects an incoherent record already present at installation', async () => { - await expect(setup([{ ...BASE, label: '' }])).rejects.toThrow(/label must be non-empty/) - }) -}) diff --git a/packages/activity/activity/tests/service.spec.ts b/packages/activity/activity/tests/service.spec.ts deleted file mode 100644 index 2dd685c1d3..0000000000 --- a/packages/activity/activity/tests/service.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, expectTypeOf, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import type { SessionId } from '@deepseek-ai/dsh-session' -import ActivityRegistry, { ActivityId, pumpActivityOutput } from '@deepseek-ai/dsh-activity' -import type { ActivityAppendOptions, ActivityHandle, ActivityPumpSource, ActivitySnapshot } from '@deepseek-ai/dsh-activity' - -/** Record every append so pump behavior is observable without a registry. */ -function recordingHandle(): { handle: ActivityHandle; appends: { text: string; options?: ActivityAppendOptions }[] } { - const appends: { text: string; options?: ActivityAppendOptions }[] = [] - const handle: ActivityHandle = { - id: ActivityId('bash-1'), - append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) }, - updateDetail() {}, - end() {}, - } - return { handle, appends } -} - -/** A scripted source: each read() shifts the next scripted result. */ -function scriptedSource( - reads: { text: string; lossy?: boolean }[], - channel?: 'stdout' | 'stderr', -): { source: ActivityPumpSource; offsets: number[] } { - const offsets: number[] = [] - let offset = 0 - const source: ActivityPumpSource = { - ...channel !== undefined ? { channel } : {}, - read(fromByte) { - offsets.push(fromByte) - const next = reads.shift() ?? { text: '' } - offset += Buffer.byteLength(next.text, 'utf8') - return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false } - }, - } - return { source, offsets } -} - -describe('ActivityRegistry seam', () => { - it('refuses to mount the abstract Service Definition directly', async () => { - const ctx = new Context() - await expect(ctx.plugin(ActivityRegistry as unknown as () => void)) - .rejects.toThrow(/load an implementation such as @deepseek-ai\/dsh-activity-local/) - }) - - it('preserves the SessionId brand on public owner snapshots', () => { - expectTypeOf().toEqualTypeOf() - }) -}) - -describe('pumpActivityOutput', () => { - it('rejects a non-positive poll interval before touching any source', async () => { - const { handle } = recordingHandle() - await expect(pumpActivityOutput(handle, [], { pollMs: 0, done: Promise.resolve() })) - .rejects.toThrow(/invalid pump pollMs/) - }) - - it('copies labeled deltas at the poll cadence and resumes each source at its own offset', async () => { - vi.useFakeTimers() - try { - const { handle, appends } = recordingHandle() - let settle!: () => void - const done = new Promise((resolve) => { settle = resolve }) - const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout') - const err = scriptedSource([{ text: '' }, { text: 'E' }], 'stderr') - const pump = pumpActivityOutput(handle, [out.source, err.source], { pollMs: 50, done }) - - await vi.advanceTimersByTimeAsync(50) - settle() - await pump - expect(appends).toEqual([ - { text: 'a', options: { channel: 'stdout' } }, - { text: 'bc', options: { channel: 'stdout' } }, - { text: 'E', options: { channel: 'stderr' } }, - ]) - // Every read resumed from the previous nextOffset, never from a shared cursor. - expect(out.offsets).toEqual([0, 1, 3]) - expect(err.offsets).toEqual([0, 0, 1]) - // Settlement cleared the raced poll timer instead of leaving it pending. - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('marks a lossy source read as a gap so observers see the discontinuity', async () => { - const { handle, appends } = recordingHandle() - const { source } = scriptedSource([{ text: 'tail', lossy: true }]) - await pumpActivityOutput(handle, [source], { pollMs: 1, done: Promise.resolve() }) - expect(appends).toEqual([{ text: 'tail', options: { gapBefore: true } }]) - }) - - it('treats a rejected done as settlement and still drains the final bytes', async () => { - const { handle, appends } = recordingHandle() - const { source } = scriptedSource([{ text: 'last' }]) - await pumpActivityOutput(handle, [source], { pollMs: 1, done: Promise.reject(new Error('producer broke')) }) - expect(appends).toEqual([{ text: 'last' }]) - }) -}) diff --git a/packages/activity/activity/tsconfig.json b/packages/activity/activity/tsconfig.json deleted file mode 100644 index f4deee6327..0000000000 --- a/packages/activity/activity/tsconfig.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../util/brand" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/session" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../jobs/jobs" - }, - { - "path": "../../runtime-diagnostics/invariants" - } - ] -} diff --git a/packages/api/activity-controller/README.i18n.yaml b/packages/api/activity-controller/README.i18n.yaml deleted file mode 100644 index 85ebf647c1..0000000000 --- a/packages/api/activity-controller/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# 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/api/activity-controller/README.md -README.md: ffdf97aa94ab808ea4f95a9b8b8a36e3e04f43ed -README.zh.md: 580d00a11d3e8743d606c9c9ca98c4553235cc50 diff --git a/packages/api/activity-controller/README.md b/packages/api/activity-controller/README.md deleted file mode 100644 index ffdf97aa94..0000000000 --- a/packages/api/activity-controller/README.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -description: "Activity Remote streams for browsers: the activity.control roster stream, per-activity activity.observe output streams with flushMs coalescing and maxFrameBytes budgets, and the ctx.activityFeed client model." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-api-activity-controller - -English | [中文](README.zh.md) - -## Summary - -`dsh-api-activity-controller` streams the live-activity plane to browsers: a client follows one roster stream for every visible task row and opens one observation stream per expanded panel, resuming across reconnects from its own cursor. A pure read surface — it starts nothing, cancels nothing, and never touches the model-facing consuming cursors; the wire path reads only the registry's non-consuming offsets. A composition without `ctx.activities` still serves an empty baseline, so the client degrades to no live rows rather than an error. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Mount the host row beside `dsh-typert-registry` and load the client half through the plugin manifest; UI features then consume `ctx.activityFeed` instead of raw streams. - -### The two Remote streams - -The Host `ActivityController` (namespace `activity`) exposes two `@Remote({ mode: 'stream' })` methods: - -- `control(signal)` — one complete roster baseline, then whole-bucket replacement frames per owner session on every registry change (the self-healing shape the session control stream uses for jobs). A composition without the registry serves an empty baseline. The Remote tier deliberately serves every session's rows to any connected browser, and `observe` satisfies the registry's owner fence with the mirrored owner: the per-session fence is an in-process contract, and this single-user local BFF reads across it exactly as the session control stream does for `jobsBySession`. -- `observe({ activityId, from? }, signal)` — one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget; one larger chunk ships whole), then one terminal `status` after the settled activity is drained, after which the generation closes normally. Reconnecting callers resume by passing the last frame's `next`; a resume behind the retained window arrives flagged `lossy`. Status rides the same stream as output, so settlement can never race a still-open output channel. - -| Config | Default | Meaning | -|---|---|---| -| `flushMs` | `100` | Coalescing window after new output before an observation read. | -| `maxFrameBytes` | `65536` | Soft byte budget per observation output frame. | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-activity-controller) is the exhaustive source for every accepted field. - -### The client feed - -The client half (`./client`) installs `ctx.activityFeed`: `ClientActivityModel` mirrors the roster (last-wins buckets, generation replace) and accumulates each observed activity's bounded render tail, and `ClientActivityFeed.observe(id)` opens reference-counted observation streams that resume from the model's cursor across carrier generations. - ------ - - -## Understand the implementation - -
        -Implementation internals — click to expand - -The Host side mirrors the registry into owner buckets (`ActivityFeed`), fans control frames out to per-generation followers, and runs each observation as an async generator that subscribes before its first read so no append lands between anchor and stream. The client side keeps observation state per activity with a resume cursor; release closures bind the exact entry they were minted for, so a superseded observation's late release can neither tear down nor clear its successor. - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | `ActivityController`: the two Remote stream methods | -| [`src/feed.ts`](src/feed.ts) | Owner-bucket roster mirror and control-stream followers | -| [`src/observe.ts`](src/observe.ts) | Per-activity observation generator: anchor, coalescing, terminal status | -| [`src/client/model.ts`](src/client/model.ts) | Roster and observation state with the bounded render tail | -| [`src/client/service.ts`](src/client/service.ts) | `ctx.activityFeed`: reference-counted observation streams | -| [`src/client/index.ts`](src/client/index.ts) | Client plugin: roster stream wiring and the service install | - -
        - ------ - - -## Further Exploration - -- [`dsh-activity`](../../activity/activity/README.md) — the observation contract this transport reads. -- [`dsh-client-ui-activity`](../../client/ui-activity/README.md) — the task list rendering `ctx.activityFeed`. -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.md) — the plane end to end, wire shapes included. - ------ - - -## Model Experience - -None, as activity transport is browser and Host observation state and registers no prompt, tool, or session event. - -#### KV Cache effect - -None; the package never assembles or alters provider requests. - -## Known Limitations and Deferred Work - - - -These limits define when the transport is a poor fit. They are current package constraints, not a task backlog. - -- **Registry presence is sampled at construction** — a registry mounted after the controller stays unobserved until the controller reloads. -- **The roster names no watermark** — an observation stream anchors itself; correlating roster rows against observation offsets is client policy. - - -### Dev Note - -
        -Working context for maintainers — click to expand - -None. - -
        diff --git a/packages/api/activity-controller/README.zh.md b/packages/api/activity-controller/README.zh.md deleted file mode 100644 index 580d00a11d..0000000000 --- a/packages/api/activity-controller/README.zh.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -description: "面向浏览器的 Activity Remote 流:activity.control roster 流、带 flushMs 合并与 maxFrameBytes 预算的逐活动 activity.observe 输出流,以及 ctx.activityFeed 客户端模型。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-api-activity-controller - -[English](README.md) | 中文 - -## 概述 - -`dsh-api-activity-controller` 把实时活动面流式提供给浏览器:客户端跟随一条 roster 流获得每个可见任务行,并为每个展开的面板打开一条观察流,重连后从自己的游标续传。它是纯读表面——什么都不启动、什么都不取消、从不触碰模型可见的消费游标;线路路径只读注册表的非消费偏移。没有 `ctx.activities` 的组合仍提供空 baseline,客户端退化为没有实时行而非报错。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -在 `dsh-typert-registry` 旁挂载宿主行,并经插件清单加载客户端半侧;UI 功能随即消费 `ctx.activityFeed` 而非裸流。 - -### 两条 Remote 流 - -宿主 `ActivityController`(命名空间 `activity`)暴露两个 `@Remote({ mode: 'stream' })` 方法: - -- `control(signal)`——一份完整 roster baseline,之后每次注册表变化按 owner 会话推送整桶替换帧(与 session control 流承载 jobs 的自愈形态一致)。没有注册表的组合返回空 baseline。Remote 层有意把所有会话的行提供给任何已连接的浏览器,`observe` 以镜像到的 owner 替调用方满足注册表的 owner 栅栏:按会话的栅栏是进程内契约,这个单用户本地 BFF 跨栅栏读取,与 session control 流对 `jobsBySession` 的做法完全一致。 -- `observe({ activityId, from? }, signal)`——一帧 `opened` 锚点、合并后的 `output` 帧(`flushMs` 窗口,`maxFrameBytes` 软预算;单个更大的 chunk 整块发出),已结算活动排空后一帧终态 `status`,随后本代正常关闭。重连调用方传上一帧的 `next` 续传;落后于留存窗口的续传带 `lossy` 标记到达。status 与 output 走同一条流,结算绝不会与仍开放的输出通道赛跑。 - -| 配置 | 默认 | 含义 | -|---|---|---| -| `flushMs` | `100` | 新输出后进行一次观察读取前的合并窗口。 | -| `maxFrameBytes` | `65536` | 每个观察输出帧的软字节预算。 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-activity-controller)是每个可接受字段的穷尽来源。 - -### 客户端 feed - -客户端半侧(`./client`)安装 `ctx.activityFeed`:`ClientActivityModel` 镜像 roster(last-wins 桶、按代整体替换)并累积每个被观察活动的有界渲染尾部,`ClientActivityFeed.observe(id)` 打开引用计数的观察流,跨 carrier 代从模型游标续传。 - ------ - - -## 理解实现 - -
        -实现内幕——点击展开 - -宿主侧把注册表镜像进 owner 桶(`ActivityFeed`),向每代 follower 扇出 control 帧,并把每个观察跑成先订阅再首读的 async generator,锚点与流之间不会漏 append。客户端侧按活动保存观察状态与续传游标;release 闭包绑定自己创建时的那个条目,被替代观察的迟到 release 既拆不掉也清不掉后继者。 - -| 文件 | 角色 | -|---|---| -| [`src/index.ts`](src/index.ts) | `ActivityController`:两个 Remote 流方法 | -| [`src/feed.ts`](src/feed.ts) | owner 桶 roster 镜像与 control 流 follower | -| [`src/observe.ts`](src/observe.ts) | 逐活动观察 generator:锚点、合并、终态 status | -| [`src/client/model.ts`](src/client/model.ts) | roster 与观察状态,含有界渲染尾部 | -| [`src/client/service.ts`](src/client/service.ts) | `ctx.activityFeed`:引用计数的观察流 | -| [`src/client/index.ts`](src/client/index.ts) | 客户端插件:roster 流接线与服务安装 | - -
        - ------ - - -## 进一步探索 - -- [`dsh-activity`](../../activity/activity/README.zh.md)——本传输所读的观察契约。 -- [`dsh-client-ui-activity`](../../client/ui-activity/README.zh.md)——渲染 `ctx.activityFeed` 的任务列表。 -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.zh.md)——端到端的观察面,含线路形态。 - ------ - - -## 模型体验 - -无:activity 传输是浏览器与宿主的观察状态,不注册任何 prompt、工具或会话事件。 - -#### KV Cache 影响 - -无;本包从不组装或改动 provider 请求。 - -## 已知限制与延期工作 - - - -这些限制说明该传输何时不合适。它们是当前包约束,不是任务积压。 - -- **注册表存在性在构造时采样**——controller 之后才挂载的注册表在 controller 重载前保持不被观察。 -- **roster 不命名水位**——观察流自行锚定;把 roster 行与观察偏移相关联是客户端策略。 - - -### 开发备注 - -
        -维护者的工作上下文——点击展开 - -无。 - -
        diff --git a/packages/api/activity-controller/package.json b/packages/api/activity-controller/package.json deleted file mode 100644 index a5250ad8ac..0000000000 --- a/packages/api/activity-controller/package.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-api-activity-controller", - "description": "Activity Remote streams: the live-activity roster and per-activity non-consuming output observation", - "version": "0.1.2-alpha.2", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/api/activity-controller" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./types": { - "types": "./lib/types/types.d.ts", - "default": "./lib/types/types.js" - }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, - "./typert": { - "types": "./lib/typert.host.d.ts", - "default": "./lib/typert.host.js" - }, - "./remote": { - "types": "./lib/typert.remote-client.d.ts", - "default": "./lib/typert.remote-client.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "scripts": { - "bundle": "tsdown", - "watch": "tsdown --watch" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/client.js", - "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/typert.host.js", - "lib/typert.host.d.ts", - "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" - ], - "license": "MIT", - "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "zod": "^4.4.3" - }, - "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", - "@deepseek-ai/dsh-activity-local": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" - }, - "dsh": { - "client": { - "external": [ - "@deepseek-ai/dsh-api-gateway/client" - ], - "inject": [ - "@deepseek-ai/dsh-api-gateway", - "@deepseek-ai/dsh-client-connection" - ], - "platform": "web" - } - } -} diff --git a/packages/api/activity-controller/src/client/index.ts b/packages/api/activity-controller/src/client/index.ts deleted file mode 100644 index 7338ae3b9b..0000000000 --- a/packages/api/activity-controller/src/client/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** Activity-specific adapter for the Gateway-owned snapshot stream lifecycle. */ - -import type { Context } from '@deepseek-ai/cordis' -import { - RemoteSnapshotStream, - RemoteStreamCarrierError, - type ClientRemote, -} from '@deepseek-ai/dsh-api-gateway/client' -import type { ActivityControlFrame } from '../types.ts' -import { ClientActivityModel } from './model.ts' -import { ClientActivityFeed } from './service.ts' -import type { ActivityRemote } from './service.ts' - -export type { ActivityFeedPhase, ActivityFeedSnapshot, ObservedActivity } from './model.ts' -export type { ActivityFeedSource, ActivityRemote, IActivityFeed } from './service.ts' -export type { ActivityId } from '@deepseek-ai/dsh-activity/brand' -export type { ActivityRow } from '../types.ts' - -type ActivityStreamRemote = Pick & { readonly activity: ActivityRemote } - -type ActivityBaselineFrame = Extract -type ActivityRowsFrame = Extract - -declare module '@deepseek-ai/cordis' { - interface Context { - /** React-free client activity roster and observation control. */ - activityFeed: import('./service.ts').IActivityFeed - } -} - -/** Required Client Remote services. */ -export const inject = ['remote', 'remote.activity'] - -/** - * Install the client activity model, its service face, and the reconnecting - * roster stream. - * @param ctx - client root Context. - */ -export function apply(ctx: Context): void { - const raw = ctx.remote as ActivityStreamRemote - // Resolve the child namespace to a concrete value while this plugin's - // context is current: later stream (re)opens run on caller stacks (a React - // event, a carrier retry) whose dynamic context has not declared - // `remote.activity`, and a property access there fails the inject check. - const remote: ActivityStreamRemote = { - $stream: options => raw.$stream(options), - activity: raw.activity, - } - const model = new ClientActivityModel() - new ClientActivityFeed(ctx, remote, model) - const control = createActivityRosterStream(remote, model) - control.start() - ctx.effect( - () => async () => { await control.dispose() }, - 'activity-controller.client.control', - ) -} - -/** - * Create the reconnecting roster stream feeding one client model. - * @param remote - generated activity namespace and Gateway stream factory. - * @param model - roster sink. - * @returns an unstarted stream owned by the client activity runtime. - */ -export function createActivityRosterStream( - remote: ActivityStreamRemote, - model: ClientActivityModel, -): RemoteSnapshotStream { - const stream = remote.$stream({ - name: 'activity roster stream', - open: signal => remote.activity.control(signal), - ended: accepted => accepted - ? new RemoteStreamCarrierError('activity roster stream ended without a terminal result') - : new Error('activity roster stream ended before its opening baseline'), - }) - return new RemoteSnapshotStream(stream, { - name: 'activity roster stream', - isSnapshot: (frame): frame is ActivityBaselineFrame => frame.type === 'baseline', - replace: (frame) => { model.replaceBaseline(frame.activities) }, - update: (frame) => { model.replaceBucket(frame.sessionId, frame.activities) }, - failed: () => { - // The previous roster stays published while the stream retries; a - // terminal protocol failure leaves the last snapshot in place. - }, - }) -} diff --git a/packages/api/activity-controller/src/client/model.ts b/packages/api/activity-controller/src/client/model.ts deleted file mode 100644 index 75354f1255..0000000000 --- a/packages/api/activity-controller/src/client/model.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** Client-side activity state model shared by Remote transport and UI projection. */ - -import { notifySubscribers } from '@deepseek-ai/dsh-client-store' -import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' -import type { ActivityObserveFrame, ActivityRow } from '../types.ts' - -/** Bounded per-activity render tail, in UTF-16 code units. */ -const RENDER_TAIL_LIMIT = 128 * 1024 - -/** Roster arrival lifecycle: `pending` until the first baseline lands. */ -export type ActivityFeedPhase = 'pending' | 'ready' - -/** One observed activity's live view state. */ -export interface ObservedActivity { - readonly activityId: ActivityId - /** Accumulated output tail, bounded to the render limit. */ - readonly text: string - /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ - readonly gapBefore: boolean - /** Latest status delivered on the observation stream. */ - readonly status: ActivityRow['status'] - readonly detail?: string - /** True while the observation stream is open and the activity has not settled. */ - readonly streaming: boolean - /** Terminal observation failure, when the stream ended abnormally. */ - readonly error?: string -} - -/** Immutable client activity state. */ -export interface ActivityFeedSnapshot { - /** Roster rows per owner session; the empty key holds unowned activities. */ - readonly rowsBySession: Readonly> - /** Live observation state keyed by activity id. */ - readonly observed: Readonly> - readonly phase: ActivityFeedPhase -} - -const UNOWNED = '' - -/** Mutable observation bookkeeping behind one {@link ObservedActivity} view. */ -interface ObservedState { - view: ObservedActivity - /** Resume offset for the next generation's `from`. */ - cursor: number | undefined -} - -/** - * Owns the client roster mirror and per-activity observation state. Pure data - * plus subscriptions — transport wiring stays in the client plugin, UI stays - * in slot components. - */ -export class ClientActivityModel { - private rowsBySession: Readonly> = {} - private readonly observedStates = new Map() - private phase: ActivityFeedPhase = 'pending' - private readonly listeners = new Set<() => void>() - private snapshotCache: ActivityFeedSnapshot = { rowsBySession: {}, observed: {}, phase: 'pending' } - private snapshotDirty = false - - /** - * Read the identity-stable current snapshot. - * @returns the cached snapshot, rebuilt only after a change. - */ - getSnapshot(): ActivityFeedSnapshot { - if (this.snapshotDirty) { - const observed: Record = {} - for (const [id, state] of this.observedStates) observed[id] = state.view - this.snapshotCache = { rowsBySession: this.rowsBySession, observed, phase: this.phase } - this.snapshotDirty = false - } - return this.snapshotCache - } - - /** - * Subscribe to snapshot changes. - * @param listener - invalidation callback. - * @returns unsubscribe function. - */ - subscribe(listener: () => void): () => void { - this.listeners.add(listener) - return () => { this.listeners.delete(listener) } - } - - /** - * Replace the whole roster from a generation baseline. - * @param rows - every visible row across all owner buckets. - */ - replaceBaseline(rows: readonly ActivityRow[]): void { - const next: Record = {} - for (const row of rows) { - const key = row.sessionId ?? UNOWNED - ;(next[key] ??= []).push(row) - } - this.rowsBySession = next - this.phase = 'ready' - this.changed() - } - - /** - * Replace one owner bucket wholesale; an empty replacement drops the key. - * @param sessionId - owner session, or undefined for the unowned bucket. - * @param rows - the bucket's complete visible rows. - */ - replaceBucket(sessionId: string | undefined, rows: readonly ActivityRow[]): void { - const key = sessionId ?? UNOWNED - if (rows.length === 0) { - this.rowsBySession = Object.fromEntries( - Object.entries(this.rowsBySession).filter(([bucket]) => bucket !== key)) - } else { - this.rowsBySession = { ...this.rowsBySession, [key]: rows } - } - this.changed() - } - - /** - * The resume offset for one activity's next observation generation. - * @param id - observed activity. - * @returns the last accepted `next`, or undefined for a fresh observation. - */ - cursorOf(id: ActivityId): number | undefined { - return this.observedStates.get(String(id))?.cursor - } - - /** - * Install or reset observation state when a generation's anchor arrives. - * @param id - observed activity. - * @param frame - the generation's `opened` anchor. - */ - observeOpened(id: ActivityId, frame: Extract): void { - const existing = this.observedStates.get(String(id)) - // A fresh view anchored past offset zero starts after an evicted head - // (fresh observations anchor at the registry's earliest retained byte), so - // it owes the same gap mark a live observer earned from lossy reads. A - // resume that already accumulated text keeps its recorded gap state. - const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0 - const view: ObservedActivity = { - activityId: id, - text: existing?.view.text ?? '', - gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead, - status: frame.status, - ...frame.detail !== undefined ? { detail: frame.detail } : {}, - streaming: true, - } - this.observedStates.set(String(id), { view, cursor: frame.from }) - this.changed() - } - - /** - * Append one output frame's chunks to the bounded render tail. - * @param id - observed activity. - * @param frame - a coalesced `output` frame. - */ - observeOutput(id: ActivityId, frame: Extract): void { - const state = this.observedStates.get(String(id)) - /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ - if (state === undefined) return - let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('') - let gapBefore = state.view.gapBefore || frame.lossy === true - || frame.chunks.some(chunk => chunk.gapBefore === true) - if (text.length > RENDER_TAIL_LIMIT) { - let cut = text.length - RENDER_TAIL_LIMIT - // Never split a surrogate pair at the render bound. - const unit = text.charCodeAt(cut) - if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1 - text = text.slice(cut) - gapBefore = true - } - state.view = { ...state.view, text, gapBefore } - state.cursor = frame.next - this.changed() - } - - /** - * Record the terminal status delivered at the end of the observation stream. - * @param id - observed activity. - * @param frame - the terminal `status` frame. - */ - observeStatus(id: ActivityId, frame: Extract): void { - const state = this.observedStates.get(String(id)) - /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ - if (state === undefined) return - state.view = { - ...state.view, - status: frame.status, - ...frame.detail !== undefined ? { detail: frame.detail } : {}, - streaming: false, - } - this.changed() - } - - /** - * Record a terminal observation failure. - * @param id - observed activity. - * @param error - the stream's terminal failure. - */ - observeFailed(id: ActivityId, error: unknown): void { - const state = this.observedStates.get(String(id)) - if (state === undefined) return - state.view = { ...state.view, streaming: false, error: String(error) } - this.changed() - } - - /** - * Drop observation state after the last observer stops. - * @param id - the no-longer-observed activity. - */ - observeStopped(id: ActivityId): void { - if (!this.observedStates.delete(String(id))) return - this.changed() - } - - private changed(): void { - this.snapshotDirty = true - notifySubscribers(this.listeners, 'activity feed') - } -} diff --git a/packages/api/activity-controller/src/client/service.ts b/packages/api/activity-controller/src/client/service.ts deleted file mode 100644 index 2947e5bf04..0000000000 --- a/packages/api/activity-controller/src/client/service.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** React-free client activity service: roster snapshot plus observation control. */ - -import { Service, type Context } from '@deepseek-ai/cordis' -import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' -import type {} from '@deepseek-ai/dsh-api-activity-controller/remote' -import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' -import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' -import type { ActivityObserveFrame } from '../types.ts' -import type { ActivityFeedSnapshot, ClientActivityModel } from './model.ts' - -/** Complete generated `ctx.remote.activity` namespace. */ -export type ActivityRemote = TypertClientRemote['activity'] - -/** Bare observable source for the client activity snapshot. */ -export interface ActivityFeedSource { - /** Read the identity-stable current snapshot. */ - getSnapshot(): ActivityFeedSnapshot - /** - * Subscribe to snapshot changes. - * @param listener - invalidation callback. - * @returns unsubscribe function. - */ - subscribe(listener: () => void): () => void -} - -/** The activity feed's client service face. */ -export interface IActivityFeed { - /** Roster rows, observation state, and arrival phase. */ - readonly state: ActivityFeedSource - /** - * Start observing one activity's live output; reference-counted, so two - * viewers of the same activity share one stream. - * @param id - activity to observe. - * @returns stop function releasing this observer's reference. - */ - observe(id: ActivityId): () => void -} - -/** One reference-counted observation stream. */ -interface ObservationEntry { - refs: number - stopped: boolean - dispose: () => Promise -} - -/** Remote face the observation runner drives. */ -type ActivityStreamRemote = Pick & { readonly activity: ActivityRemote } - -/** Owns the bare activity snapshot and per-activity observation streams. */ -export class ClientActivityFeed extends Service implements IActivityFeed { - readonly state: ActivityFeedSource - private readonly entries = new Map() - - /** - * @param ctx - client root Context. - * @param remote - generated activity namespace plus the Gateway stream factory. - * @param model - shared client activity model. - */ - constructor( - ctx: Context, - private readonly remote: ActivityStreamRemote, - private readonly model: ClientActivityModel, - ) { - super(ctx, 'activityFeed') - this.state = model - ctx.effect(() => () => { - const open = [...this.entries.values()] - this.entries.clear() - for (const entry of open) { - entry.stopped = true - void entry.dispose() - } - }, 'activity-controller.client.observations') - } - - observe(id: ActivityId): () => void { - const key = String(id) - const existing = this.entries.get(key) - if (existing !== undefined && !existing.stopped) { - existing.refs += 1 - return this.releaser(key, existing) - } - const entry = this.startObservation(id) - this.entries.set(key, entry) - return this.releaser(key, entry) - } - - /** - * Release closures bind the exact entry they were minted for, never the - * map's current occupant: a later `observe()` on the same id may have - * replaced a stopped entry, and decrementing or disposing through the key - * alone would tear down that newer stream's references. - */ - private releaser(key: string, entry: ObservationEntry): () => void { - let released = false - return () => { - if (released) return - released = true - entry.refs -= 1 - if (entry.refs > 0) return - if (this.entries.get(key) === entry) this.entries.delete(key) - entry.stopped = true - void entry.dispose().then(() => { - // Clear the view only while no successor observation holds the key: - // a re-expand inside the dispose round-trip already re-anchored the - // model, and a stale clear would blank its panel for good. - if (this.entries.has(key)) return - // The key is the stringified branded id this releaser was minted for. - this.model.observeStopped(key as ActivityId) - }) - } - } - - private startObservation(id: ActivityId): ObservationEntry { - const name = `activity observation ${String(id)}` - const stream = this.remote.$stream({ - name, - open: (signal) => { - const from = this.model.cursorOf(id) - return this.remote.activity.observe( - { activityId: id, ...from !== undefined ? { from } : {} }, - signal, - ) - }, - // A premature end after the anchor is retryable (a Host reload closes the - // generation); resuming from the cursor loses nothing. An end before the - // anchor is terminal. - ended: accepted => accepted - ? new RemoteStreamCarrierError(`${name} ended before settlement`) - : new Error(`${name} ended before its anchor`), - }) - const entry: ObservationEntry = { - refs: 1, - stopped: false, - dispose: () => stream.dispose(), - } - void (async () => { - try { - for await (const item of stream) { - const frame = item.value - if (frame.type === 'opened') { - this.model.observeOpened(id, frame) - item.accept() - continue - } - if (frame.type === 'output') { - this.model.observeOutput(id, frame) - continue - } - // Terminal status: leave the loop before the generation end is - // classified, then close the stream for good. - this.model.observeStatus(id, frame) - break - } - } catch (error) { - if (!entry.stopped) this.model.observeFailed(id, error) - } finally { - entry.stopped = true - void entry.dispose() - } - })() - return entry - } -} diff --git a/packages/api/activity-controller/src/feed.ts b/packages/api/activity-controller/src/feed.ts deleted file mode 100644 index 34091dad8d..0000000000 --- a/packages/api/activity-controller/src/feed.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** Reconnect-safe activity roster producer over the optional `ctx.activities` registry. */ - -import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ActivityRegistry, ActivitySnapshot } from '@deepseek-ai/dsh-activity' -import type { ActivityControlFrame, ActivityRow } from './types.ts' - -/** - * Project one registry snapshot into its browser-safe roster row. - * @param snapshot - fresh registry snapshot. - * @returns detached wire row for Remote consumers. - */ -export function activityRow(snapshot: ActivitySnapshot): ActivityRow { - return { - id: snapshot.id, - kind: snapshot.kind, - label: snapshot.label, - ...snapshot.ownerSession !== undefined ? { sessionId: snapshot.ownerSession } : {}, - ...snapshot.correlation !== undefined - ? { - correlation: { - ...snapshot.correlation.callId !== undefined ? { callId: snapshot.correlation.callId } : {}, - ...snapshot.correlation.jobId !== undefined ? { jobId: snapshot.correlation.jobId } : {}, - }, - } - : {}, - status: snapshot.status, - ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, - startedAt: snapshot.startedAt, - ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, - outputTotal: snapshot.outputTotal, - } -} - -/** One owner bucket: the exact live owner used for fenced reads plus its rows. */ -interface OwnerBucket { - /** Exact registered Agent; undefined for the unowned bucket. */ - readonly owner: Agent | undefined - rows: readonly ActivityRow[] -} - -const UNOWNED = '' - -/** - * Owns registry observation, the owner-bucketed roster mirror, and all active - * control-stream generations. The mirror also answers `ownerOf`, which - * observation streams use to read owned activities through the visibility - * fence with the exact owner the registry announced. - */ -export class ActivityFeed { - private readonly followers = new Set() - private readonly buckets = new Map() - private readonly registry: ActivityRegistry | undefined - - /** @param ctx - Host context; the activity registry stays optional. */ - constructor(ctx: Context) { - this.registry = ctx.get('activities') - if (this.registry === undefined) return - const registry = this.registry - this.refresh(undefined) - for (const agent of ctx.get('agents')?.list() ?? []) this.refresh(agent) - registry.onActivitiesChanged((owner) => { - this.refresh(owner) - const bucket = this.buckets.get(owner?.id ?? UNOWNED) - this.publish({ - type: 'rows', - ...owner !== undefined ? { sessionId: owner.id } : {}, - activities: bucket?.rows ?? [], - }) - }) - ctx.effect(() => () => { - for (const follower of this.followers) follower.close() - this.followers.clear() - }, 'activity-controller.feed') - } - - /** - * Read the complete current roster synchronously. - * @returns every visible row across all owner buckets. - */ - baseline(): readonly ActivityRow[] { - return [...this.buckets.values()].flatMap(bucket => bucket.rows) - } - - /** - * Resolve the exact owner the registry announced for one activity, for - * fenced reads. `undefined` covers both unowned activities and ids this - * mirror has never seen — the registry itself settles unknown ids. - * @param id - activity id to resolve. - * @returns the exact live owner, or undefined. - */ - ownerOf(id: string): Agent | undefined { - for (const bucket of this.buckets.values()) { - if (bucket.rows.some(row => String(row.id) === id)) return bucket.owner - } - return undefined - } - - /** - * Open one generation beginning with a complete baseline. - * @param signal - generation cancellation. - * @returns baseline followed by whole-bucket replacement frames. - */ - async *follow(signal: AbortSignal): AsyncIterable { - signal.throwIfAborted() - const follower = new ActivityFollower() - this.followers.add(follower) - try { - yield { type: 'baseline', activities: this.baseline() } - yield* follower.read(signal) - } finally { - this.followers.delete(follower) - follower.close() - } - } - - /** Re-read one owner's visible rows into its bucket; an emptied bucket is dropped. */ - private refresh(owner: Agent | undefined): void { - /* v8 ignore next -- refresh is only reachable through a live registry's change feed. */ - if (this.registry === undefined) return - const rows = this.registry.list(owner) - .filter(snapshot => owner === undefined - ? snapshot.ownerSession === undefined - : snapshot.ownerSession === owner.id) - .map(activityRow) - const key = owner?.id ?? UNOWNED - if (rows.length === 0) { - this.buckets.delete(key) - return - } - this.buckets.set(key, { owner, rows }) - } - - private publish(frame: Exclude): void { - for (const follower of this.followers) follower.push(frame) - } -} - -/* jscpd:ignore-start -- mirrors WorkspaceFeed's follower fan-out by design; both serve baseline-plus-increment Remote streams. */ -class ActivityFollower { - private readonly frames: ActivityControlFrame[] = [] - private waiting: (() => void) | undefined - private closed = false - - push(frame: ActivityControlFrame): void { - /* v8 ignore next -- closed followers are removed before later publication can reach them. */ - if (this.closed) return - this.frames.push(frame) - this.waiting?.() - } - - close(): void { - if (this.closed) return - this.closed = true - this.waiting?.() - } - - async *read(signal: AbortSignal): AsyncIterable { - while (!this.closed && !signal.aborted) { - const frame = this.frames.shift() - if (frame !== undefined) { - yield frame - continue - } - await this.wait(signal) - } - } - - private wait(signal: AbortSignal): Promise { - return new Promise((resolve) => { - const finish = (): void => { - signal.removeEventListener('abort', finish) - /* v8 ignore next -- one read owns the sole installed wait callback. */ - if (this.waiting === finish) this.waiting = undefined - resolve() - } - this.waiting = finish - signal.addEventListener('abort', finish, { once: true }) - /* v8 ignore next -- native signals and the private queue cannot change during this synchronous setup. */ - if (signal.aborted || this.closed || this.frames.length > 0) finish() - }) - } -} -/* jscpd:ignore-end */ diff --git a/packages/api/activity-controller/src/index.ts b/packages/api/activity-controller/src/index.ts deleted file mode 100644 index a2d62c4a33..0000000000 --- a/packages/api/activity-controller/src/index.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Host Activity Remote owner: the live-activity roster stream and per-activity - * output observation. A pure read surface over the optional `ctx.activities` - * registry — it starts nothing, cancels nothing, and never touches the - * model-facing consuming cursors. Compositions without the registry serve an - * empty roster and refuse observation with a stable error. - */ - -import { Context } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' -import type {} from '@deepseek-ai/dsh-activity' -import { ActivityFeed } from './feed.ts' -import { observeActivity } from './observe.ts' -import type { ActivityControlFrame, ActivityObserveFrame, ActivityObserveRequest } from './types.ts' - -export type * from './types.ts' - -/** Default coalescing window between observation reads, in milliseconds. */ -const DEFAULT_FLUSH_MS = 100 - -/** Default soft byte budget per observation output frame. */ -const DEFAULT_MAX_FRAME_BYTES = 64 * 1024 - -/** Configuration for the Activity Remote owner. */ -export interface Config { - /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ - flushMs?: number - /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ - maxFrameBytes?: number -} - -declare module '@deepseek-ai/cordis' { - interface Context { - /** Host Activity read API and Remote namespace owner. */ - activityController: ActivityController - } -} - -/** Host service backing the generated `ctx.remote.activity` namespace. */ -export class ActivityController extends TypertRemoteService { - static inject = ['typert'] - - static Config: z = z.object({ - flushMs: z.number() - .step(1) - .min(1) - .max(Number.MAX_SAFE_INTEGER) - .default(DEFAULT_FLUSH_MS), - maxFrameBytes: z.number() - .step(1) - .min(1) - .max(Number.MAX_SAFE_INTEGER) - .default(DEFAULT_MAX_FRAME_BYTES), - }) - - private readonly feed: ActivityFeed - private readonly flushMs: number - private readonly maxFrameBytes: number - - /** - * @param ctx - Host context; the activity registry stays optional. - * @param config - validated coalescing and framing bounds. - */ - constructor(ctx: Context, config: Config) { - super(ctx, 'activityController', { namespace: 'activity' }) - this.flushMs = (config as Required).flushMs - this.maxFrameBytes = (config as Required).maxFrameBytes - this.feed = new ActivityFeed(ctx) - } - - /** - * Stream a complete roster baseline followed by whole-bucket replacements. - * @param signal - generation cancellation. - * @returns baseline followed by per-owner roster replacement frames. - */ - @Remote({ mode: 'stream' }) - control(signal: AbortSignal): AsyncIterable { - return this.feed.follow(signal) - } - - /** - * Stream one activity's retained output from an absolute byte offset, then - * its terminal status once settled and drained. Non-consuming: the - * model-facing cursors never observe these reads. - * @param request - target activity and optional resume offset. - * @param signal - generation cancellation. - * @returns anchor, coalesced output frames, and the terminal status. - */ - @Remote({ mode: 'stream' }) - observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable { - const registry = this.ctx.get('activities') - if (registry === undefined) { - throw new Error('activity observation unavailable: no activity registry is loaded (load @deepseek-ai/dsh-activity-local)') - } - return observeActivity(registry, request, { - flushMs: this.flushMs, - maxFrameBytes: this.maxFrameBytes, - ownerOf: id => this.feed.ownerOf(id), - }, signal) - } -} - -export default ActivityController diff --git a/packages/api/activity-controller/src/invariant.ts b/packages/api/activity-controller/src/invariant.ts deleted file mode 100644 index f85e7460b8..0000000000 --- a/packages/api/activity-controller/src/invariant.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-activity-controller/invariant */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-api-activity-controller' - -/** Cordis companion plugin name. */ -export const name = 'api-activity-controller-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: `@deepseek-ai/dsh-activity/invariant` owns snapshot relations; every roster - * generation is a full projection and observation reads are validated by the registry itself. - */ -const install: InvariantInstaller = () => {} - -/** Register this package's invariant companion. */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/api/activity-controller/src/observe.ts b/packages/api/activity-controller/src/observe.ts deleted file mode 100644 index f793b11496..0000000000 --- a/packages/api/activity-controller/src/observe.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** Per-activity observation generations: anchor, coalesced output, terminal status. */ - -import type { ActivityRegistry, ActivitySnapshot } from '@deepseek-ai/dsh-activity' -import { ActivityId } from '@deepseek-ai/dsh-activity/brand' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ActivityObserveFrame, ActivityObserveRequest, ActivityWireChunk } from './types.ts' - -/** Cadence and framing bounds for one observation generation. */ -export interface ObserveOptions { - /** Coalescing window after a wake before reading, in milliseconds. */ - readonly flushMs: number - /** - * Soft byte budget per `output` frame. Accumulated chunks flush once the - * budget is met; one chunk larger than the budget ships whole (the - * registry's retention cap is the hard bound on any single chunk). - */ - readonly maxFrameBytes: number - /** Resolve the exact owner for fenced reads (the roster mirror's `ownerOf`). */ - readonly ownerOf: (id: string) => Agent | undefined -} - -function isTerminal(status: ActivitySnapshot['status']): boolean { - return status !== 'running' -} - -/** Wake-flag waiter: a wake between waits is never lost. */ -class OutputWaiter { - private dirty = false - private resolve: (() => void) | undefined - - wake(): void { - this.dirty = true - this.resolve?.() - } - - /** - * Resolve on the next wake, immediately when one already arrived, or on abort. - * @param signal - generation cancellation. - */ - wait(signal: AbortSignal): Promise { - if (this.dirty || signal.aborted) { - this.dirty = false - return Promise.resolve() - } - return new Promise((resolve) => { - const finish = (): void => { - signal.removeEventListener('abort', finish) - /* v8 ignore next -- one wait owns the sole installed resolver. */ - if (this.resolve === finish) this.resolve = undefined - this.dirty = false - resolve() - } - this.resolve = finish - signal.addEventListener('abort', finish, { once: true }) - }) - } -} - -function sleep(ms: number, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.resolve() - return new Promise((resolve) => { - const timer = setTimeout(done, ms) - function done(): void { - clearTimeout(timer) - signal.removeEventListener('abort', done) - resolve() - } - signal.addEventListener('abort', done, { once: true }) - }) -} - -/** - * Stream one activity's retained output from an absolute offset: one `opened` - * anchor, coalesced `output` frames as the stream advances, then one terminal - * `status` after the settled activity is drained, after which the generation - * closes normally. Reads are non-consuming; reconnecting callers resume by - * passing the last frame's `next` as `from`. - * @param registry - the live activity registry. - * @param request - target activity and optional resume offset. - * @param options - cadence, framing bounds, and owner resolution. - * @param signal - generation cancellation owned by the Remote stream carrier. - * @returns the observation frame sequence for one generation. - */ -export async function* observeActivity( - registry: ActivityRegistry, - request: ActivityObserveRequest, - options: ObserveOptions, - signal: AbortSignal, -): AsyncIterable { - if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) { - throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`) - } - signal.throwIfAborted() - const id = ActivityId(String(request.activityId)) - const waiter = new OutputWaiter() - // Subscribe before the first read so an append between the anchor read and - // the wait cannot be missed. - const unsubscribe = registry.onOutput((changed) => { - if (changed === id) waiter.wake() - }) - try { - const caller = options.ownerOf(String(id)) - let snapshot = registry.get(id, caller) - let cursor = request.from ?? snapshot.outputEarliest - yield { - type: 'opened', - activityId: id, - from: cursor, - earliest: snapshot.outputEarliest, - total: snapshot.outputTotal, - status: snapshot.status, - ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, - } - while (!signal.aborted) { - const read = registry.read(id, cursor, caller) - if (read.chunks.length > 0) { - yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes) - } - cursor = read.next - snapshot = registry.get(id, caller) - if (isTerminal(snapshot.status) && cursor >= snapshot.outputTotal) { - yield { - type: 'status', - status: snapshot.status, - ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, - // Settlement always stamps finishedAt; the guard only discharges the optional field type. - /* v8 ignore next */ - ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, - } - return - } - await waiter.wait(signal) - // Let a burst accumulate so producer chatter becomes bounded frames. - await sleep(options.flushMs, signal) - } - } finally { - unsubscribe() - } -} - -/** Split one read into frames along the soft per-frame byte budget. */ -function* outputFrames( - chunks: readonly ActivityWireChunk[], - next: number, - lossy: boolean, - maxFrameBytes: number, -): Iterable { - let batch: ActivityWireChunk[] = [] - let batchBytes = 0 - let flaggedLossy = lossy - for (const chunk of chunks) { - batch.push(chunk) - batchBytes += Buffer.byteLength(chunk.text, 'utf8') - if (batchBytes >= maxFrameBytes) { - const last = batch[batch.length - 1] - /* v8 ignore start -- a non-empty batch always has a last chunk; the arm only discharges noUncheckedIndexedAccess. */ - const end = last === undefined ? next : last.at + Buffer.byteLength(last.text, 'utf8') - /* v8 ignore stop */ - yield { - type: 'output', - chunks: batch, - next: end, - ...flaggedLossy ? { lossy: true as const } : {}, - } - flaggedLossy = false - batch = [] - batchBytes = 0 - } - } - if (batch.length > 0 || flaggedLossy) { - yield { - type: 'output', - chunks: batch, - next, - ...flaggedLossy ? { lossy: true as const } : {}, - } - } -} diff --git a/packages/api/activity-controller/src/types.ts b/packages/api/activity-controller/src/types.ts deleted file mode 100644 index bc2e39808d..0000000000 --- a/packages/api/activity-controller/src/types.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** Browser-safe roster and observation vocabulary for the Activity Remote. */ - -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' -import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' - -export type { ActivityId } from '@deepseek-ai/dsh-activity/brand' - -/** Lifecycle state carried on the wire; mirrors the registry's status union. */ -export type ActivityRowStatus = 'running' | 'completed' | 'failed' | 'killed' - -/** Links one roster row to the tool call and background job for the same work. */ -export interface ActivityRowCorrelation { - /** Tool call that started the work, when a tool produced it. */ - readonly callId?: ToolCallId - /** Background job carrying the model-facing control surface, when one exists. */ - readonly jobId?: JobId -} - -/** - * Browser-safe activity row. `kind` is `string` on the wire because the kind - * map is merge-extensible by producer plugins; presentation falls through a - * documented default for an unrecognized kind. - */ -export interface ActivityRow { - readonly id: ActivityId - readonly kind: string - readonly label: string - /** Owning session for grouping; absent for an unowned activity, visible to every session. */ - readonly sessionId?: SessionId - readonly correlation?: ActivityRowCorrelation - readonly status: ActivityRowStatus - readonly detail?: string - readonly startedAt: number - readonly finishedAt?: number - /** Total UTF-8 bytes the activity has produced so far. */ - readonly outputTotal: number -} - -/** - * Roster stream frames. Each generation starts with exactly one complete - * baseline; later frames replace one owner bucket wholesale (absent - * `sessionId` = the unowned bucket), so start, settlement, removal, and a - * second browser tab all converge through one authoritative value. - */ -export type ActivityControlFrame = - | { readonly type: 'baseline'; readonly activities: readonly ActivityRow[] } - | { readonly type: 'rows'; readonly sessionId?: SessionId; readonly activities: readonly ActivityRow[] } - -/** Opens one observation stream over a single activity's retained output. */ -export interface ActivityObserveRequest { - readonly activityId: ActivityId - /** - * Absolute byte offset to resume from — a previous frame's `next`. Omitted - * starts at the oldest retained byte. - */ - readonly from?: number -} - -/** One retained output chunk on the wire. `channel` widens to string like `kind`. */ -export interface ActivityWireChunk { - /** Absolute offset of the chunk's first byte. */ - readonly at: number - readonly text: string - readonly channel?: string - /** Bytes immediately before this chunk were lost at the producer or to retention. */ - readonly gapBefore?: true -} - -/** - * Observation stream frames: one `opened` anchor, then coalesced `output` - * batches, then — once the activity has settled and its retained output is - * drained — one terminal `status`, after which the stream closes normally. - * Status rides the same stream as output so settlement can never race a - * still-open output channel. - */ -export type ActivityObserveFrame = - | { - readonly type: 'opened' - readonly activityId: ActivityId - /** Offset the first `output` frame continues from. */ - readonly from: number - /** Oldest retained byte at open time; `from < earliest` means the head is gone. */ - readonly earliest: number - /** Total bytes produced at open time. */ - readonly total: number - readonly status: ActivityRowStatus - readonly detail?: string - } - | { - readonly type: 'output' - readonly chunks: readonly ActivityWireChunk[] - /** Offset to resume from after this frame. */ - readonly next: number - /** Bytes between the requested offset and `chunks` were already evicted. */ - readonly lossy?: true - } - | { - readonly type: 'status' - readonly status: ActivityRowStatus - readonly detail?: string - readonly finishedAt?: number - } diff --git a/packages/api/activity-controller/tests/activity-controller.host.spec.ts b/packages/api/activity-controller/tests/activity-controller.host.spec.ts deleted file mode 100644 index 757a0af115..0000000000 --- a/packages/api/activity-controller/tests/activity-controller.host.spec.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { Session, SessionId } from '@deepseek-ai/dsh-session' -import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' -import { ActivityId } from '@deepseek-ai/dsh-activity' -import { ActivityFeed } from '../src/feed.ts' -import { observeActivity } from '../src/observe.ts' -import type { ObserveOptions } from '../src/observe.ts' -import type { ActivityControlFrame, ActivityObserveFrame } from '../src/types.ts' - -async function harness(withRegistry = true) { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - if (withRegistry) await ctx.plugin(LocalActivityRegistry, { retainBytes: 64, settledRetainBytes: 64 }) - return ctx -} - -function registerAgent(ctx: Context, rawId: string): Agent { - const scopeFiber = ctx.plugin(() => {}) - const id = SessionId(rawId) - const session = Session.create(id) - const agent = { - id, - session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), - status: 'idle', - ctx: scopeFiber.ctx, - } as unknown as Agent - ctx.agents.register(agent) - return agent -} - -/** Read frames until `count` arrive or the generator finishes, then abort. */ -async function collect( - iterable: AsyncIterable, - count: number, - abort: AbortController, -): Promise { - const frames: Frame[] = [] - const iterator = iterable[Symbol.asyncIterator]() - try { - while (frames.length < count) { - const step = await iterator.next() - if (step.done) return frames - frames.push(step.value) - } - } finally { - abort.abort() - await iterator.next().catch(() => undefined) - } - return frames -} - -const OBSERVE: Omit = { flushMs: 5, maxFrameBytes: 1024 } - -describe('ActivityFeed roster', () => { - it('serves an empty baseline without the registry', async () => { - const ctx = await harness(false) - const feed = new ActivityFeed(ctx) - expect(feed.baseline()).toEqual([]) - }) - - it('projects owned and unowned rows into the baseline with correlation and no live objects', async () => { - const ctx = await harness() - const owner = registerAgent(ctx, 'alice') - ctx.activities.open({ kind: 'bash', label: 'unowned build' }) - ctx.activities.open({ - kind: 'bash', - label: 'owned build', - owner, - correlation: { jobId: 'bash-7' as never }, - }) - const feed = new ActivityFeed(ctx) - const rows = feed.baseline() - expect(rows).toHaveLength(2) - const owned = rows.find(row => row.sessionId !== undefined) - expect(owned?.sessionId).toBe('alice') - expect(owned?.correlation).toEqual({ jobId: 'bash-7' }) - expect(owned?.status).toBe('running') - for (const row of rows) { - expect(Object.keys(row)).not.toContain('owner') - expect(row.outputTotal).toBe(0) - } - }) - - it('pushes whole-bucket frames for open, detail, settlement, and removal', async () => { - const ctx = await harness() - const owner = registerAgent(ctx, 'alice') - const feed = new ActivityFeed(ctx) - const abort = new AbortController() - const frames = collect(feed.follow(abort.signal), 4, abort) - await new Promise(resolve => setTimeout(resolve, 0)) - const handle = ctx.activities.open({ kind: 'bash', label: 'work', owner }) - handle.updateDetail('halfway') - handle.end({ status: 'completed', detail: 'exit code: 0' }) - const seen = await frames - expect(seen[0]).toEqual({ type: 'baseline', activities: [] }) - const rows = seen.slice(1) as Extract[] - expect(rows.every(frame => frame.sessionId === 'alice')).toBe(true) - expect(rows[0]?.activities[0]?.status).toBe('running') - expect(rows[1]?.activities[0]?.detail).toBe('halfway') - expect(rows[2]?.activities[0]?.status).toBe('completed') - expect(feed.ownerOf(String(rows[0]!.activities[0]!.id))).toBe(owner) - }) - - it('projects callId-only correlation and serves rosters without an agent registry', async () => { - const bare = new Context() - await bare.plugin(LocalActivityRegistry) - bare.activities.open({ kind: 'bash', label: 'lone', correlation: { callId: 'call-1' as never } }) - const feed = new ActivityFeed(bare) - expect(feed.baseline()[0]?.correlation).toEqual({ callId: 'call-1' }) - }) - - it('pushes an empty bucket frame when owner disposal removes the last row', async () => { - const ctx = await harness() - const owner = registerAgent(ctx, 'alice') - const feed = new ActivityFeed(ctx) - ctx.activities.open({ kind: 'bash', label: 'doomed', owner }) - const abort = new AbortController() - const frames = collect(feed.follow(abort.signal), 3, abort) - await new Promise(resolve => setTimeout(resolve, 0)) - await (owner.ctx as unknown as { fiber: { dispose(): Promise } }).fiber?.dispose?.() - // Owner-disposal removal reaches the feed as an emptied bucket. - await new Promise(resolve => setTimeout(resolve, 10)) - abort.abort() - const seen = await frames - const last = seen.at(-1) as Extract | undefined - if (last !== undefined && last.type === 'rows') expect(Array.isArray(last.activities)).toBe(true) - }) - - it('resolves undefined for unowned and unknown activities', async () => { - const ctx = await harness() - const feed = new ActivityFeed(ctx) - const handle = ctx.activities.open({ kind: 'bash', label: 'shared' }) - expect(feed.ownerOf(String(handle.id))).toBeUndefined() - expect(feed.ownerOf('bash-999')).toBeUndefined() - }) -}) - -describe('observeActivity', () => { - async function observed(ctx: Context, feed: ActivityFeed, request: { activityId: ActivityId; from?: number }) { - const abort = new AbortController() - const frames: ActivityObserveFrame[] = [] - const stream = observeActivity( - ctx.activities, - request, - { ...OBSERVE, ownerOf: id => feed.ownerOf(id) }, - abort.signal, - ) - const done = (async () => { - for await (const frame of stream) frames.push(frame) - })() - return { abort, frames, done } - } - - it('anchors, streams coalesced output, and closes with the terminal status', async () => { - const ctx = await harness() - const feed = new ActivityFeed(ctx) - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - handle.append('early ') - const { frames, done } = await observed(ctx, feed, { activityId: handle.id }) - await new Promise(resolve => setTimeout(resolve, 20)) - handle.append('live', { channel: 'stderr' }) - await new Promise(resolve => setTimeout(resolve, 20)) - handle.end({ status: 'completed', detail: 'exit code: 0' }) - await done - - expect(frames[0]).toMatchObject({ type: 'opened', from: 0, earliest: 0, total: 6, status: 'running' }) - const outputs = frames.filter(frame => frame.type === 'output') - const text = outputs.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') - expect(text).toBe('early live') - expect(outputs.at(-1)?.next).toBe(10) - expect(outputs.flatMap(frame => frame.chunks).find(chunk => chunk.text === 'live')?.channel).toBe('stderr') - expect(frames.at(-1)).toMatchObject({ type: 'status', status: 'completed', detail: 'exit code: 0' }) - }) - - it('resumes from an explicit offset and flags an evicted resume lossy', async () => { - const ctx = await harness() - const feed = new ActivityFeed(ctx) - const handle = ctx.activities.open({ kind: 'bash', label: 'spam' }) - handle.append('a'.repeat(60)) - handle.append('b'.repeat(60)) - handle.end({ status: 'completed' }) - - const fresh = await observed(ctx, feed, { activityId: handle.id, from: 0 }) - await fresh.done - const output = fresh.frames.find(frame => frame.type === 'output') - expect(output?.lossy).toBe(true) - expect(output?.chunks.map(chunk => chunk.text).join('')).toBe('b'.repeat(60)) - - const resumed = await observed(ctx, feed, { activityId: handle.id, from: 120 }) - await resumed.done - expect(resumed.frames.some(frame => frame.type === 'output')).toBe(false) - expect(resumed.frames.at(-1)?.type).toBe('status') - }) - - it('splits oversized reads along the frame budget', async () => { - const ctx = await harness() - const feed = new ActivityFeed(ctx) - const registry = ctx.activities - const handle = registry.open({ kind: 'bash', label: 'wide' }) - const abort = new AbortController() - handle.append('x'.repeat(30)) - handle.append('y'.repeat(30)) - handle.end({ status: 'completed' }) - const frames = await collect( - observeActivity(registry, { activityId: handle.id }, { - flushMs: 5, - maxFrameBytes: 30, - ownerOf: id => feed.ownerOf(id), - }, abort.signal), - 5, - abort, - ) - const outputs = frames.filter(frame => frame.type === 'output') - expect(outputs).toHaveLength(2) - expect(outputs[0]?.next).toBe(30) - expect(outputs[1]?.next).toBe(60) - }) - - it('carries the lossy flag on the first frame of a split evicted read', async () => { - const ctx = await harness() - const feed = new ActivityFeed(ctx) - const registry = ctx.activities - const handle = registry.open({ kind: 'bash', label: 'evicted split' }) - handle.append('a'.repeat(40)) - handle.append('b'.repeat(40)) - handle.append('c'.repeat(40)) - handle.end({ status: 'completed' }) - const abort = new AbortController() - const frames = await collect( - observeActivity(registry, { activityId: handle.id, from: 0 }, { - flushMs: 5, - maxFrameBytes: 40, - ownerOf: id => feed.ownerOf(id), - }, abort.signal), - 5, - abort, - ) - const outputs = frames.filter(frame => frame.type === 'output') - expect(outputs[0]?.lossy).toBe(true) - expect(outputs[1]?.lossy).toBeUndefined() - }) - - it('reads owned activities through the roster owner and rejects bad offsets and unknown ids', async () => { - const ctx = await harness() - const owner = registerAgent(ctx, 'alice') - const feed = new ActivityFeed(ctx) - const handle = ctx.activities.open({ kind: 'bash', label: 'secret', owner }) - handle.append('classified') - handle.end({ status: 'completed' }) - const { frames, done } = await observed(ctx, feed, { activityId: handle.id }) - await done - expect(frames.some(frame => frame.type === 'output' - && frame.chunks.some(chunk => chunk.text === 'classified'))).toBe(true) - - const abort = new AbortController() - await expect(collect( - observeActivity(ctx.activities, { activityId: handle.id, from: -1 }, { - ...OBSERVE, - ownerOf: id => feed.ownerOf(id), - }, abort.signal), - 1, - abort, - )).rejects.toThrow(/invalid observe offset/) - - const abortUnknown = new AbortController() - await expect(collect( - observeActivity(ctx.activities, { activityId: ActivityId('bash-999') }, { - ...OBSERVE, - ownerOf: id => feed.ownerOf(id), - }, abortUnknown.signal), - 1, - abortUnknown, - )).rejects.toThrow(/unknown activity/) - }) - - it('ignores output signals for other activities while waiting', async () => { - const ctx = await harness() - const feed = new ActivityFeed(ctx) - const watched = ctx.activities.open({ kind: 'bash', label: 'watched' }) - const noisy = ctx.activities.open({ kind: 'bash', label: 'noisy' }) - const { abort, frames, done } = await observed(ctx, feed, { activityId: watched.id }) - await new Promise(resolve => setTimeout(resolve, 15)) - noisy.append('unrelated') - await new Promise(resolve => setTimeout(resolve, 15)) - watched.end({ status: 'completed' }) - await done - abort.abort() - expect(frames.filter(frame => frame.type === 'output')).toEqual([]) - }) - - it('stops cleanly on abort while waiting for output', async () => { - const ctx = await harness() - const feed = new ActivityFeed(ctx) - const handle = ctx.activities.open({ kind: 'bash', label: 'idle' }) - const { abort, frames, done } = await observed(ctx, feed, { activityId: handle.id }) - await new Promise(resolve => setTimeout(resolve, 20)) - abort.abort() - await done - expect(frames[0]?.type).toBe('opened') - expect(frames.at(-1)?.type).not.toBe('status') - }) -}) diff --git a/packages/api/activity-controller/tests/client-apply.client.spec.ts b/packages/api/activity-controller/tests/client-apply.client.spec.ts deleted file mode 100644 index 231703ba13..0000000000 --- a/packages/api/activity-controller/tests/client-apply.client.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' -import { apply, createActivityRosterStream } from '../src/client/index.ts' -import { ClientActivityModel } from '../src/client/model.ts' -import type { ActivityControlFrame, ActivityId, ActivityRow } from '../src/types.ts' - -function row(): ActivityRow { - return { - id: 'bash-1' as ActivityId, - kind: 'bash', - label: 'build', - status: 'running', - startedAt: 1, - outputTotal: 0, - } -} - -/** One scripted roster stream: frames pushed by the test, never reopened. */ -class FakeStream { - disposed = false - private readonly frames: ActivityControlFrame[] = [] - private waiter: (() => void) | undefined - private failure: Error | undefined - - push(frame: ActivityControlFrame): void { - this.frames.push(frame) - this.waiter?.() - } - - poison(error: Error): void { - this.failure = error - this.waiter?.() - } - - dispose(): Promise { - this.disposed = true - this.waiter?.() - return Promise.resolve() - } - - async *[Symbol.asyncIterator]() { - while (!this.disposed) { - if (this.failure !== undefined) throw this.failure - const frame = this.frames.shift() - if (frame === undefined) { - await new Promise((resolve) => { this.waiter = resolve }) - continue - } - yield { generation: 1, value: frame, signal: new AbortController().signal, accept: () => {} } - } - } -} - -const tick = () => new Promise(resolve => setTimeout(resolve, 0)) - -describe('activity-controller client plugin', () => { - it('installs ctx.activityFeed and folds roster frames from the control stream', async () => { - const ctx = new Context() - const streams: { options: { open: (signal: AbortSignal) => unknown; ended: (accepted: boolean) => Error }; stream: FakeStream }[] = [] - const controlCalls: AbortSignal[] = [] - ctx.provide('remote', { - $stream: (options: never) => { - const stream = new FakeStream() - streams.push({ options, stream }) - return stream - }, - activity: { - control: (signal: AbortSignal) => { - controlCalls.push(signal) - return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } - }, - observe: () => { throw new Error('not exercised') }, - }, - } as never) - const fiber = ctx.plugin({ inject: ['remote'], apply }) - await fiber - - streams[0]!.stream.push({ type: 'baseline', activities: [row()] }) - streams[0]!.stream.push({ type: 'rows', activities: [] }) - await tick() - const snapshot = ctx.activityFeed.state.getSnapshot() - expect(snapshot.phase).toBe('ready') - expect(snapshot.rowsBySession['']).toBeUndefined() - - // The generation opener and end classification belong to the domain. - streams[0]!.options.open(new AbortController().signal) - expect(controlCalls).toHaveLength(1) - expect(streams[0]!.options.ended(true)).toBeInstanceOf(RemoteStreamCarrierError) - expect(streams[0]!.options.ended(false)).toBeInstanceOf(Error) - - // A terminal roster failure keeps the last snapshot in place. - streams[0]!.stream.poison(new Error('roster stream broke')) - await tick() - expect(ctx.activityFeed.state.getSnapshot().phase).toBe('ready') - - await fiber.dispose() - expect(streams[0]!.stream.disposed).toBe(true) - }) - - it('keeps the last roster on a terminal stream failure', async () => { - const model = new ClientActivityModel() - const remote = { - $stream: () => new FakeStream(), - activity: { control: () => { throw new Error('unused') }, observe: () => { throw new Error('unused') } }, - } - const stream = createActivityRosterStream(remote as never, model) - model.replaceBaseline([row()]) - stream.start() - await stream.dispose() - expect(model.getSnapshot().rowsBySession['']?.[0]?.label).toBe('build') - }) -}) diff --git a/packages/api/activity-controller/tests/client-model.client.spec.ts b/packages/api/activity-controller/tests/client-model.client.spec.ts deleted file mode 100644 index 31f5d18c33..0000000000 --- a/packages/api/activity-controller/tests/client-model.client.spec.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { ClientActivityModel } from '../src/client/model.ts' -import type { ActivityId, ActivityRow } from '../src/types.ts' - -function row(over: Partial = {}): ActivityRow { - return { - id: 'bash-1' as ActivityId, - kind: 'bash', - label: 'build', - sessionId: 'alice' as ActivityRow['sessionId'], - status: 'running', - startedAt: 1, - outputTotal: 0, - ...over, - } as ActivityRow -} - -/** A row with no owner session, as the unowned bucket carries them. */ -function unownedRow(over: Partial = {}): ActivityRow { - const { sessionId: _drop, ...rest } = row(over) - return rest -} - -const ID = 'bash-1' as ActivityId - -function opened(model: ClientActivityModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void { - model.observeOpened(ID, { - type: 'opened', - activityId: ID, - from: 0, - earliest: 0, - total: 0, - status: 'running', - ...over, - }) -} - -describe('ClientActivityModel roster', () => { - it('folds baselines into per-session buckets and reports readiness', () => { - const model = new ClientActivityModel() - expect(model.getSnapshot().phase).toBe('pending') - model.replaceBaseline([row(), unownedRow({ id: 'workflow-1' as ActivityId })]) - const snapshot = model.getSnapshot() - expect(snapshot.phase).toBe('ready') - expect(snapshot.rowsBySession['alice']).toHaveLength(1) - expect(snapshot.rowsBySession['']).toHaveLength(1) - }) - - it('replaces buckets wholesale and drops emptied keys', () => { - const model = new ClientActivityModel() - model.replaceBaseline([row()]) - model.replaceBucket('alice', [row({ status: 'completed', finishedAt: 2 })]) - expect(model.getSnapshot().rowsBySession['alice']?.[0]?.status).toBe('completed') - model.replaceBucket('alice', []) - expect('alice' in model.getSnapshot().rowsBySession).toBe(false) - model.replaceBucket(undefined, [unownedRow()]) - expect(model.getSnapshot().rowsBySession['']).toHaveLength(1) - }) - - it('keeps snapshot identity stable between changes and notifies subscribers', () => { - const model = new ClientActivityModel() - let notified = 0 - const unsubscribe = model.subscribe(() => { notified += 1 }) - const before = model.getSnapshot() - expect(model.getSnapshot()).toBe(before) - model.replaceBaseline([row()]) - expect(notified).toBe(1) - expect(model.getSnapshot()).not.toBe(before) - unsubscribe() - model.replaceBucket('alice', []) - expect(notified).toBe(1) - }) -}) - -describe('ClientActivityModel observation', () => { - it('accumulates output, advances the resume cursor, and settles on status', () => { - const model = new ClientActivityModel() - opened(model) - expect(model.cursorOf(ID)).toBe(0) - model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'a' }, { at: 1, text: 'b' }], next: 2 }) - model.observeOutput(ID, { type: 'output', chunks: [{ at: 2, text: 'c' }], next: 3 }) - expect(model.cursorOf(ID)).toBe(3) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.text).toBe('abc') - expect(view?.streaming).toBe(true) - model.observeStatus(ID, { type: 'status', status: 'completed', detail: 'exit code: 0' }) - const settled = model.getSnapshot().observed[String(ID)] - expect(settled?.streaming).toBe(false) - expect(settled?.status).toBe('completed') - expect(settled?.detail).toBe('exit code: 0') - }) - - it('marks gaps from lossy frames, gap chunks, and a resume behind the retained head', () => { - const model = new ClientActivityModel() - opened(model, { from: 4, earliest: 8, total: 10 }) - expect(model.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - - const clean = new ClientActivityModel() - opened(clean) - clean.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'x', gapBefore: true }], next: 1 }) - expect(clean.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - - const lossy = new ClientActivityModel() - opened(lossy) - lossy.observeOutput(ID, { type: 'output', chunks: [], next: 5, lossy: true }) - expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - }) - - it('marks a fresh observation anchored past the evicted head', () => { - // Fresh observations anchor at the registry's earliest retained byte, so - // from === earliest > 0 means the head was already discarded. - const fresh = new ClientActivityModel() - opened(fresh, { from: 60_240, earliest: 60_240, total: 321_328 }) - expect(fresh.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - - // A retry that accumulated no text yet earns the mark the same way. - const retried = new ClientActivityModel() - opened(retried) - expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(false) - opened(retried, { from: 6, earliest: 6, total: 6 }) - expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - }) - - it('bounds the render tail without splitting a surrogate pair', () => { - const model = new ClientActivityModel() - opened(model) - const emoji = '😀'.repeat((64 * 1024) + 8) - model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: emoji }], next: emoji.length * 2 }) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.gapBefore).toBe(true) - expect(view!.text.length).toBeLessThanOrEqual(128 * 1024) - // The bound landed between pairs: the surviving text still round-trips. - expect(/^(?:😀)+$/u.test(view!.text)).toBe(true) - }) - - it('carries the anchor detail and trims a plain-ASCII tail without a boundary shift', () => { - const model = new ClientActivityModel() - model.observeOpened(ID, { - type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running', detail: 'exit soon', - }) - expect(model.getSnapshot().observed[String(ID)]?.detail).toBe('exit soon') - const long = 'x'.repeat((128 * 1024) + 5) - model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: long }], next: long.length }) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.text.length).toBe(128 * 1024) - expect(view?.gapBefore).toBe(true) - }) - - it('advances the cut past a low surrogate landing exactly on the bound', () => { - const model = new ClientActivityModel() - opened(model) - // 'z' + one emoji + odd ASCII tail puts a low surrogate exactly at the cut index. - const text = 'z😀' + 'a'.repeat((128 * 1024) - 1) - model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text }], next: text.length }) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.text.length).toBe((128 * 1024) - 1) - expect(view?.text.startsWith('a')).toBe(true) - }) - - it('preserves accumulated text across a reconnect anchor and clears on stop', () => { - const model = new ClientActivityModel() - opened(model) - model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'kept' }], next: 4 }) - opened(model, { from: 4, earliest: 0, total: 4 }) - expect(model.getSnapshot().observed[String(ID)]?.text).toBe('kept') - model.observeStopped(ID) - expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() - expect(model.cursorOf(ID)).toBeUndefined() - // A second stop is inert. - model.observeStopped(ID) - }) - - it('records a terminal stream failure on the live view', () => { - const model = new ClientActivityModel() - opened(model) - model.observeFailed(ID, new Error('carrier gone')) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.streaming).toBe(false) - expect(view?.error).toContain('carrier gone') - // A failure for an untracked id is inert. - model.observeFailed('bash-9' as ActivityId, new Error('ignored')) - }) -}) diff --git a/packages/api/activity-controller/tests/client-service.client.spec.ts b/packages/api/activity-controller/tests/client-service.client.spec.ts deleted file mode 100644 index ba051d2e23..0000000000 --- a/packages/api/activity-controller/tests/client-service.client.spec.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import { ClientActivityModel } from '../src/client/model.ts' -import { ClientActivityFeed } from '../src/client/service.ts' -import type { ActivityId, ActivityObserveFrame } from '../src/types.ts' - -const ID = 'bash-1' as ActivityId - -/** One scripted logical stream: frames are pushed by the test, never reopened. */ -class FakeStream { - disposed = false - /** Model a carrier whose disposal surfaces as an iterator throw. */ - throwOnDispose = false - /** Hold the dispose promise open until {@link releaseDispose}. */ - deferDispose = false - private disposeRelease: (() => void) | undefined - private disposePending: Promise | undefined - private readonly frames: ActivityObserveFrame[] = [] - private waiter: (() => void) | undefined - private failure: Error | undefined - readonly accepts: number[] = [] - - push(frame: ActivityObserveFrame): void { - this.frames.push(frame) - this.waiter?.() - } - - poison(error: Error): void { - this.failure = error - this.waiter?.() - } - - dispose(): Promise { - if (this.throwOnDispose && this.failure === undefined) { - this.failure = new Error('carrier tore down') - } else { - this.disposed = true - } - this.waiter?.() - if (this.deferDispose) { - // Repeat disposals (releaser plus the consumer's finally) share one - // deferred promise, so releaseDispose resumes every waiter. - this.disposePending ??= new Promise((resolve) => { this.disposeRelease = resolve }) - return this.disposePending - } - return Promise.resolve() - } - - releaseDispose(): void { - this.disposeRelease?.() - } - - async *[Symbol.asyncIterator]() { - let generation = 1 - while (!this.disposed) { - if (this.failure !== undefined) throw this.failure - const frame = this.frames.shift() - if (frame === undefined) { - await new Promise((resolve) => { this.waiter = resolve }) - continue - } - yield { - generation, - value: frame, - signal: new AbortController().signal, - accept: () => { this.accepts.push(generation) }, - } - generation = 1 - } - } -} - -function bench() { - const ctx = new Context() - const model = new ClientActivityModel() - const streams: { options: { open: (signal: AbortSignal) => unknown }; stream: FakeStream }[] = [] - const observeCalls: unknown[] = [] - const remote = { - $stream: (options: { open: (signal: AbortSignal) => unknown }) => { - const stream = new FakeStream() - streams.push({ options, stream }) - return stream - }, - activity: { - observe: (request: unknown) => { - observeCalls.push(request) - return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } - }, - control: () => { throw new Error('not exercised') }, - }, - } - const feed = new ClientActivityFeed(ctx, remote as never, model) - return { ctx, model, feed, streams, observeCalls } -} - -const tick = () => new Promise(resolve => setTimeout(resolve, 0)) - -describe('ClientActivityFeed observation streams', () => { - it('feeds anchor, output, and terminal frames into the model, then closes', async () => { - const { model, feed, streams } = bench() - feed.observe(ID) - const { stream } = streams[0]! - stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - stream.push({ type: 'output', chunks: [{ at: 0, text: 'hi' }], next: 2 }) - await tick() - expect(model.getSnapshot().observed[String(ID)]?.text).toBe('hi') - expect(stream.accepts).toHaveLength(1) - - stream.push({ type: 'status', status: 'completed' }) - await tick() - expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) - expect(stream.disposed).toBe(true) - }) - - it('shares one stream across observers and stops after the last release', async () => { - const { model, feed, streams } = bench() - const first = feed.observe(ID) - const second = feed.observe(ID) - expect(streams).toHaveLength(1) - streams[0]!.stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - - first() - first() - expect(streams[0]!.stream.disposed).toBe(false) - second() - await tick() - expect(streams[0]!.stream.disposed).toBe(true) - expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() - }) - - it('a late release of a superseded observation neither tears down nor clears its successor', async () => { - const { model, feed, streams } = bench() - const firstStop = feed.observe(ID) - const first = streams[0]!.stream - first.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - first.push({ type: 'status', status: 'completed' }) - await tick() - // A successor observation replaces the settled entry while the first - // observer still holds its releaser. - const secondStop = feed.observe(ID) - const second = streams[1]!.stream - second.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - - firstStop() - await tick() - expect(second.disposed).toBe(false) - expect(model.getSnapshot().observed[String(ID)]).toBeDefined() - - secondStop() - await tick() - expect(second.disposed).toBe(true) - expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() - }) - - it('a re-observation inside the dispose round-trip keeps its fresh view', async () => { - const { model, feed, streams } = bench() - const stop = feed.observe(ID) - const first = streams[0]!.stream - first.deferDispose = true - first.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - - stop() - // Re-expand while the previous generation's dispose is still in flight. - feed.observe(ID) - const second = streams[1]!.stream - second.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - - first.releaseDispose() - await tick() - // The stale post-dispose clear must not blank the successor's view. - expect(model.getSnapshot().observed[String(ID)]).toBeDefined() - second.push({ type: 'output', chunks: [{ at: 0, text: 'alive' }], next: 5 }) - await tick() - expect(model.getSnapshot().observed[String(ID)]?.text).toBe('alive') - }) - - it('opens a fresh stream for a re-observed activity and resumes from the model cursor', async () => { - const { feed, streams, observeCalls, model } = bench() - const stop = feed.observe(ID) - streams[0]!.stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - streams[0]!.stream.push({ type: 'output', chunks: [{ at: 0, text: 'abc' }], next: 3 }) - await tick() - // The generation opener reads the live cursor at open time. - streams[0]!.options.open(new AbortController().signal) - expect(observeCalls.at(-1)).toEqual({ activityId: ID, from: 3 }) - - stop() - await tick() - feed.observe(ID) - expect(streams).toHaveLength(2) - streams[1]!.options.open(new AbortController().signal) - // Stopped observation dropped the cursor: the fresh stream starts unanchored. - expect(observeCalls.at(-1)).toEqual({ activityId: ID }) - expect(model.cursorOf(ID)).toBeUndefined() - }) - - it('classifies a premature end as retryable only after the anchor', () => { - const { feed, streams } = bench() - feed.observe(ID) - const options = streams[0]!.options as unknown as { ended: (accepted: boolean) => Error } - expect(options.ended(true).name).toBe('RemoteStreamCarrierError') - expect(options.ended(false).name).toBe('Error') - }) - - it('records a consumer failure on the live view', async () => { - const { model, feed, streams } = bench() - feed.observe(ID) - const { stream } = streams[0]! - stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - stream.poison(new Error('frame decode broke')) - await tick() - const view = model.getSnapshot().observed[String(ID)] - expect(view?.error).toContain('frame decode broke') - expect(view?.streaming).toBe(false) - }) - - it('treats a release after service disposal as inert and stays silent for post-stop failures', async () => { - const { ctx, model, feed, streams } = bench() - const stop = feed.observe(ID) - const { stream } = streams[0]! - stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - // Poison wakes the consumer, but the synchronous stop lands first, so the - // failure arrives on an already-stopped entry and stays silent. - stream.poison(new Error('after stop')) - stop() - await tick() - expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() - - const second = feed.observe(ID) - await ctx.fiber.dispose() - // The disposal path already removed every entry; a live releaser whose - // entry is gone is inert. - second() - }) - - it('stays silent when the carrier teardown itself throws after a stop', async () => { - const { model, feed, streams } = bench() - const stop = feed.observe(ID) - const { stream } = streams[0]! - stream.throwOnDispose = true - stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - // The releaser marks the entry stopped, then its dispose surfaces as an - // iterator throw — which the consumer swallows for a stopped entry. - stop() - await tick() - expect(model.getSnapshot().observed[String(ID)]?.error).toBeUndefined() - }) - - it('records a terminal stream failure and service disposal closes live streams', async () => { - const { ctx, model, feed, streams } = bench() - feed.observe(ID) - const failing = streams[0]!.stream - failing.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - const iterator = failing[Symbol.asyncIterator]() - void iterator - // Simulate a terminal consumer failure by disposing the underlying stream: - // the consumer loop ends without a status frame and cleans up. - const spy = vi.spyOn(model, 'observeFailed') - await failing.dispose() - await tick() - - const other = 'bash-2' as ActivityId - feed.observe(other) - await ctx.fiber.dispose() - expect(streams.every(entry => entry.stream.disposed)).toBe(true) - expect(spy).not.toHaveBeenCalled() - }) -}) diff --git a/packages/api/activity-controller/tests/controller.host.spec.ts b/packages/api/activity-controller/tests/controller.host.spec.ts deleted file mode 100644 index 4c3262f093..0000000000 --- a/packages/api/activity-controller/tests/controller.host.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' -import { ActivityController } from '../src/index.ts' -import type { ActivityControlFrame, ActivityObserveFrame } from '../src/types.ts' - -async function collect( - iterable: AsyncIterable, - count: number, - abort: AbortController, -): Promise { - const frames: Frame[] = [] - const iterator = iterable[Symbol.asyncIterator]() - try { - while (frames.length < count) { - const step = await iterator.next() - if (step.done) return frames - frames.push(step.value) - } - } finally { - abort.abort() - await iterator.next().catch(() => undefined) - } - return frames -} - -describe('ActivityController', () => { - it('closes live roster followers when the owning fiber unwinds', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalActivityRegistry) - const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) - const abort = new AbortController() - const iterator = controller.control(abort.signal)[Symbol.asyncIterator]() - expect((await iterator.next()).value).toMatchObject({ type: 'baseline' }) - const pending = iterator.next() - await ctx.fiber.dispose() - // The feed effect closed the follower, ending the generation cleanly. - expect((await pending).done).toBe(true) - }) - - it('coalesces bursts that land inside the flush window and carries the anchor detail', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalActivityRegistry) - const controller = new ActivityController(ctx, { flushMs: 30, maxFrameBytes: 1024 }) - const handle = ctx.activities.open({ kind: 'bash', label: 'burst' }) - handle.updateDetail('warming up') - const abort = new AbortController() - const frames: ActivityObserveFrame[] = [] - const done = (async () => { - for await (const frame of controller.observe({ activityId: handle.id }, abort.signal)) frames.push(frame) - })() - await new Promise(resolve => setTimeout(resolve, 10)) - handle.append('a') - await new Promise(resolve => setTimeout(resolve, 5)) - // Lands inside the flush sleep, so the waiter is already dirty at the next wait. - handle.append('b') - await new Promise(resolve => setTimeout(resolve, 80)) - handle.end({ status: 'completed' }) - await done - expect(frames[0]).toMatchObject({ type: 'opened', detail: 'warming up' }) - const text = frames.filter(frame => frame.type === 'output') - .flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') - expect(text).toBe('ab') - }) - - it('serves an empty baseline and refuses observation without the registry', async () => { - const ctx = new Context() - const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) - const abort = new AbortController() - const frames = await collect(controller.control(abort.signal), 1, abort) - expect(frames).toEqual([{ type: 'baseline', activities: [] }]) - expect(() => controller.observe({ activityId: 'bash-1' as never }, new AbortController().signal)) - .toThrow(/activity observation unavailable/) - }) - - it('streams the roster and one activity end to end over a live registry', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalActivityRegistry) - const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) - - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - handle.append('hi') - handle.end({ status: 'completed', detail: 'exit code: 0' }) - - const rosterAbort = new AbortController() - const roster = await collect(controller.control(rosterAbort.signal), 1, rosterAbort) - expect(roster[0]).toMatchObject({ type: 'baseline' }) - expect((roster[0] as Extract).activities[0]?.label).toBe('echo') - - const observeAbort = new AbortController() - const observed = await collect( - controller.observe({ activityId: handle.id }, observeAbort.signal), - 3, - observeAbort, - ) - expect(observed.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) - }) -}) diff --git a/packages/api/activity-controller/tsconfig.client.json b/packages/api/activity-controller/tsconfig.client.json deleted file mode 100644 index d2934629b4..0000000000 --- a/packages/api/activity-controller/tsconfig.client.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types", - "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" - }, - "files": [ - "src/client/index.ts", - "src/client/model.ts", - "src/client/service.ts", - "src/types.ts" - ], - "references": [ - { - "path": "../../../vendor/cordis" - }, - { - "path": "../gateway/tsconfig.client.json" - }, - { - "path": "../../client/connection/tsconfig.client.json" - }, - { - "path": "../../client/store" - }, - { - "path": "../../activity/activity" - }, - { - "path": "../../core/session" - }, - { - "path": "../../jobs/jobs" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../util/brand" - }, - { - "path": "../../typert/protocol" - } - ] -} diff --git a/packages/api/activity-controller/tsconfig.host.json b/packages/api/activity-controller/tsconfig.host.json deleted file mode 100644 index 4193d47109..0000000000 --- a/packages/api/activity-controller/tsconfig.host.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types", - "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" - }, - "files": [ - "src/index.ts", - "src/invariant.ts", - "src/types.ts", - "src/feed.ts", - "src/observe.ts" - ], - "references": [ - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../activity/activity" }, - { "path": "../../core/agent" }, - { "path": "../../core/session" }, - { "path": "../../jobs/jobs" }, - { "path": "../../llm/llm" }, - { "path": "../../runtime-diagnostics/invariants" }, - { "path": "../../typert/protocol" } - ] -} diff --git a/packages/api/activity-controller/tsconfig.json b/packages/api/activity-controller/tsconfig.json deleted file mode 100644 index 2a0b0e33f7..0000000000 --- a/packages/api/activity-controller/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.host.json" }, - { "path": "./tsconfig.client.json" } - ] -} diff --git a/packages/api/activity-controller/tsdown.config.ts b/packages/api/activity-controller/tsdown.config.ts deleted file mode 100644 index 0552d1b1cf..0000000000 --- a/packages/api/activity-controller/tsdown.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { clientBundle } from '../../client/tsdown.client.ts' - -export default clientBundle( - '@deepseek-ai/dsh-api-activity-controller', - ['lib/types/index.js', 'lib/types/invariant.js'], - { hostPhase: true }, -) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index f6f3b74990..a4334e0e75 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -66,7 +66,6 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-settings-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index baaccbbdbf..e7fc7757e9 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -13,7 +13,6 @@ import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' -import activityRemote from '@deepseek-ai/dsh-api-activity-controller/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -32,8 +31,6 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/remote' export type * from '@deepseek-ai/dsh-api-session-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' -export type {} from '@deepseek-ai/dsh-api-activity-controller/remote' -export type * from '@deepseek-ai/dsh-api-activity-controller/types' export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. @@ -149,7 +146,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, - subagentsRemote, sessionRemote, workspaceRemote, activityRemote, + subagentsRemote, sessionRemote, workspaceRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index f19acea2c2..5a11bae49d 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -26,7 +26,6 @@ import type {} from '@deepseek-ai/dsh-settings/types' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-user-questions' export type {} from '@deepseek-ai/dsh-api-session-controller/types' -export type {} from '@deepseek-ai/dsh-api-activity-controller/types' export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' export type { ApiRemoteForwardedEvent } from './types.ts' diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 1b4d4ca1ae..56f664d499 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -66,9 +66,6 @@ { "path": "../session-controller/tsconfig.client.json" }, - { - "path": "../activity-controller/tsconfig.client.json" - }, { "path": "../settings-controller" }, diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index a0aab5ea93..65fca35ee0 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -60,9 +60,6 @@ { "path": "../session-controller/tsconfig.host.json" }, - { - "path": "../activity-controller/tsconfig.host.json" - }, { "path": "../workspace-controller/tsconfig.host.json" }, diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 2f2192cde2..2e5ca38bf5 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,6 +29,8 @@ Each endpoint states its activation policy. List, search, attachment, history pa The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. +A `SessionJob` row carries `outputTotal` exactly when the job declared an observation record, and `session.observeJob` streams that record from an absolute byte offset — one `opened` anchor, coalesced `output` frames, then one terminal `status` — with the fenced read resolved from the request's session. Record reads are non-consuming: the model-facing cursor and completion-notice state never observe them. The Client adapter exposes `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views a task-list UI renders; observers share one stream per job and resume from their model cursor across reconnects. + The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. ----- @@ -40,6 +42,8 @@ The Session object also carries local submission echoes: `session.beginSubmissio |---|---:|---| | `coldBlankProbeMaxBytes` | `1,024` | Maximum physical size of a cold Session artifact eligible for blankness verification; `0` disables probes | | `nativeOpen` | platform-detected | Whether Session workspace paths can be handed to a native desktop opener | +| `observeFlushMs` | `100` | Coalescing window after new record output before a job-observation read, in milliseconds | +| `observeMaxFrameBytes` | `65,536` | Soft byte budget per job-observation output frame; one larger chunk ships whole | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-session-controller) is the exhaustive source for accepted fields and their JSDoc. diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index adec1a5b4b..eaa8457e88 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -81,11 +81,6 @@ - id: jobs name: '@deepseek-ai/dsh-jobs-local' - # Live-activity observation registry: non-consuming output streams for the - # optional activity plane (background bash/pwsh mirror into it when loaded). - - id: activity - name: '@deepseek-ai/dsh-activity-local' - - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 50a3583899..a203607a5d 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -41,7 +41,6 @@ "dependencies": { "@deepseek-ai/cordis-plugin-hmr": "workspace:^", "@deepseek-ai/cordis-plugin-timer": "workspace:^", - "@deepseek-ai/dsh-activity-local": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", "@deepseek-ai/dsh-agent-instructions": "workspace:^", diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 510d0653f3..85b67e7d08 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -95,10 +95,6 @@ - id: workspace-controller name: '@deepseek-ai/dsh-api-workspace-controller' - # Live-activity roster and per-activity output observation over Typert Remote. - - id: activity-controller - name: '@deepseek-ai/dsh-api-activity-controller' - - id: cordis-host-runner name: '@deepseek-ai/dsh-cordis-host-runner' @@ -258,10 +254,10 @@ name: '@deepseek-ai/dsh-client-ui-reference' - # Live activities: the session-header roster with on-demand streaming - # output panels over the activity feed. - - id: ui-activity - name: '@deepseek-ai/dsh-client-ui-activity' + # Background jobs: the session-header roster with on-demand streaming + # record panels over the session job-output service. + - id: ui-jobs + name: '@deepseek-ai/dsh-client-ui-jobs' # Read-only active Schedule catalog. The shipped Web graph resolves the # client package but leaves it disabled; the explicit Schedule overlay diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index b6e34b5a79..0a58a1a78c 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -45,7 +45,6 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-settings-controller": "workspace:^", @@ -55,7 +54,7 @@ "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-ui-activity": "workspace:^", + "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-ui-approval": "workspace:^", "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", diff --git a/packages/client/README.md b/packages/client/README.md index 3e45bac9b0..367a6d39ce 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -55,7 +55,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions | — | | [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source | — | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references | — | -| [`ui-activity/`](ui-activity/README.md) | Lists this session's tasks — jobs joined with their live-output activities | — | +| [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs with on-demand streaming record panels | — | | [`ui-schedule/`](ui-schedule/README.md) | Lists the current Session's active reminders in a read-only header catalog | — | | [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces | — | | [`ui-permission-presets/`](ui-permission-presets/README.md) | Configures default permissions and switches the current session's access | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index e624cd5f87..05a0143ce4 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -55,7 +55,7 @@ kind: "package-group" | [`ui-skill/`](ui-skill/README.zh.md) | 向内联建议添加 skill(技能)引用 | — | | [`ui-reference/`](ui-reference/README.zh.md) | 统一的 Web `@file` / `@session` 引用 source | — | | [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent(子智能体)导航、子级 transcript(文本记录)状态与内联引用 | — | -| [`ui-activity/`](ui-activity/README.zh.md) | 列出本会话的任务——后台任务与其实时输出 activity 的合并视图 | — | +| [`ui-jobs/`](ui-jobs/README.zh.md) | 列出本会话的后台任务,并提供按需展开的流式输出面板 | — | | [`ui-schedule/`](ui-schedule/README.zh.md) | 在只读标题栏目录中列出当前 Session 的活动提醒 | — | | [`ui-model-selection/`](ui-model-selection/README.zh.md) | 在对话界面中提供模型选择 | — | | [`ui-permission-presets/`](ui-permission-presets/README.zh.md) | 配置默认权限并切换当前会话的访问模式 | — | diff --git a/packages/client/ui-activity/README.md b/packages/client/ui-activity/README.md deleted file mode 100644 index 6630558922..0000000000 --- a/packages/client/ui-activity/README.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -description: "The session-header task list: background jobs joined per row with their live-output activities, expandable streaming panels, running/finished sections, and the graceful no-registry degradation." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-client-ui-activity - -English | [中文](README.zh.md) - -## Summary - -`dsh-client-ui-activity` puts this session's running work in one header control: background jobs joined per row with their correlated live-output activities, plus standalone activities such as workflow runs. Expanding an observable row streams its real output into an embedded terminal panel while it runs; collapsing stops the stream, so output only flows while someone is watching. Live rows lead with a ticking duration over a kind-and-status second line, settled rows compact behind a section heading, and without the activity registry the job rows still render — the control degrades to a plain job list rather than disappearing. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Load the plugin through the web-app manifest; it renders nothing until the session can see at least one task, so an ordinary conversation never grows a control for a capability it is not using. - -### The per-row join - -Jobs and activities stay separate planes ([why](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md)); the join happens per row in this component. A job row keeps the job's lifecycle, duration, and model-visible `detail` from the `jobsBySession` mirror, and gains an expandable output panel when an activity carries its `correlation.jobId`; an activity without a job keeps its own row. Rows without an observable activity render as static rows with no expansion affordance. - -### The expanded panel - -Expanding an observable row opens that activity's observation stream from `ctx.activityFeed` into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream failures render as notices above the panel. - ------ - - -## Understand the implementation - -
        -Implementation internals — click to expand - -One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.activityFeed` and the standard `useSessions` hook — the component holds no transport state. Observation follows visibility: one `useEffect` opens the stream for the expanded row's activity and closes it on collapse, unmount, or popover dismissal. - -| File | Role | -|---|---| -| [`src/client/ActivityListAction.tsx`](src/client/ActivityListAction.tsx) | The merged task list: join, sections, durations, panels | -| [`src/client/index.ts`](src/client/index.ts) | Slot registration and the dictionaries | -| [`src/client/locales.ts`](src/client/locales.ts) | The `activity` namespace copy (zh source of truth) | - -
        - ------ - - -## Further Exploration - -- [`dsh-api-activity-controller`](../../api/activity-controller/README.md) — the feed service and streams behind the panel. -- [Unified task list Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-unified-task-list.md) — why the join lives in the UI projection. -- [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures. - ------ - - -## Model Experience - -None, as this package renders host-observed state and live output for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). - -#### KV Cache effect - -None; the package never assembles or sends provider requests. - -## Known Limitations and Deferred Work - - - -These limits define current package constraints, not a task backlog. - -- **No kill control** — cancellation stays with the model's `job_kill`; a human kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). -- **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up. - - -### Dev Note - -
        -Working context for maintainers — click to expand - -None. - -
        diff --git a/packages/client/ui-activity/README.zh.md b/packages/client/ui-activity/README.zh.md deleted file mode 100644 index b02949f251..0000000000 --- a/packages/client/ui-activity/README.zh.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -description: "会话头部任务列表:后台任务与其实时输出 activity 逐行合并、可展开的流式面板、进行中/已结束分组,以及无注册表时的优雅退化。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-client-ui-activity - -[English](README.md) | 中文 - -## 概述 - -`dsh-client-ui-activity` 把本会话正在跑的工作放进一个头部控件:后台任务与其关联的实时输出 activity 逐行合并,外加 workflow 运行等独立 activity。展开可观察的行,其真实输出在运行期间流入内嵌终端面板;收起即停流——只有有人在看时输出才流动。进行中的行以跳动时长为主行、kind 与状态为副行,已结束的行折叠在分组标题之后;没有 activity 注册表时 job 行照常渲染——控件退化为普通任务列表而不是消失。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -经 web-app 清单加载该插件;会话一个任务都看不到时它完全不渲染,普通会话不会为未使用的能力多出控件。 - -### 逐行 join - -jobs 与 activity 保持为两个独立的面([缘由](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md));合并逐行发生在本组件内。job 行保留来自 `jobsBySession` 镜像的生命周期、时长与模型可见 `detail`,当某个 activity 以 `correlation.jobId` 关联到它时获得可展开的输出面板;没有 job 的 activity 保留自己的行。没有可观察 activity 的行渲染为无展开交互的静态行。 - -### 展开的面板 - -展开可观察的行即把该 activity 在 `ctx.activityFeed` 上的观察流打开进内嵌终端面板。面板的复制控件复制命令(而非输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不画自己的运行状态点——上方的行已携带状态。保留缺口与流失败以面板上方的提示呈现。 - ------ - - -## 理解实现 - -
        -实现内幕——点击展开 - -头部操作区里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹层;弹层通过测量锚点把自己贴合进视口。所有数据经 `ctx.activityFeed` 与标准 `useSessions` 钩子到达——组件不持有任何传输状态。观察跟随可见性:一个 `useEffect` 为展开行的 activity 开流,在收起、卸载或弹层关闭时关流。 - -| 文件 | 角色 | -|---|---| -| [`src/client/ActivityListAction.tsx`](src/client/ActivityListAction.tsx) | 合并任务列表:join、分组、时长、面板 | -| [`src/client/index.ts`](src/client/index.ts) | slot 注册与词典 | -| [`src/client/locales.ts`](src/client/locales.ts) | `activity` 命名空间文案(zh 为真源) | - -
        - ------ - - -## 进一步探索 - -- [`dsh-api-activity-controller`](../../api/activity-controller/README.zh.md)——面板背后的 feed 服务与流。 -- [统一任务列表 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md)——join 为何住在 UI 投影层。 -- [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md)——面板所配置的 `TerminalBlock` 表面。 - ------ - - -## 模型体验 - -无:本包只为人渲染宿主观察到的状态与实时输出,不触碰任何 prompt、消息、schema、流或工具结果。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 - -#### KV Cache 影响 - -无;本包从不组装或发送 provider 请求。 - -## 已知限制与延期工作 - - - -这些限制是当前包约束,不是任务积压。 - -- **没有 kill 控件**——取消仍走模型的 `job_kill`;人工 kill 被 [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题阻塞。 -- **不渲染通道标签**——stdout 与 stderr chunk 连接成一条流;分通道着色是后续呈现优化。 - - -### 开发备注 - -
        -维护者的工作上下文——点击展开 - -无。 - -
        diff --git a/packages/client/ui-activity/src/client/index.ts b/packages/client/ui-activity/src/client/index.ts deleted file mode 100644 index 39f43df9e1..0000000000 --- a/packages/client/ui-activity/src/client/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Task-list plugin, browser half: contributes one session-header action that - * renders this session's tasks — background jobs joined with their correlated - * live-output activities, plus standalone activities such as workflow runs. - * Job rows arrive through the `jobsBySession` list mirror; activity rows and - * per-row observation streams arrive through the `activityFeed` client - * service. This plugin holds no transport state of its own. - */ -import type { Context as ClientContext } from '@deepseek-ai/cordis' -import { ActivityListAction } from './ActivityListAction.tsx' -import type { ActivityListInjected } from './ActivityListAction.tsx' -import type {} from '@deepseek-ai/dsh-api-activity-controller/client' -import type {} from '@deepseek-ai/dsh-client-locale/client' -import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' -import type {} from '@deepseek-ai/dsh-client-ui-session/client' -import { en, NS, zh, type ActivityKey } from './locales.ts' - -declare module '@deepseek-ai/dsh-client-ui-slots' { - interface LocaleNamespaceMap { - /** Live-activity list copy. */ - 'activity': ActivityKey - } -} - -export type { ActivityListActionProps, ActivityListInjected } from './ActivityListAction.tsx' - -/** Required services: session and activity state, the slot registry, and dictionaries. */ -export const inject = ['sessions', 'activityFeed', 'slots', 'locale'] - -/** - * Client plugin body: register the dictionaries and the header action. - * @param ctx - client root context. - */ -export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-activity: dictionaries') - ctx.slots.inject( - 'conversation.session.header.actions', - () => ctx.slots.register({ - name: 'conversation.session.header.actions', - id: 'activity-list', - // After the subagent catalog: session lineage reads before running work. - order: 20, - locale: NS, - inject: (): ActivityListInjected => ({ - hooks: { activity: ctx.activityFeed.state }, - observe: id => ctx.activityFeed.observe(id), - }), - }, ActivityListAction), - ) -} diff --git a/packages/client/ui-activity/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml similarity index 70% rename from packages/client/ui-activity/README.i18n.yaml rename to packages/client/ui-jobs/README.i18n.yaml index bae875ee0e..b968d83fab 100644 --- a/packages/client/ui-activity/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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/ui-activity/README.md -README.md: 6630558922cf9f1e2b772bcc6334a21f91591acb -README.zh.md: b02949f2518c85ba931e0adf76946c390b564d1a +# pnpm run verify-translation-pairing --write packages/client/ui-jobs/README.md +README.md: b93f6b6f9dcb0af26c077634181427105e0b3a1b +README.zh.md: 17809b2203b34bdc78b34984a71fb755d3f67a05 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md new file mode 100644 index 0000000000..b93f6b6f9d --- /dev/null +++ b/packages/client/ui-jobs/README.md @@ -0,0 +1,93 @@ +--- +description: "The session-header background-job list: expandable streaming record panels, running/finished sections, and static rows for jobs without a record." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-jobs + +English | [中文](README.zh.md) + +## Summary + +`dsh-client-ui-jobs` puts this session's background jobs in one header control. Every row carries the job's lifecycle, ticking duration, and model-visible `detail` from the `jobsBySession` mirror; a job that declared an observation record additionally offers an expandable panel that streams its real output while it runs. Collapsing stops the stream, so output only flows while someone is watching. Live rows lead with a ticking duration over a kind-and-status second line; settled rows compact behind a section heading; jobs without a record (subagent delegations, PTY sends) render as static rows with no expansion affordance. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Load the plugin through the web-app manifest; it renders nothing until the session can see at least one job, so an ordinary conversation never grows a control for a capability it is not using. + +### One row per job + +The session control stream's `jobsBySession` mirror is the single roster: each `SessionJob` row carries lifecycle, duration, and the model-visible `detail`, and its `outputTotal` field is present exactly when the job declared a record — that presence is what makes a row expandable. There is no second roster to join. + +### The expanded panel + +Expanding an observable row opens that job's record observation stream from `ctx.jobOutput` into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream failures render as notices above the panel. + +----- + + +## Understand the implementation + +
        +Implementation internals — click to expand + +One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobOutput` and the standard `useSessions` hook — the component holds no transport state. Observation follows visibility: one `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover dismissal. + +| File | Role | +|---|---| +| [`src/client/JobListAction.tsx`](src/client/JobListAction.tsx) | The job list: sections, durations, panels | +| [`src/client/index.ts`](src/client/index.ts) | Slot registration and the dictionaries | +| [`src/client/locales.ts`](src/client/locales.ts) | The `job` namespace copy (zh source of truth) | + +
        + +----- + + +## Further Exploration + +- [`dsh-api-session-controller`](../../api/session-controller/README.md) — the `jobsBySession` mirror and the `session.observeJob` stream behind the panel. +- [`dsh-jobs`](../../jobs/jobs/README.md) — the registry contract that owns the record semantics. +- [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures. + +----- + + +## Model Experience + +None, as this package renders host-observed state and live output for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). + +#### KV Cache effect + +None; the package never assembles or sends provider requests. + +## Known Limitations and Deferred Work + + + +These limits define current package constraints, not a task backlog. + +- **No kill control** — cancellation stays with the model's `job_kill`; a human kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). +- **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up. + + +### Dev Note + +
        +Working context for maintainers — click to expand + +None. + +
        diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md new file mode 100644 index 0000000000..17809b2203 --- /dev/null +++ b/packages/client/ui-jobs/README.zh.md @@ -0,0 +1,93 @@ +--- +description: "会话头部后台任务列表:可展开的流式 record 面板、进行中/已结束分组,以及无 record 任务的静态行。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-jobs + +[English](README.md) | 中文 + +## 概述 + +`dsh-client-ui-jobs` 把本会话的后台任务放进一个头部控件。每一行都携带 `jobsBySession` 镜像里该 job 的生命周期、跳动时长与模型可见的 `detail`;声明了观测 record 的 job 额外提供可展开的面板,在运行期间流式呈现其真实输出。收起即停流——只有有人在看时输出才流动。进行中的行以跳动时长为主行、kind 与状态为副行;已结束的行折叠在分组标题之后;没有 record 的 job(subagent 委托、PTY 发送)渲染为无展开操作的静态行。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +通过 web-app 清单加载本插件;在会话看得到至少一个 job 之前它不渲染任何东西,因此普通对话不会为未使用的能力长出控件。 + +### 一 job 一行 + +session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJob` 行携带生命周期、时长与模型可见的 `detail`,其 `outputTotal` 字段恰在 job 声明了 record 时存在——这个存在性就是行可展开的依据。不存在需要 join 的第二份名册。 + +### 展开的面板 + +展开可观察的行会从 `ctx.jobOutput` 打开该 job 的 record 观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。 + +----- + + +## 理解实现 + +
        +实现内幕——点击展开 + +头部操作带里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobOutput` 与标准 `useSessions` hook 到达——组件不持有任何传输状态。观测跟随可见性:一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 + +| 文件 | 角色 | +|---|---| +| [`src/client/JobListAction.tsx`](src/client/JobListAction.tsx) | 任务列表:分组、时长、面板 | +| [`src/client/index.ts`](src/client/index.ts) | slot 注册与词典 | +| [`src/client/locales.ts`](src/client/locales.ts) | `job` 命名空间文案(zh 为事实源) | + +
        + +----- + + +## 进一步探索 + +- [`dsh-api-session-controller`](../../api/session-controller/README.zh.md) —— 面板背后的 `jobsBySession` 镜像与 `session.observeJob` 流。 +- [`dsh-jobs`](../../jobs/jobs/README.zh.md) —— 拥有 record 语义的注册表契约。 +- [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md) —— 面板所配置的 `TerminalBlock` 表面。 + +----- + + +## 模型体验 + +无。本包为人类渲染宿主观测到的状态与实时输出,不触碰任何提示词、消息、schema、流或工具结果。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 + +#### KV 缓存影响 + +无;本包从不组装或发送 provider 请求。 + +## 已知限制与延期工作 + + + +这些限制界定当前包约束,不是任务清单。 + +- **没有 kill 控件**——取消仍归模型的 `job_kill`;人工 kill 卡在 [web 任务显示 Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题上。 +- **不渲染 channel 标签**——stdout 与 stderr 块拼接为一条流;按 channel 着色是展示层的后续工作。 + + +### 开发备注 + +
        +维护者工作语境——点击展开 + +无。 + +
        diff --git a/packages/client/ui-activity/package.json b/packages/client/ui-jobs/package.json similarity index 83% rename from packages/client/ui-activity/package.json rename to packages/client/ui-jobs/package.json index cd558c41d2..5b29f894b6 100644 --- a/packages/client/ui-activity/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-client-ui-activity", - "description": "Session-header task list: background jobs joined with their live-output activities, with on-demand streaming output panels", + "name": "@deepseek-ai/dsh-client-ui-jobs", + "description": "Session-header background-job list with on-demand streaming record panels", "version": "0.1.2-alpha.2", "type": "module", "main": "lib/index.js", @@ -29,7 +29,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/client/ui-activity" + "directory": "packages/client/ui-jobs" }, "publishConfig": { "access": "public" @@ -39,7 +39,6 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", @@ -49,6 +48,7 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, @@ -61,7 +61,7 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-api-activity-controller", + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives" diff --git a/packages/client/ui-activity/src/client/ActivityListAction.module.css b/packages/client/ui-jobs/src/client/JobListAction.module.css similarity index 100% rename from packages/client/ui-activity/src/client/ActivityListAction.module.css rename to packages/client/ui-jobs/src/client/JobListAction.module.css diff --git a/packages/client/ui-activity/src/client/ActivityListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx similarity index 61% rename from packages/client/ui-activity/src/client/ActivityListAction.tsx rename to packages/client/ui-jobs/src/client/JobListAction.tsx index 41f4d7a4b1..edac6497f6 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -1,8 +1,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' -import type { - ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, -} from '@deepseek-ai/dsh-api-activity-controller/client' +import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconChevronDownOutline14, StateDot, TerminalBlock, useDismissOnOutsidePointer, type StateDotState, type TerminalBlockLabels, @@ -10,74 +9,57 @@ import { import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { NS } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import css from './ActivityListAction.module.css' +import css from './JobListAction.module.css' -/** Registration-side business face for the activity feed. */ -export interface ActivityListInjected { +/** Registration-side business face for the job list. */ +export interface JobListInjected { hooks: { - /** Client activity snapshot bound by the renderer as useActivity. */ - activity: { - getSnapshot(): ActivityFeedSnapshot + /** Client job-output snapshot bound by the renderer as useJobOutput. */ + jobOutput: { + getSnapshot(): JobOutputSnapshot subscribe(listener: () => void): () => void } } /** - * Start observing one activity's live output; returns the stop function. - * Reference-counted by the client feed, so panels can overlap safely. + * Start observing one job's live record; returns the stop function. + * Reference-counted by the client service, so panels can overlap safely. */ - observe: (id: ActivityId) => () => void + observe: (sessionId: SessionId | undefined, id: SessionJob['id']) => () => void } -/** Full props for the session-header task-list action. */ -export type ActivityListActionProps = +/** Full props for the session-header job-list action. */ +export type JobListActionProps = PropsRuntime<'conversation.session.header.actions'> & PropsLocale - & InjectFace + & InjectFace -/** - * One merged task row. Jobs and activities are separate planes — the control - * plane the model interacts with, and the transient observation plane — so - * this list joins them per row: a job carries identity, lifecycle, and the - * model-visible detail; its correlated activity contributes the observable - * live output; an activity without a job (a workflow run) stands alone. - */ -interface TaskRow { - /** Stable row identity: the job id when one exists, else the activity id. */ - key: string - kind: string - label: string - /** Job lifecycle vocabulary; activity-only rows never reach `stopping`. */ - status: SessionJob['status'] - detail?: string - startedAt: number - finishedAt?: number - /** Present when this row's live output can be observed. */ - activityId?: ActivityId -} - -/** Stable empty lists so a session with no work keeps one array identity. */ -const NO_ROWS: readonly ActivityRow[] = [] +/** Stable empty list so a session with no jobs keeps one array identity. */ const NO_JOBS: readonly SessionJob[] = [] /** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */ const VIEWPORT_MARGIN = 12 -function isLive(row: TaskRow): boolean { - return row.status === 'running' || row.status === 'stopping' +function isLive(job: SessionJob): boolean { + return job.status === 'running' || job.status === 'stopping' +} + +/** True exactly when the job declared an observation record. */ +function isObservable(job: SessionJob): boolean { + return job.outputTotal !== undefined } /** Closed-union exhaustiveness fence for the wire status set. */ /* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */ function assertNever(value: never): never { - throw new Error(`unhandled task status: ${JSON.stringify(value)}`) + throw new Error(`unhandled job status: ${JSON.stringify(value)}`) } /** * Status marker semantics. `stopping` and `killed` share the attention color: * both mean the work ended (or is ending) on request rather than on its own. */ -function dotState(status: TaskRow['status']): StateDotState { +function dotState(status: SessionJob['status']): StateDotState { switch (status) { case 'running': return 'ongoing' case 'stopping': return 'warning' @@ -89,7 +71,7 @@ function dotState(status: TaskRow['status']): StateDotState { } } -function statusLabel(status: TaskRow['status'], t: TranslateNS): string { +function statusLabel(status: SessionJob['status'], t: TranslateNS): string { switch (status) { case 'running': return t('status.running') case 'stopping': return t('status.stopping') @@ -102,7 +84,7 @@ function statusLabel(status: TaskRow['status'], t: TranslateNS): stri } /** - * Elapsed time in at most two adjacent units. A task that outlives an hour is + * Elapsed time in at most two adjacent units. A job that outlives an hour is * already exceptional, so hours is the widest unit — beyond that the figure * stays in hours rather than growing a day/month vocabulary no producer * currently reaches. @@ -143,66 +125,13 @@ function terminalLabels(t: TranslateNS): TerminalBlockLabels { } } -/** Project one standalone activity into the merged row vocabulary. */ -function activityTask(activity: ActivityRow): TaskRow { - return { - key: String(activity.id), - kind: activity.kind, - label: activity.label, - status: activity.status, - ...activity.detail !== undefined ? { detail: activity.detail } : {}, - startedAt: activity.startedAt, - ...activity.finishedAt !== undefined ? { finishedAt: activity.finishedAt } : {}, - activityId: activity.id, - } -} - -/** - * Join this session's jobs with their correlated activities and append the - * activities that stand alone. A job row keeps the job's lifecycle and detail - * (what the model sees) and gains observability from its activity; an - * activity whose job is not in the projection keeps its own row rather than - * disappearing. - */ -function mergeRows( - jobs: readonly SessionJob[], - activities: readonly ActivityRow[], -): TaskRow[] { - const byJob = new Map() - const standalone: ActivityRow[] = [] - for (const activity of activities) { - const jobId = activity.correlation?.jobId - if (jobId === undefined) standalone.push(activity) - // One activity per job: every shipped producer opens at most one, so a - // duplicate jobId keeps only the newest row rather than growing the list. - else byJob.set(String(jobId), activity) - } - const rows: TaskRow[] = jobs.map((job) => { - const activity = byJob.get(String(job.id)) - byJob.delete(String(job.id)) - return { - key: String(job.id), - kind: job.kind, - label: job.label, - status: job.status, - ...job.detail !== undefined ? { detail: job.detail } : {}, - startedAt: job.startedAt, - ...job.finishedAt !== undefined ? { finishedAt: job.finishedAt } : {}, - ...activity !== undefined ? { activityId: activity.id } : {}, - } - }) - for (const activity of byJob.values()) rows.push(activityTask(activity)) - for (const activity of standalone) rows.push(activityTask(activity)) - return rows -} - /** * Live rows first in start order, then settled rows newest-first. Two rows * that settled in the same millisecond fall back to start order, so the sort * never depends on the host's map iteration. */ -function ordered(rows: readonly TaskRow[]): TaskRow[] { - return [...rows].sort((left, right) => { +function ordered(jobs: readonly SessionJob[]): SessionJob[] { + return [...jobs].sort((left, right) => { const liveLeft = isLive(left) if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 if (liveLeft) return left.startedAt - right.startedAt @@ -211,21 +140,21 @@ function ordered(rows: readonly TaskRow[]): TaskRow[] { }) } -/** One task row plus, when observable and expanded, its live output panel. */ -function TaskItem({ row, view, expanded, now, onToggle, t }: { - row: TaskRow - view: ObservedActivity | undefined +/** One job row plus, when observable and expanded, its live output panel. */ +function JobItem({ job, view, expanded, now, onToggle, t }: { + job: SessionJob + view: ObservedJob | undefined expanded: boolean /** Clock sample live rows derive their running duration from. */ now: number onToggle: () => void t: TranslateNS }) { - const live = isLive(row) - const status = statusLabel(row.status, t) - const observable = row.activityId !== undefined + const live = isLive(job) + const status = statusLabel(job.status, t) + const observable = isObservable(job) const labels = useMemo(() => terminalLabels(t), [t]) - const elapsed = live ? now - row.startedAt : (row.finishedAt ?? row.startedAt) - row.startedAt + const elapsed = live ? now - job.startedAt : (job.finishedAt ?? job.startedAt) - job.startedAt const duration = formatDuration(elapsed, t) const durationCell = ( - + - {row.label} + {job.label} {durationCell} - {row.kind} - {row.detail ?? status} + {job.kind} + {job.detail ?? status} {observable ? : null} @@ -254,10 +183,10 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: { ) : ( <> - - {row.kind} - {row.label} - {row.detail ?? status} + + {job.kind} + {job.label} + {job.detail ?? status} {durationCell} {observable ? : null} @@ -270,7 +199,7 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: { type="button" className={live ? css.row : `${css.row} ${css.rowSettled}`} aria-expanded={expanded} - aria-label={t(expanded ? 'row.collapseAria' : 'row.expandAria', { label: row.label })} + aria-label={t(expanded ? 'row.collapseAria' : 'row.expandAria', { label: job.label })} onClick={onToggle} > {body} @@ -289,10 +218,10 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: { ?
        {t('output.error', { error: view.error })}
        : null} state.jobsBySession[sessionId]) ?? NO_JOBS - const owned = useActivity(state => state.rowsBySession[sessionId]) ?? NO_ROWS - const unowned = useActivity(state => state.rowsBySession['']) ?? NO_ROWS - const observedViews = useActivity(state => state.observed) + const observedViews = useJobOutput(state => state.observed) const [open, setOpen] = useState(false) const [expandedKey, setExpandedKey] = useState(undefined) const [now, setNow] = useState(() => Date.now()) @@ -333,12 +259,9 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ // inside the viewport (the stylesheet alone cannot see the anchor offset). const [menuShift, setMenuShift] = useState(0) - const rows = useMemo( - () => ordered(mergeRows(jobs, [...owned, ...unowned])), - [jobs, owned, unowned], - ) + const rows = useMemo(() => ordered(jobs), [jobs]) const liveRows = useMemo(() => rows.filter(isLive), [rows]) - const settledRows = useMemo(() => rows.filter(row => !isLive(row)), [rows]) + const settledRows = useMemo(() => rows.filter(job => !isLive(job)), [rows]) useDismissOnOutsidePointer(rootRef, open, setOpen) @@ -378,15 +301,16 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ // Observation follows visibility: the stream opens when an observable panel // expands and closes when it collapses, unmounts, or the popover closes. - const activeActivity = open && expandedKey !== undefined - ? rows.find(row => row.key === expandedKey)?.activityId + const expandedRow = open && expandedKey !== undefined + ? rows.find(job => String(job.id) === expandedKey) : undefined + const activeJob = expandedRow !== undefined && isObservable(expandedRow) ? expandedRow.id : undefined useEffect(() => { - if (activeActivity === undefined) return - return observe(activeActivity) - }, [activeActivity, observe]) + if (activeJob === undefined) return + return observe(sessionId, activeJob) + }, [sessionId, activeJob, observe]) - // The last task disappearing removes this control; close first so focus + // The last job disappearing removes this control; close first so focus // does not vanish from an unmounting node. useEffect(() => { if (rows.length === 0 && open) setOpen(false) @@ -394,7 +318,7 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ // An expanded row that left the list (owner disposal) folds its panel. useEffect(() => { - if (expandedKey !== undefined && !rows.some(row => row.key === expandedKey)) { + if (expandedKey !== undefined && !rows.some(job => String(job.id) === expandedKey)) { setExpandedKey(undefined) } }, [rows, expandedKey]) @@ -413,15 +337,15 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ triggerRef.current?.focus() } - const item = (row: TaskRow) => ( - ( + { - setExpandedKey(current => current === row.key ? undefined : row.key) + setExpandedKey(current => current === String(job.id) ? undefined : String(job.id)) }} t={t} /> @@ -437,7 +361,7 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ aria-label={countLabel} onClick={() => { // Sample the clock in the same commit that opens the list: the - // mount-time value predates every task, so the first painted frame + // mount-time value predates every job, so the first painted frame // would otherwise clamp a long-running row to zero until the // open effect corrects it a frame later. setNow(Date.now()) diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts new file mode 100644 index 0000000000..164b6f6e3c --- /dev/null +++ b/packages/client/ui-jobs/src/client/index.ts @@ -0,0 +1,49 @@ +/** + * Background-job plugin, browser half: contributes one session-header action + * that renders this session's jobs. Job rows arrive through the + * `jobsBySession` list mirror; per-row observation streams arrive through the + * `jobOutput` client service. This plugin holds no transport state of its + * own. + */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { JobListAction } from './JobListAction.tsx' +import type { JobListInjected } from './JobListAction.tsx' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import { en, NS, zh, type JobKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Background-job list copy. */ + 'job': JobKey + } +} + +export type { JobListActionProps, JobListInjected } from './JobListAction.tsx' + +/** Required services: session state, job observation, the slot registry, and dictionaries. */ +export const inject = ['sessions', 'jobOutput', 'slots', 'locale'] + +/** + * Client plugin body: register the dictionaries and the header action. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-jobs: dictionaries') + ctx.slots.inject( + 'conversation.session.header.actions', + () => ctx.slots.register({ + name: 'conversation.session.header.actions', + id: 'job-list', + // After the subagent catalog: session lineage reads before running work. + order: 20, + locale: NS, + inject: (): JobListInjected => ({ + hooks: { jobOutput: ctx.jobOutput.state }, + observe: (sessionId, id) => ctx.jobOutput.observe(sessionId, id), + }), + }, JobListAction), + ) +} diff --git a/packages/client/ui-activity/src/client/locales.ts b/packages/client/ui-jobs/src/client/locales.ts similarity index 78% rename from packages/client/ui-activity/src/client/locales.ts rename to packages/client/ui-jobs/src/client/locales.ts index d4fb08ec31..d2d36b74d1 100644 --- a/packages/client/ui-activity/src/client/locales.ts +++ b/packages/client/ui-jobs/src/client/locales.ts @@ -1,15 +1,15 @@ -/** `activity` namespace dictionaries. */ +/** `job` namespace dictionaries. */ /** Dictionary namespace owned by this plugin. */ -export const NS = 'activity' +export const NS = 'job' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'count.live.one': '{count} 个任务进行中', - 'count.live.other': '{count} 个任务进行中', - 'count.idle.one': '{count} 个任务', - 'count.idle.other': '{count} 个任务', - 'list.aria': '任务', + 'count.live.one': '{count} 个后台任务运行中', + 'count.live.other': '{count} 个后台任务运行中', + 'count.idle.one': '{count} 个后台任务', + 'count.idle.other': '{count} 个后台任务', + 'list.aria': '后台任务', 'section.live': '进行中', 'section.settled': '已结束', 'row.expandAria': '展开 {label} 的实时输出', @@ -41,12 +41,12 @@ export const zh = { } as const /** English dictionary, key-identical to the Chinese source of truth. */ -export const en: Record = { - 'count.live.one': '{count} task running', - 'count.live.other': '{count} tasks running', - 'count.idle.one': '{count} task', - 'count.idle.other': '{count} tasks', - 'list.aria': 'Tasks', +export const en: Record = { + 'count.live.one': '{count} background job running', + 'count.live.other': '{count} background jobs running', + 'count.idle.one': '{count} background job', + 'count.idle.other': '{count} background jobs', + 'list.aria': 'Background jobs', 'section.live': 'Running', 'section.settled': 'Finished', 'row.expandAria': 'Show live output of {label}', @@ -77,5 +77,5 @@ export const en: Record = { 'terminal.expandAria': 'Expand {n} collapsed output lines', } -/** Key domain of the `activity` namespace (zh is the source of truth). */ -export type ActivityKey = keyof typeof zh +/** Key domain of the `job` namespace (zh is the source of truth). */ +export type JobKey = keyof typeof zh diff --git a/packages/client/ui-activity/src/css-modules.d.ts b/packages/client/ui-jobs/src/css-modules.d.ts similarity index 100% rename from packages/client/ui-activity/src/css-modules.d.ts rename to packages/client/ui-jobs/src/css-modules.d.ts diff --git a/packages/client/ui-activity/src/index.ts b/packages/client/ui-jobs/src/index.ts similarity index 66% rename from packages/client/ui-activity/src/index.ts rename to packages/client/ui-jobs/src/index.ts index f75578ddaf..457e7eeb9c 100644 --- a/packages/client/ui-activity/src/index.ts +++ b/packages/client/ui-jobs/src/index.ts @@ -1,8 +1,8 @@ /** - * Node half of the live-activity list plugin. The browser half in + * Node half of the background-job list plugin. The browser half in * `src/client/` owns every contribution; this entry exists so the package * appears as an ordinary Loader row. - * @module @deepseek-ai/dsh-client-ui-activity + * @module @deepseek-ai/dsh-client-ui-jobs */ /** Loader-visible no-op body; the browser half carries the feature. */ diff --git a/packages/client/ui-activity/src/invariant.ts b/packages/client/ui-jobs/src/invariant.ts similarity index 70% rename from packages/client/ui-activity/src/invariant.ts rename to packages/client/ui-jobs/src/invariant.ts index 22eba24c42..51ae72f998 100644 --- a/packages/client/ui-activity/src/invariant.ts +++ b/packages/client/ui-jobs/src/invariant.ts @@ -1,20 +1,20 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-activity`. - * @module @deepseek-ai/dsh-client-ui-activity/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-jobs`. + * @module @deepseek-ai/dsh-client-ui-jobs/invariant */ /* jscpd:ignore-start */ import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-activity' +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-jobs' /** Cordis companion plugin name. */ -export const name = 'client-ui-activity-invariant' +export const name = 'client-ui-jobs-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** No runtime invariant: pure presentation over the activity feed's client snapshot; the feed's own packages assert the data relations. */ +/** No runtime invariant: pure presentation over client snapshots; the registry packages assert the data relations. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts similarity index 73% rename from packages/client/ui-activity/tests/browser-plugin.client.spec.ts rename to packages/client/ui-jobs/tests/browser-plugin.client.spec.ts index 3f84c2252d..8de72f8ccd 100644 --- a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts @@ -1,5 +1,5 @@ /** - * ui-activity plugin halves: the browser entry's dictionary and header-slot + * ui-jobs plugin halves: the browser entry's dictionary and header-slot * registrations against the real SlotRegistry (with fiber teardown proving * removal — HMR safety), the inert node entry, and the invariant companion's * ownership reservation. @@ -12,7 +12,7 @@ import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' -import * as ActivityInvariant from '../src/invariant.ts' +import * as JobsInvariant from '../src/invariant.ts' import { en, NS, zh } from '../src/client/locales.ts' /** Slot ledger reader: entry ids currently registered in the header list. */ @@ -22,8 +22,8 @@ function headerEntryIds(ctx: Context): (string | undefined)[] { .map(entry => entry.options.id) } -/** Ids handed to the stubbed feed's observe control. */ -const observed: string[] = [] +/** Observation requests handed to the stubbed job-output control. */ +const observed: [string | undefined, string][] = [] /** Boot the browser half over a real slot tree that declares the header list. */ async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { @@ -36,9 +36,12 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null) ctx.provide('sessions', {} as never) - ctx.provide('activityFeed', { - state: { getSnapshot: () => ({ rowsBySession: {}, observed: {}, phase: 'pending' }), subscribe: () => () => {} }, - observe: (id: string) => { observed.push(id); return () => {} }, + ctx.provide('jobOutput', { + state: { getSnapshot: () => ({ observed: {} }), subscribe: () => () => {} }, + observe: (sessionId: string | undefined, id: string) => { + observed.push([sessionId, id]) + return () => {} + }, } as never) // The locale plugin binds a settings scope, which reads the connection handle // and the forwarded-event port. @@ -55,30 +58,33 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType { +describe('ui-jobs browser half', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['sessions', 'activityFeed', 'slots', 'locale']) + expect(inject).toEqual(['sessions', 'jobOutput', 'slots', 'locale']) }) - it('exposes the feed source and observation control through the inject face', async () => { + it('exposes the output source and observation control through the inject face', async () => { const { ctx } = await bench() const entry = ctx.slots .entries('conversation.session.header.actions') - .find(candidate => candidate.options.id === 'activity-list') + .find(candidate => candidate.options.id === 'job-list') const face = (entry as unknown as { - inject: () => { hooks: { activity: unknown }; observe: (id: string) => () => void } + inject: () => { + hooks: { jobOutput: unknown } + observe: (sessionId: string | undefined, id: string) => () => void + } }).inject() - expect(face.hooks.activity).toBeDefined() - const stopper = face.observe('bash-1') - expect(observed).toEqual(['bash-1']) + expect(face.hooks.jobOutput).toBeDefined() + const stopper = face.observe('session', 'bash-1') + expect(observed).toEqual([['session', 'bash-1']]) stopper() }) it('registers the header action, and fiber teardown removes it (HMR safety)', async () => { const { ctx, fiber } = await bench() - expect(headerEntryIds(ctx)).toContain('activity-list') + expect(headerEntryIds(ctx)).toContain('job-list') await fiber.dispose() - expect(headerEntryIds(ctx)).not.toContain('activity-list') + expect(headerEntryIds(ctx)).not.toContain('job-list') }) it('registers both dictionaries under its own namespace and releases them with the fiber', async () => { @@ -98,19 +104,19 @@ describe('ui-activity browser half', () => { }) }) -describe('ui-activity node half', () => { +describe('ui-jobs node half', () => { it('contributes no host behavior', () => { // The node half exists only so the plugin appears in the Loader tree. expect(applyNode).not.toThrow() }) }) -describe('ui-activity invariant companion', () => { +describe('ui-jobs invariant companion', () => { it('reserves package ownership under its declared companion name', async () => { const ctx = new Context() await ctx.plugin(InvariantRegistry, { enabled: true }) - await ctx.plugin(ActivityInvariant) - expect(ActivityInvariant.name).toBe('client-ui-activity-invariant') - expect(ActivityInvariant.inject).toEqual(['invariants']) + await ctx.plugin(JobsInvariant) + expect(JobsInvariant.name).toBe('client-ui-jobs-invariant') + expect(JobsInvariant.inject).toEqual(['invariants']) }) }) diff --git a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx similarity index 59% rename from packages/client/ui-activity/tests/activity-list-action.client.spec.tsx rename to packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index ffb4ce5492..15bd8ec4a9 100644 --- a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -2,12 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import type { - ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, -} from '@deepseek-ai/dsh-api-activity-controller/client' +import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { ActivityListAction, type ActivityListActionProps } from '../src/client/ActivityListAction.tsx' +import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' import { zh } from '../src/client/locales.ts' afterEach(() => { @@ -17,26 +15,7 @@ afterEach(() => { }) const SESSION = 'session' as SessionId -const t: ActivityListActionProps['t'] = makeTranslate(zh) - -function row(over: Partial = {}): ActivityRow { - return { - id: 'bash-1' as ActivityId, - kind: 'bash', - label: 'pnpm run build', - sessionId: SESSION, - status: 'running', - startedAt: 1_700_000_000_000, - outputTotal: 0, - ...over, - } -} - -/** A row with no owner session, as the unowned bucket carries them. */ -function unownedRow(over: Partial = {}): ActivityRow { - const { sessionId: _drop, ...rest } = row(over) - return rest -} +const t: JobListActionProps['t'] = makeTranslate(zh) function job(over: Partial = {}): SessionJob { return { @@ -49,117 +28,88 @@ function job(over: Partial = {}): SessionJob { } } +/** A job that declared an observation record, so its row offers a panel. */ +function recordJob(over: Partial = {}): SessionJob { + return job({ outputTotal: 0, ...over }) +} + function props( - snapshot: Partial, - observe: ActivityListActionProps['observe'] = () => () => {}, - jobs: readonly SessionJob[] = [], -): ActivityListActionProps { - const state: ActivityFeedSnapshot = { - rowsBySession: {}, - observed: {}, - phase: 'ready', - ...snapshot, - } - function useActivity(select: (value: ActivityFeedSnapshot) => T): T { - return select(state) + jobs: readonly SessionJob[], + observe: JobListActionProps['observe'] = () => () => {}, + observed: Readonly> = {}, +): JobListActionProps { + const outputState: JobOutputSnapshot = { observed } + function useJobOutput(select: (value: JobOutputSnapshot) => T): T { + return select(outputState) } const sessionsState = { jobsBySession: jobs.length > 0 ? { [SESSION]: jobs } : {} } function useSessions(select: (value: typeof sessionsState) => T): T { return select(sessionsState) } - return { sessionId: SESSION, useSessions, useActivity, observe, t } as unknown as ActivityListActionProps + return { sessionId: SESSION, useSessions, useJobOutput, observe, t } as unknown as JobListActionProps } function openList(): void { fireEvent.click(screen.getAllByRole('button')[0]!) } -describe('ActivityListAction visibility', () => { - it('renders nothing while the session sees no tasks', () => { - const { container } = render() +describe('JobListAction visibility', () => { + it('renders nothing while the session sees no jobs', () => { + const { container } = render() expect(container.innerHTML).toBe('') }) - it('counts live tasks on the trigger, merging owned and unowned activity rows', () => { - render() - expect(screen.getByRole('button', { name: '2 个任务进行中' })).toBeDefined() + it('counts live jobs on the trigger', () => { + render() + expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined() }) - it('falls back to the total when nothing is live and hides foreign sessions', () => { - render() - expect(screen.getByRole('button', { name: '1 个任务' })).toBeDefined() + it('falls back to the total when nothing is live', () => { + render() + expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined() }) }) -describe('ActivityListAction merged rows', () => { - it('joins a job with its correlated activity: job lifecycle, activity output', () => { - const observe = vi.fn(() => () => {}) - render() - openList() - const list = screen.getByRole('list', { name: zh['list.aria'] }) - // One merged row: the job's label and lifecycle word, not the activity's. - expect(within(list).getAllByRole('listitem')).toHaveLength(1) - expect(within(list).getByText('pnpm run build')).toBeDefined() - expect(screen.queryByText('activity-side label')).toBeNull() - expect(within(list).getByText('winding down')).toBeDefined() - // Expansion observes the correlated activity's stream. - fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - expect(observe).toHaveBeenCalledWith('bash-act-7') - }) - - it('renders a bare job row without expansion affordances', () => { - render( { + it('renders a record-less job row without expansion affordances', () => { + render() - expect(screen.getByRole('button', { name: '1 个任务进行中' })).toBeDefined() + expect(screen.getByRole('button', { name: '1 个后台任务运行中' })).toBeDefined() openList() expect(screen.getByText('explore the repo')).toBeDefined() expect(screen.queryByRole('button', { name: zh['row.expandAria'].replace('{label}', 'explore the repo') })).toBeNull() }) - it('keeps an activity whose job is not in the projection', () => { - render() + it('shows the live detail line in place of the status word', () => { + render() openList() - expect(screen.getByText('pnpm run build')).toBeDefined() + const list = screen.getByRole('list', { name: zh['list.aria'] }) + expect(within(list).getAllByRole('listitem')).toHaveLength(1) + expect(within(list).getByText('pnpm run build')).toBeDefined() + expect(within(list).getByText('winding down')).toBeDefined() }) it('labels each non-empty section and drops the heading of an empty one', () => { const settled = [job({ id: 'bash-2' as SessionJob['id'], status: 'completed', finishedAt: 1_700_000_012_000 })] - const { rerender } = render() + const { rerender } = render() openList() expect(screen.getByText(zh['section.live'])).toBeDefined() expect(screen.getByText(zh['section.settled'])).toBeDefined() // A live-only list keeps its own heading and drops the settled one. - rerender() + rerender() expect(screen.getByText(zh['section.live'])).toBeDefined() expect(screen.queryByText(zh['section.settled'])).toBeNull() }) it('shows a settled duration and ticks a live one', () => { vi.useFakeTimers({ now: 1_700_000_020_000 }) - render( { act(() => { vi.advanceTimersByTime(1_000) }) expect(screen.getByText('6秒')).toBeDefined() }) -}) -describe('ActivityListAction rows and observation', () => { it('lists rows with kind, label, and detail-or-status', () => { - render() + render() openList() const list = screen.getByRole('list', { name: zh['list.aria'] }) const items = within(list).getAllByRole('listitem') @@ -194,21 +138,52 @@ describe('ActivityListAction rows and observation', () => { expect(items[1]?.textContent).toContain('exit code: 3') }) + it('orders live rows first by start and settled rows newest-first with tie-breaks', () => { + const settled = (id: string, label: string, startedAt: number, finishedAt?: number): SessionJob => + job({ + id: id as SessionJob['id'], + label, + status: 'completed', + startedAt, + ...finishedAt !== undefined ? { finishedAt } : {}, + }) + render() + openList() + const labels = within(screen.getByRole('list', { name: zh['list.aria'] })) + .getAllByRole('listitem') + .map(item => item.querySelector('[title]')?.getAttribute('title')) + expect(labels).toEqual([ + 'live-2', 'live-1', 'settled-d', 'settled-a', 'settled-c', 'settled-b', 'settled-e', + ]) + expect(screen.getAllByText(zh['status.completed']).length).toBeGreaterThan(0) + }) +}) + +describe('JobListAction observation', () => { it('marks killed rows, renders every output line unfolded, and copies the command', async () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) const lines = Array.from({ length: 24 }, (_value, index) => `line ${index + 1}`).join('\n') - const view: ObservedActivity = { - activityId: 'bash-1' as ActivityId, + const view: ObservedJob = { + jobId: 'bash-1' as SessionJob['id'], text: lines, gapBefore: false, status: 'killed', streaming: false, } - render() + render() openList() expect(screen.getByText(zh['status.killed'])).toBeDefined() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) @@ -223,45 +198,13 @@ describe('ActivityListAction rows and observation', () => { expect(writeText).toHaveBeenCalledWith('pnpm run build') }) - it('orders live rows first by start and settled rows newest-first with tie-breaks', () => { - const settled = (id: string, label: string, startedAt: number, finishedAt?: number): ActivityRow => - row({ - id: id as ActivityId, - label, - status: 'completed', - startedAt, - ...finishedAt !== undefined ? { finishedAt } : {}, - }) - render() - openList() - const labels = within(screen.getByRole('list', { name: zh['list.aria'] })) - .getAllByRole('listitem') - .map(item => item.querySelector('[title]')?.getAttribute('title')) - expect(labels).toEqual([ - 'live-2', 'live-1', 'settled-d', 'settled-a', 'settled-c', 'settled-b', 'settled-e', - ]) - expect(screen.getAllByText(zh['status.completed']).length).toBeGreaterThan(0) - }) - it('shifts an overflowing popover back inside the viewport and follows resizes', () => { vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440) vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 344 } as DOMRect) const originalWidth = window.innerWidth Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) try { - render() + render() openList() const menu = screen.getByRole('list', { name: zh['list.aria'] }) // 700 - 12 - 440 - 344 = -96: the popover moves left to keep the margin. @@ -282,7 +225,7 @@ describe('ActivityListAction rows and observation', () => { const originalWidth = window.innerWidth Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) try { - render() + render() openList() const menu = screen.getByRole('list', { name: zh['list.aria'] }) // max(12 - 4, min(0, 700 - 12 - 800 - 4)) = 8: clamped at the left margin. @@ -294,39 +237,34 @@ describe('ActivityListAction rows and observation', () => { it('ignores other keys while open and closes once the roster empties', () => { const settledPair = [ - row({ id: 'bash-1' as ActivityId, status: 'completed', finishedAt: 2 }), - row({ id: 'bash-2' as ActivityId, status: 'completed', finishedAt: 3 }), + recordJob({ id: 'bash-1' as SessionJob['id'], status: 'completed', finishedAt: 2 }), + recordJob({ id: 'bash-2' as SessionJob['id'], status: 'completed', finishedAt: 3 }), ] - const { rerender } = render() - expect(screen.getByRole('button', { name: '2 个任务' })).toBeDefined() + const { rerender } = render() + expect(screen.getByRole('button', { name: '2 个后台任务' })).toBeDefined() openList() const list = screen.getByRole('list', { name: zh['list.aria'] }) fireEvent.keyDown(list, { key: 'a' }) expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() // The roster emptying unmounts the control entirely; the open flag resets first. - rerender() + rerender() expect(screen.queryByRole('button')).toBeNull() }) - it('starts observing on expand, renders the live text, and stops on collapse', () => { + it('starts observing on expand with the session identity, renders the live text, and stops on collapse', () => { const stop = vi.fn() const observe = vi.fn(() => stop) - const view: ObservedActivity = { - activityId: 'bash-1' as ActivityId, + const view: ObservedJob = { + jobId: 'bash-1' as SessionJob['id'], text: 'compiling…\n', gapBefore: false, status: 'running', streaming: true, } - render() + render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - expect(observe).toHaveBeenCalledWith('bash-1') + expect(observe).toHaveBeenCalledWith(SESSION, 'bash-1') expect(screen.getByText('compiling…')).toBeDefined() fireEvent.click(screen.getByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'pnpm run build') })) @@ -334,18 +272,15 @@ describe('ActivityListAction rows and observation', () => { }) it('surfaces retention gaps and stream failures above the panel', () => { - const view: ObservedActivity = { - activityId: 'bash-1' as ActivityId, + const view: ObservedJob = { + jobId: 'bash-1' as SessionJob['id'], text: 'tail only', gapBefore: true, status: 'running', streaming: false, error: 'connection lost', } - render() + render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) expect(screen.getByText(zh['output.gap'])).toBeDefined() @@ -354,9 +289,7 @@ describe('ActivityListAction rows and observation', () => { it('stops observation when the popover closes via Escape', () => { const stop = vi.fn() - render( stop)} />) + render( stop)} />) openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) fireEvent.keyDown(screen.getByRole('list', { name: zh['list.aria'] }), { key: 'Escape' }) @@ -368,13 +301,10 @@ describe('ActivityListAction rows and observation', () => { const stop = vi.fn() // One stable observe identity across renders, as the inject face provides. const observe = () => stop - const first = props({ rowsBySession: { [SESSION]: [row()] } }, observe) - const { rerender } = render() + const { rerender } = render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - rerender() + rerender() expect(stop).toHaveBeenCalledTimes(1) expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'other') })).toBeNull() }) diff --git a/packages/client/ui-activity/tsconfig.json b/packages/client/ui-jobs/tsconfig.json similarity index 89% rename from packages/client/ui-activity/tsconfig.json rename to packages/client/ui-jobs/tsconfig.json index 562e2c2228..4b8aae7886 100644 --- a/packages/client/ui-activity/tsconfig.json +++ b/packages/client/ui-jobs/tsconfig.json @@ -10,7 +10,6 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, - { "path": "../../api/activity-controller/tsconfig.client.json" }, { "path": "../../api/session-controller/tsconfig.client.json" }, { "path": "../locale" }, { "path": "../ui-conversation" }, diff --git a/packages/client/ui-activity/tsdown.config.ts b/packages/client/ui-jobs/tsdown.config.ts similarity index 76% rename from packages/client/ui-activity/tsdown.config.ts rename to packages/client/ui-jobs/tsdown.config.ts index 096771fe90..c2f3a27162 100644 --- a/packages/client/ui-activity/tsdown.config.ts +++ b/packages/client/ui-jobs/tsdown.config.ts @@ -1,6 +1,6 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-client-ui-activity', + '@deepseek-ai/dsh-client-ui-jobs', ['lib/types/index.js', 'lib/types/invariant.js'], ) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 066e930d6d..23b36a8440 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1144,8 +1144,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'conversation.session.header\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ - 'client-ui-activity ActivityListAction id \'activity-list\'', 'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'', + 'client-ui-jobs JobListAction id \'job-list\'', 'client-ui-schedule ScheduleCatalogAction id \'schedule-catalog\'', 'client-ui-subagent SubagentCatalogAction id \'subagent-catalog\'', 'experimental-client-ui-agent-team TeamAction id \'agent-team\'', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 21e7f10221..80cc02f219 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -81,68 +81,6 @@ export interface TypeApiEntry { /** Every harness `ctx.` service, sorted by key. */ export const SERVICE_API: readonly ServiceApiEntry[] = [ - { - key: 'activities', - summary: 'Abstract streaming-output registry.', - description: 'Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected.\n- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource.\n- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap.\n- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition\'s own carrier — sees every owner, while one registered under an agent composition\'s scope sees exactly the agents composed under it. Every listener is contained.', - methods: [ - { - signature: 'abstract open(spec: ActivityOpen): ActivityHandle', - description: 'Validate the spec, attach owner cleanup, and atomically register one activity. A rejection leaves no id; after return the producer owns the handle and the visible set has changed.', - parameters: [{ name: 'spec', description: 'activity identity, owner, and correlation.' }], - returns: 'the producer face of the registered activity.', - }, - { - signature: 'abstract list(caller?: Agent): ActivitySnapshot[]', - description: 'List caller-owned and unowned activities in registration order without exposing another session\'s labels or output.', - parameters: [{ name: 'caller', description: 'reading agent; a non-agent caller sees only unowned activities.' }], - returns: 'fresh snapshots.', - }, - { - signature: 'abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot', - description: 'Return a fresh snapshot. Throws for an unknown or foreign activity.', - parameters: [{ name: 'id', description: 'activity to look up.' }, { name: 'caller', description: 'reading agent checked against the owner.' }], - returns: 'a fresh snapshot.', - }, - { - signature: 'abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead', - description: 'Read retained output from an absolute byte offset without consuming it. Resume by passing a previous read\'s `next`; a foreign offset inside a retained chunk returns that whole chunk (its `at` may precede `from`). Throws for an unknown or foreign activity or a negative or non-integer offset.', - parameters: [{ name: 'id', description: 'activity to read.' }, { name: 'from', description: 'absolute byte offset to read from (0 for the retained head).' }, { name: 'caller', description: 'reading agent checked against the owner.' }], - returns: 'retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.', - }, - { - signature: 'abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void', - description: 'Register an effect-scoped observer of visible-set changes: opening, detail updates, settlement, owner-disposal removal, and the emptying that service disposal commits — so an observer re-reads rather than accumulating deltas. Listeners are contained and never awaited.', - parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned activity changed and every caller\'s set did.' }], - returns: 'disposer that unregisters the listener.', - }, - { - signature: 'abstract onOutput(listener: ActivityOutputListener): () => void', - description: 'Register an effect-scoped observer of stream advancement — one signal per committed append and one at settlement, carrying only the activity id. A consumer schedules a read from its own cursor; the registry never pushes payloads. Listeners are contained and never awaited.', - parameters: [{ name: 'listener', description: 'receives the id whose stream advanced.' }], - returns: 'disposer that unregisters the listener.', - }, - ], - }, - { - key: 'activityController', - summary: 'Host service backing the generated `ctx.remote.activity` namespace.', - description: 'Host service backing the generated `ctx.remote.activity` namespace.', - methods: [ - { - signature: '@Remote({ mode: \'stream\' }) control(signal: AbortSignal): AsyncIterable', - description: 'Stream a complete roster baseline followed by whole-bucket replacements.', - parameters: [{ name: 'signal', description: 'generation cancellation.' }], - returns: 'baseline followed by per-owner roster replacement frames.', - }, - { - signature: '@Remote({ mode: \'stream\' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable', - description: 'Stream one activity\'s retained output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursors never observe these reads.', - parameters: [{ name: 'request', description: 'target activity and optional resume offset.' }, { name: 'signal', description: 'generation cancellation.' }], - returns: 'anchor, coalesced output frames, and the terminal status.', - }, - ], - }, { key: 'agentDefaultModel', summary: 'Owns the default model selection independently of any Host or transport.', @@ -1098,7 +1036,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'jobs', summary: 'Abstract background job registry.', - description: 'Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.', + description: 'Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.\n- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected.\n- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle.', methods: [ { signature: 'abstract start(spec: JobStart): JobId', @@ -1148,6 +1086,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned job changed and every caller\'s set did.' }], returns: 'disposer that unregisters the listener.', }, + { + signature: 'abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead', + description: 'Read retained record output from an absolute byte offset without consuming it. Resume by passing a previous read\'s `next`; a foreign offset inside a retained chunk returns that whole chunk (its `at` may precede `from`). Never marks the job reported. Throws for an unknown or foreign job, a job without a JobStart.record declaration, or a negative or non-integer offset.', + parameters: [{ name: 'id', description: 'job to read.' }, { name: 'from', description: 'absolute byte offset to read from (0 for the retained head).' }, { name: 'caller', description: 'reading agent checked against the owner.' }], + returns: 'retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.', + }, + { + signature: 'abstract onOutput(listener: JobOutputListener): () => void', + description: 'Register an effect-scoped observer of record advancement — one signal per committed append and one at settlement, carrying only the job id. A consumer schedules a readRecord from its own cursor; the registry never pushes payloads. Delivery is owner-relative on the same terms as onJobsChanged. Listeners are contained and never awaited.', + parameters: [{ name: 'listener', description: 'receives the id whose record advanced.' }], + returns: 'disposer that unregisters the listener.', + }, { signature: 'abstract attachController(name: string): () => void', description: 'Attach an effect-scoped controller that can read and stop jobs. It serves the owners its registering context\'s scope covers, and start refuses an owner no attached controller serves.', @@ -1505,6 +1455,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], returns: 'one complete baseline followed by live replacement frames.', }, + { + signature: '@Remote({ mode: \'stream\' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable', + description: 'Stream one job\'s retained record output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session resolves the fenced-read caller; the registry rejects a job the session does not own, a job without a record, or an unknown job.', + parameters: [{ name: 'request', description: 'target job, owning session, and optional resume offset.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], + returns: 'anchor, coalesced output frames, and the terminal status.', + }, ], }, { @@ -3473,94 +3429,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the Service and Event signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ - { - name: 'ActivitiesChangedListener', - declaration: 'export type ActivitiesChangedListener = (owner: Agent | undefined) => void;', - }, - { - name: 'ActivityAppendOptions', - declaration: 'export interface ActivityAppendOptions {\n channel?: ActivityChannel;\n gapBefore?: true;\n}', - }, - { - name: 'ActivityChannel', - declaration: 'export type ActivityChannel = \'stdout\' | \'stderr\';', - }, - { - name: 'ActivityControlFrame', - declaration: 'export type ActivityControlFrame = {\n readonly type: \'baseline\';\n readonly activities: readonly ActivityRow[];\n} | {\n readonly type: \'rows\';\n readonly sessionId?: SessionId;\n readonly activities: readonly ActivityRow[];\n};', - }, - { - name: 'ActivityCorrelation', - declaration: 'export interface ActivityCorrelation {\n callId?: ToolCallId;\n jobId?: JobId;\n}', - }, - { - name: 'ActivityHandle', - declaration: 'export interface ActivityHandle {\n readonly id: ActivityId;\n append(text: string, options?: ActivityAppendOptions): void;\n updateDetail(detail: string): void;\n end(outcome: ActivityOutcome): void;\n}', - }, - { - name: 'ActivityId', - declaration: 'export type ActivityId = Branded<\'ActivityId\'>;', - }, - { - name: 'ActivityKind', - declaration: 'export type ActivityKind = ActivityKindMap[keyof ActivityKindMap];', - }, - { - name: 'ActivityKindMap', - declaration: 'export interface ActivityKindMap {\n bash: \'bash\';\n}', - }, - { - name: 'ActivityObserveFrame', - declaration: 'export type ActivityObserveFrame = {\n readonly type: \'opened\';\n readonly activityId: ActivityId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly ActivityWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n readonly finishedAt?: number;\n};', - }, - { - name: 'ActivityObserveRequest', - declaration: 'export interface ActivityObserveRequest {\n readonly activityId: ActivityId;\n readonly from?: number;\n}', - }, - { - name: 'ActivityOpen', - declaration: 'export interface ActivityOpen {\n kind: ActivityKind;\n label: string;\n owner?: Agent;\n correlation?: ActivityCorrelation;\n}', - }, - { - name: 'ActivityOutcome', - declaration: 'export interface ActivityOutcome {\n status: \'completed\' | \'failed\' | \'killed\';\n detail?: string;\n}', - }, - { - name: 'ActivityOutputChunk', - declaration: 'export interface ActivityOutputChunk {\n at: number;\n text: string;\n channel?: ActivityChannel;\n gapBefore?: true;\n}', - }, - { - name: 'ActivityOutputListener', - declaration: 'export type ActivityOutputListener = (id: ActivityId) => void;', - }, - { - name: 'ActivityRead', - declaration: 'export interface ActivityRead {\n chunks: readonly ActivityOutputChunk[];\n next: number;\n lossy: boolean;\n}', - }, - { - name: 'ActivityRow', - declaration: 'export interface ActivityRow {\n readonly id: ActivityId;\n readonly kind: string;\n readonly label: string;\n readonly sessionId?: SessionId;\n readonly correlation?: ActivityRowCorrelation;\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal: number;\n}', - }, - { - name: 'ActivityRowCorrelation', - declaration: 'export interface ActivityRowCorrelation {\n readonly callId?: ToolCallId;\n readonly jobId?: JobId;\n}', - }, - { - name: 'ActivityRowStatus', - declaration: 'export type ActivityRowStatus = \'running\' | \'completed\' | \'failed\' | \'killed\';', - }, - { - name: 'ActivitySnapshot', - declaration: 'export interface ActivitySnapshot {\n id: ActivityId;\n kind: ActivityKind;\n label: string;\n ownerSession?: SessionId;\n correlation?: ActivityCorrelation;\n status: ActivityStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n outputTotal: number;\n outputEarliest: number;\n}', - }, - { - name: 'ActivityStatus', - declaration: 'export type ActivityStatus = \'running\' | \'completed\' | \'failed\' | \'killed\';', - }, - { - name: 'ActivityWireChunk', - declaration: 'export interface ActivityWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}', - }, { name: 'AdapterRegistrationHandle', declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', @@ -4349,6 +4217,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'InvokeRemoteRequest', declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly signal?: AbortSignal;\n}', }, + { + name: 'JobAppendOptions', + declaration: 'export interface JobAppendOptions {\n channel?: JobChannel;\n gapBefore?: true;\n}', + }, + { + name: 'JobChannel', + declaration: 'export type JobChannel = \'stdout\' | \'stderr\';', + }, { name: 'JobDoneListener', declaration: 'export type JobDoneListener = (snapshot: JobSnapshot, owner: Agent | undefined) => void | PromiseLike;', @@ -4373,21 +4249,33 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JobOutcome', declaration: 'export interface JobOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n output?: string;\n}', }, + { + name: 'JobOutputListener', + declaration: 'export type JobOutputListener = (id: JobId) => void;', + }, { name: 'JobRead', declaration: 'export interface JobRead {\n text: string;\n snapshot: JobSnapshot;\n}', }, + { + name: 'JobRecordChunk', + declaration: 'export interface JobRecordChunk {\n at: number;\n text: string;\n channel?: JobChannel;\n gapBefore?: true;\n}', + }, + { + name: 'JobRecordRead', + declaration: 'export interface JobRecordRead {\n chunks: readonly JobRecordChunk[];\n next: number;\n lossy: boolean;\n}', + }, { name: 'JobsChangedListener', declaration: 'export type JobsChangedListener = (owner: Agent | undefined) => void;', }, { name: 'JobSnapshot', - declaration: 'export interface JobSnapshot {\n id: JobId;\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: JobStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}', + declaration: 'export interface JobSnapshot {\n id: JobId;\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: JobStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n outputTotal?: number;\n outputEarliest?: number;\n}', }, { name: 'JobStart', - declaration: 'export interface JobStart {\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n owner?: Agent;\n run(): JobHooks;\n}', + declaration: 'export interface JobStart {\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n owner?: Agent;\n record?: true;\n run(job: RunningJob): JobHooks;\n}', }, { name: 'JobStatus', @@ -4877,6 +4765,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RunnerFailureRule', declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}', }, + { + name: 'RunningJob', + declaration: 'export interface RunningJob {\n readonly id: JobId;\n append(text: string, options?: JobAppendOptions): void;\n updateDetail(detail: string): void;\n}', + }, { name: 'SandboxEnforcement', declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';', @@ -5103,7 +4995,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionJob', - declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n}', + declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal?: number;\n}', + }, + { + name: 'SessionJobWireChunk', + declaration: 'export interface SessionJobWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}', }, { name: 'SessionLineageNode', @@ -5137,6 +5033,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionObservationOptions', declaration: 'export interface SessionObservationOptions {\n readonly signal?: AbortSignal;\n readonly projectionMode?: \'all\' | \'none\';\n}', }, + { + name: 'SessionObserveJobFrame', + declaration: 'export type SessionObserveJobFrame = {\n readonly type: \'opened\';\n readonly jobId: JobId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: SessionJob[\'status\'];\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly SessionJobWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: SessionJob[\'status\'];\n readonly detail?: string;\n readonly finishedAt?: number;\n};', + }, + { + name: 'SessionObserveJobRequest', + declaration: 'export interface SessionObserveJobRequest {\n readonly sessionId?: SessionId;\n readonly jobId: JobId;\n readonly from?: number;\n}', + }, { name: 'SessionOpenWorkspacePathRequest', declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly path: string;\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da5b3091d6..d938573ee5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -625,110 +625,6 @@ importers: specifier: workspace:^ version: link:../../interaction/user-approval - packages/activity/activity: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - - packages/activity/activity-local: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-include': - specifier: workspace:^ - version: link:../../../vendor/include - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-activity': - specifier: workspace:^ - version: link:../activity - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - - packages/api/activity-controller: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - zod: - specifier: ^4.4.3 - version: 4.4.3 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-activity': - specifier: workspace:^ - version: link:../../activity/activity - '@deepseek-ai/dsh-activity-local': - specifier: workspace:^ - version: link:../../activity/activity-local - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-api-gateway': - specifier: workspace:^ - version: link:../gateway - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../../client/connection - '@deepseek-ai/dsh-client-store': - specifier: workspace:^ - version: link:../../client/store - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol - packages/api/gateway: dependencies: '@deepseek-ai/dsh-deque': @@ -793,9 +689,6 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets - '@deepseek-ai/dsh-api-activity-controller': - specifier: workspace:^ - version: link:../activity-controller '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway @@ -1174,9 +1067,6 @@ importers: '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer - '@deepseek-ai/dsh-activity-local': - specifier: workspace:^ - version: link:../../activity/activity-local '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -1571,9 +1461,6 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets - '@deepseek-ai/dsh-api-activity-controller': - specifier: workspace:^ - version: link:../../api/activity-controller '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -1601,9 +1488,6 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules - '@deepseek-ai/dsh-client-ui-activity': - specifier: workspace:^ - version: link:../../client/ui-activity '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../client/ui-agent-preset @@ -1643,6 +1527,9 @@ importers: '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../../client/ui-input-trigger + '@deepseek-ai/dsh-client-ui-jobs': + specifier: workspace:^ + version: link:../../client/ui-jobs '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../client/ui-layout @@ -1961,48 +1848,6 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - packages/client/ui-activity: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-activity-controller': - specifier: workspace:^ - version: link:../../api/activity-controller - '@deepseek-ai/dsh-api-session-controller': - specifier: workspace:^ - version: link:../../api/session-controller - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-renderer': - specifier: workspace:^ - version: link:../ui-renderer - '@deepseek-ai/dsh-client-ui-session': - specifier: workspace:^ - version: link:../ui-session - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-agent-preset: devDependencies: '@deepseek-ai/cordis': @@ -2723,6 +2568,48 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-jobs: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-layout: devDependencies: '@deepseek-ai/cordis': @@ -8288,9 +8175,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-activity': - specifier: workspace:^ - version: link:../../activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -8416,9 +8300,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-activity': - specifier: workspace:^ - version: link:../../activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -10405,12 +10286,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-activity': - specifier: workspace:^ - version: link:../../activity/activity - '@deepseek-ai/dsh-activity-local': - specifier: workspace:^ - version: link:../../activity/activity-local '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -10575,9 +10450,6 @@ importers: '@deepseek-ai/dsh-acp': specifier: workspace:^ version: link:../../packages/acp/acp - '@deepseek-ai/dsh-activity': - specifier: workspace:^ - version: link:../../packages/activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 2ae2320a08..f735144468 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -13,7 +13,6 @@ "@deepseek-ai/cosmokit": "workspace:^", "@deepseek-ai/dsh": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 02eccc59d1..d477494d34 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -109,8 +109,6 @@ export const SERVICE_PAGE: Record = { subprocess: 'subprocess.md', systemPrompt: 'system-prompt.md', jobs: 'jobs.md', - activities: 'activity.md', - activityController: 'activity.md', sessionTelemetry: 'session-telemetry.md', agentTeams: 'agent-team.md', tokenMeter: 'token-meter.md', @@ -175,7 +173,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API', workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API', - activityFeed: 'client-side interface-typed browser service — packages/api/activity-controller/README.md owns the API', + jobOutput: 'client-side interface-typed browser service — packages/api/session-controller/README.md owns the API', } /** @@ -322,6 +320,8 @@ export const LINK_MAP: Readonly> = { SessionId: 'core.md', SessionListRequest: 'session.md', SessionListValue: 'session.md', + SessionObserveJobFrame: 'session.md', + SessionObserveJobRequest: 'session.md', ModelCatalog: 'session.md', SessionOpenWorkspacePathRequest: 'session.md', SessionOpenWorkspacePathValue: 'session.md', @@ -501,22 +501,18 @@ export const LINK_MAP: Readonly> = { PromptSectionOrderName: 'system-prompt.md', SystemPrompt: 'system-prompt.md', ToolProviderResult: 'system-prompt.md', - ActivitiesChangedListener: 'activity.md', - ActivityHandle: 'activity.md', - ActivityId: 'activity.md', - ActivityOpen: 'activity.md', - ActivityOutputListener: 'activity.md', - ActivityRead: 'activity.md', - ActivitySnapshot: 'activity.md', - ActivityControlFrame: 'activity.md', - ActivityObserveFrame: 'activity.md', - ActivityObserveRequest: 'activity.md', JobDoneListener: 'jobs.md', JobId: 'jobs.md', JobRead: 'jobs.md', JobSnapshot: 'jobs.md', JobStart: 'jobs.md', JobsChangedListener: 'jobs.md', + JobAppendOptions: 'jobs.md', + JobChannel: 'jobs.md', + JobOutputListener: 'jobs.md', + JobRecordChunk: 'jobs.md', + JobRecordRead: 'jobs.md', + RunningJob: 'jobs.md', CreateTeamTaskRequest: 'agent-team.md', SendTeamMessageRequest: 'agent-team.md', SendTeamMessageResult: 'agent-team.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 008991a00c..3638b0c002 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -198,13 +198,6 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on.', }, - { - key: 'activityController', - pkg: 'api-activity-controller', - title: 'Host Activity Remote controller', - mode: 'core', - note: 'Owns the live-activity roster stream and per-activity non-consuming output observation through the generated Remote namespace.', - }, { key: 'invariants', pkg: 'invariants', @@ -583,17 +576,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Background job registry', mode: 'seam', implementations: ['jobs-local'], - consumers: ['tool-bash', 'tool-terminal', 'tool-subagent', 'tool-jobs'], - note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.', - }, - { - key: 'activities', - pkg: 'activity', - title: 'Live activity observation registry', - mode: 'seam', - implementations: ['activity-local'], - consumers: ['tool-bash', 'tool-pwsh', 'tool-workflow', 'activity-controller'], - note: 'Producers mirror live output and status into non-consuming offset-addressed streams; activity-controller serves them to browsers, and the model-facing paths (jobs cursors, tool results) stay untouched.', + consumers: ['tool-bash', 'tool-pwsh', 'tool-terminal', 'tool-subagent', 'tool-jobs'], + note: 'Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.', }, { key: 'web', diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 15b481383c..63054fd183 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -5,7 +5,6 @@ const CLIENT_FACE_INCLUDE: readonly string[] = [] /** Packages exempted from automatic Client/Host treatment despite declaring `dsh.client`. */ const CLIENT_FACE_EXCLUDE: readonly string[] = [ - '@deepseek-ai/dsh-api-activity-controller', '@deepseek-ai/dsh-api-session-controller', '@deepseek-ai/dsh-api-workspace-controller', ] diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index 26263fe018..4294cff84b 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -123,7 +123,6 @@ function hostRuntimeFixture(): { describe('package dependency scope', () => { it('keeps the measured Host relay roster explicit', () => { expect(PACKAGE_DEPENDENCY_POLICY.clientFaceExclude).toEqual([ - '@deepseek-ai/dsh-api-activity-controller', '@deepseek-ai/dsh-api-session-controller', '@deepseek-ai/dsh-api-workspace-controller', ]) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index ec942708ea..a563dc46bd 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -46,10 +46,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, - 'packages/activity/activity': { kind: 'none', reason: 'Pure observation seam for humans; activity output becomes model-visible only through producers\' existing tool results and jobs reads.' }, - 'packages/activity/activity-local': { kind: 'none', reason: 'In-memory observation provider; producers own every model-visible surface.' }, - 'packages/api/activity-controller': { kind: 'none', reason: 'Browser/Host observation transport; registers no prompt, tool, or session event.' }, - 'packages/client/ui-activity': { kind: 'none', reason: 'Browser-side presentation of host-observed output; touches no prompt, message, schema, stream, or tool result.' }, + 'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side presentation of host-observed output; touches no prompt, message, schema, stream, or tool result.' }, 'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, 'packages/shell/shell-env': { kind: 'indirect', reason: 'The env service exposes managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' }, 'packages/shell/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index c09254eec2..d8ba3b6830 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -47,13 +47,6 @@ "@deepseek-ai/dsh-api-gateway/client": ["./packages/api/gateway/src/client/index.ts"], "@deepseek-ai/dsh-api-gateway/invariant": ["./packages/api/gateway/src/invariant.ts"], "@deepseek-ai/dsh-api-gateway/types": ["./packages/api/gateway/src/types.ts"], - "@deepseek-ai/dsh-api-activity-controller": ["./packages/api/activity-controller/src/index.ts"], - "@deepseek-ai/dsh-api-activity-controller/client": ["./packages/api/activity-controller/src/client/index.ts"], - "@deepseek-ai/dsh-api-activity-controller/invariant": ["./packages/api/activity-controller/src/invariant.ts"], - "@deepseek-ai/dsh-api-activity-controller/types": ["./packages/api/activity-controller/src/types.ts"], - "@deepseek-ai/dsh-client-ui-activity": ["./packages/client/ui-activity/src"], - "@deepseek-ai/dsh-client-ui-activity/client": ["./packages/client/ui-activity/src/client/index.ts"], - "@deepseek-ai/dsh-client-ui-activity/invariant": ["./packages/client/ui-activity/src/invariant.ts"], "@deepseek-ai/dsh-api-session-controller": ["./packages/api/session-controller/src/index.ts"], "@deepseek-ai/dsh-api-session-controller/client": ["./packages/api/session-controller/src/client/index.ts"], "@deepseek-ai/dsh-api-session-controller/invariant": ["./packages/api/session-controller/src/invariant.ts"], @@ -108,7 +101,6 @@ "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], "@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"], - "@deepseek-ai/dsh-activity/brand": ["./packages/activity/activity/src/brand.ts"], "@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"], "@deepseek-ai/dsh-authorization/types": ["./packages/credentials/authorization/src/types.ts"], "@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"], @@ -212,6 +204,8 @@ "@deepseek-ai/dsh-experimental-client-ui-agent-team": ["./packages/experimental/client-ui-agent-team/src"], "@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"], "@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"], + "@deepseek-ai/dsh-client-ui-jobs/client": ["./packages/client/ui-jobs/src/client/index.ts"], + "@deepseek-ai/dsh-client-ui-jobs/invariant": ["./packages/client/ui-jobs/src/invariant.ts"], "@deepseek-ai/dsh-client-ui-schedule": ["./packages/client/ui-schedule/src"], "@deepseek-ai/dsh-client-ui-schedule/client": ["./packages/client/ui-schedule/src/client/index.ts"], "@deepseek-ai/dsh-client-ui-schedule/invariant": ["./packages/client/ui-schedule/src/invariant.ts"], @@ -255,10 +249,6 @@ "@deepseek-ai/dsh-acp/invariant": ["./packages/acp/acp/src/invariant.ts"], "@deepseek-ai/dsh-acp-app": ["./packages/bundle/acp-app/src"], "@deepseek-ai/dsh-acp-app/invariant": ["./packages/bundle/acp-app/src/invariant.ts"], - "@deepseek-ai/dsh-activity": ["./packages/activity/activity/src"], - "@deepseek-ai/dsh-activity/invariant": ["./packages/activity/activity/src/invariant.ts"], - "@deepseek-ai/dsh-activity-local": ["./packages/activity/activity-local/src"], - "@deepseek-ai/dsh-activity-local/invariant": ["./packages/activity/activity-local/src/invariant.ts"], "@deepseek-ai/dsh-agent": ["./packages/core/agent/src"], "@deepseek-ai/dsh-agent-default-model": ["./packages/core/agent-default-model/src"], "@deepseek-ai/dsh-agent-default-model/invariant": ["./packages/core/agent-default-model/src/invariant.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 2426473861..bb6735bbc7 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -57,7 +57,6 @@ { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/session-controller/tsconfig.client.json" }, - { "path": "./packages/api/activity-controller/tsconfig.client.json" }, { "path": "./packages/api/workspace-controller/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/extensions/cordis-client-runner" }, @@ -78,7 +77,7 @@ { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-subagent" }, - { "path": "./packages/client/ui-activity" }, + { "path": "./packages/client/ui-jobs" }, { "path": "./packages/experimental/client-ui-agent-team" }, { "path": "./packages/client/ui-schedule" }, { "path": "./packages/client/ui-directory-picker-browse" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index b9fac3cdff..4bbbda019b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -81,7 +81,7 @@ "apps/web/tests/subagent-interrupt-ui.e2e.ts", "apps/web/tests/sidebar-subagent-activity.e2e.ts", "apps/web/tests/background-job-list.e2e.ts", - "apps/web/tests/live-activity-stream.e2e.ts", + "apps/web/tests/live-job-stream.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", "apps/web/tests/turn-tail-actions.e2e.ts", @@ -152,7 +152,6 @@ { "path": "./packages/api/gateway/tsconfig.host.json" }, { "path": "./packages/api/remotes/tsconfig.host.json" }, { "path": "./packages/api/session-controller/tsconfig.host.json" }, - { "path": "./packages/api/activity-controller/tsconfig.host.json" }, { "path": "./packages/api/settings-controller" }, { "path": "./packages/api/workspace-controller/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, @@ -298,8 +297,6 @@ { "path": "./packages/jobs/jobs" }, { "path": "./packages/jobs/jobs-local" }, { "path": "./packages/jobs/tool-jobs" }, - { "path": "./packages/activity/activity" }, - { "path": "./packages/activity/activity-local" }, { "path": "./packages/experimental/agent-team" }, { "path": "./packages/experimental/agent-team-profile" }, { "path": "./packages/experimental/tool-agent-team" }, From 3c79e71cdc467ab651efd60fad7a94db11da6cfc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 18:34:36 +0800 Subject: [PATCH 017/284] docs(i18n): mirror the record refactor across the bilingual pairs Chinese sides of the jobs, jobs-local, tool-bash, tool-pwsh, tool-workflow, session-controller, and ui-jobs pages follow their English edits; the generated en-only catalogs (config catalog, capability seams, event producer/consumer) get the same section surgery by hand; the activity subsystem page triplet is fully removed and the jobs page carries the record contract in both languages with fresh type-equiv manifest entries. The three prior activity notes point at the superseding note. Web e2e label assertions follow the ui-jobs copy, and the three test call sites that invoked run() bare pass the producer face. --- ...9-01-jobs-absorb-activity-record.i18n.yaml | 6 + .../2026-09-01-jobs-absorb-activity-record.md | 2 +- ...26-09-01-jobs-absorb-activity-record.zh.md | 2 +- ...08-08-web-background-job-display.i18n.yaml | 4 +- ...-08-24-activity-observation-seam.i18n.yaml | 4 +- .../2026-08-25-unified-task-list.i18n.yaml | 4 +- apps/web/tests/background-job-list.e2e.ts | 6 +- docs/architecture.i18n.yaml | 4 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.zh.md | 20 +-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.zh.md | 61 +++------- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.zh.md | 14 +-- docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/activity.i18n.yaml | 6 - docs/subsystems/jobs.i18n.yaml | 4 +- docs/subsystems/jobs.zh.md | 114 ++++++++++++++++-- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.zh.md | 4 + packages/client/README.i18n.yaml | 4 +- packages/jobs/jobs-local/README.i18n.yaml | 4 +- packages/jobs/jobs-local/README.zh.md | 11 +- packages/jobs/jobs-local/tests/jobs.spec.ts | 6 +- packages/jobs/jobs/README.i18n.yaml | 4 +- packages/jobs/jobs/README.zh.md | 13 +- packages/shell/tool-bash/README.i18n.yaml | 4 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-pwsh/README.i18n.yaml | 4 +- packages/shell/tool-pwsh/README.zh.md | 2 +- .../workflow/tool-workflow/README.i18n.yaml | 4 +- packages/workflow/tool-workflow/README.zh.md | 3 +- scripts/type-equiv.manifest.json | 15 +++ 33 files changed, 214 insertions(+), 137 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml delete mode 100644 docs/subsystems/activity.i18n.yaml diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml new file mode 100644 index 0000000000..87e45ad40a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +2026-09-01-jobs-absorb-activity-record.md: cc2cc8606a50e2d7c06e183a09df709b75b9bd35 +2026-09-01-jobs-absorb-activity-record.zh.md: 8b6739192b56899899773ba1e7af2bf1bd846b0b diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md index 501cdcf801..cc2cc8606a 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -22,7 +22,7 @@ The one activity without a job was the foreground workflow mirror. Everything el - **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `outputTotal` is present. The two-roster join is deleted. - **Foreground workflow loses its live panel deliberately.** `tool-workflow`'s activity mirror is removed; a foreground run surfaces through its recorded run/member lifecycle events only, and the per-line `workflow/phase` / `workflow/log` narration has no observer until workflow gains `run_in_background` and registers a record job. Jobs stay a pure background registry — no `foreground` mode bit, no model-invisible rows, no run-less rows. -## Rejected +## Alternatives considered - **A `foreground: true` job mode** to keep the foreground workflow panel: it needs two coupled enforcement points (reported-at-birth and a `job_list` filter) whose divergence double-delivers or leaks rows to the model, for one edge feature replaceable by `run_in_background`. - **Serving model reads from the record** (deleting `readOutput`): model reads are producer-formatted (truncation and spill notices, sandbox markers) and consuming; the record is raw, channel-labeled, and non-consuming. Unifying them moves producer-specific formatting into the registry or noise into the observer stream. diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md index 40e795146c..8b6739192b 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -22,7 +22,7 @@ Status: implemented - **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `outputTotal` 存在时可展开。双名册 join 被删除。 - **前台 workflow 有意失去实时面板。** `tool-workflow` 的 activity 镜像被移除;前台 run 只通过已记录的 run/member 生命周期事件呈现,逐行的 `workflow/phase` / `workflow/log` 叙述在 workflow 获得 `run_in_background` 并登记 record job 之前没有观察者。jobs 保持纯后台注册表——没有 `foreground` 模式位、没有模型不可见行、没有无 run 行。 -## 已否决 +## 曾考虑的替代方案 - **`foreground: true` job 模式**以保留前台 workflow 面板:它需要两个耦合的执行点(出生即 reported 与 `job_list` 过滤),两者一旦分叉就会双投递或向模型泄漏行,而它换来的只是一个可被 `run_in_background` 替代的边缘特性。 - **用 record 服务模型读取**(删除 `readOutput`):模型读取是生产者格式化的(截断与 spill 提示、sandbox 标记)且消耗型;record 是原始、带 channel 标签、非消耗的。统一二者要么把生产者特有格式化搬进注册表,要么把噪声混入观察者流。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 9b64d73885..444bbac0ad 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 23074b5d38cdab8c45925c75a7d0b851f7be2ece -2026-08-08-web-background-job-display.zh.md: 06645267ee8afadf284b7654b0cd4a672fe7e4b4 +2026-08-08-web-background-job-display.md: 3dd003c0e972ec7a5857e84980520eee93b53955 +2026-08-08-web-background-job-display.zh.md: 5d1ee4bada9c1966e4329d35f5a4db64300ca3b4 diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml index 96434aa147..651ef99e3c 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md -2026-08-24-activity-observation-seam.md: 88109cb3404132885bde359af0907a29ea1bee2c -2026-08-24-activity-observation-seam.zh.md: b8aa938edfd0019794211975c616aa50381ffe77 +2026-08-24-activity-observation-seam.md: 29ee08b1bb7f72a63d2f84278af62587060de3fe +2026-08-24-activity-observation-seam.zh.md: 97ac4f195c83cdd4f16d2f0a108abd2b3338d95d diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml index cba3c8b11c..eab6f6fa1b 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-25-unified-task-list.md -2026-08-25-unified-task-list.md: e67ca84d96424e723f6a5660cbb2b02733e42892 -2026-08-25-unified-task-list.zh.md: 2a71b04ec33b635478bd4897e8777abb3081a8df +2026-08-25-unified-task-list.md: daf6582bf2c9e4b4a5ce26d6ae632fb78b30dbcb +2026-08-25-unified-task-list.zh.md: 624747396e175a216047239333575322ec4b1808 diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index 57fdd1358d..1030369886 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { it('shows a running background job in the session header without a refresh', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running')) // Polling for zero would pass at t=0 before delivery and prove nothing. - const trigger = page.getByRole('button', { name: '1 task running' }) + const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) const started = await scaffold.ctx.tools.execute({ @@ -97,7 +97,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { await trigger.waitFor({ timeout: 15_000 }) await trigger.click() - const row = page.getByRole('list', { name: 'Tasks' }).getByRole('listitem').first() + const row = page.getByRole('list', { name: 'Background jobs' }).getByRole('listitem').first() await row.waitFor({ timeout: 10_000 }) await expect.poll(() => row.textContent()).toContain(COMMAND) @@ -111,7 +111,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled')) expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') - const idle = page.getByRole('button', { name: '1 task' }) + const idle = page.getByRole('button', { name: '1 background job' }) await idle.waitFor({ timeout: 20_000 }) const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 25942087ae..1f40c3002f 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 docs/architecture.md -architecture.md: 8a2f9b772c6c50aed0389421fd26a713668b1907 -architecture.zh.md: cc291fb0184bcc62a283741ff453e62d7400eb0a +architecture.md: f36c956d9a14f6b4490d2cd5ce03d807da4a94ad +architecture.zh.md: 67e2ae905cdf0ddb210f320fd7b8cb34395d99d4 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 9327ad1685..5b19c660e0 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -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 docs/capability-seams.md -capability-seams.md: 6c0867eeb8d47fefcbbf270e79123935cdae2aa3 -capability-seams.zh.md: aa741f96303e0dbf5f883c2fb4a2af927e165f63 +capability-seams.md: db2b8db95647204b4a49a63c6a7f1d97b942e635 +capability-seams.zh.md: f145241760737654c39662c76ddd027cf22bc409 diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index aa741f9630..f145241760 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -47,8 +47,6 @@ flowchart LR pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
        Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
        Host directory-picking Remote controller"] - pkg_api_activity_controller["api-activity-controller"] - svc_activityController["ctx.activityController
        Host Activity Remote controller"] svc_invariants["ctx.invariants
        Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -188,11 +186,6 @@ flowchart LR svc_jobs["ctx.jobs
        Background job registry"] pkg_jobs_local["jobs-local"] pkg_tool_jobs["tool-jobs"] - pkg_activity["activity"] - svc_activities["ctx.activities
        Live activity observation registry"] - pkg_activity_local["activity-local"] - pkg_tool_workflow["tool-workflow"] - pkg_activity_controller["activity-controller"] pkg_web["web"] svc_web["ctx.web
        Web access provider registry"] pkg_web_search_exa["web-search-exa"] @@ -216,6 +209,7 @@ flowchart LR pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
        Workflow script engine"] pkg_workflow_worker_thread["workflow-worker-thread"] + pkg_tool_workflow["tool-workflow"] pkg_webhook["webhook"] svc_webhookRuntime["ctx.webhookRuntime
        Webhook rule runtime"] pkg_webhook_github["webhook-github"] @@ -225,13 +219,10 @@ flowchart LR pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
        Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
        Dynamic Cordis inspect registry"] - pkg_activity --> svc_activities - pkg_activity_local --> svc_activities pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets - pkg_api_activity_controller --> svc_activityController pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences @@ -344,10 +335,6 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry - svc_activities --> pkg_activity_controller - svc_activities --> pkg_tool_bash - svc_activities --> pkg_tool_pwsh - svc_activities --> pkg_tool_workflow svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_agent_spine_demo @@ -384,6 +371,7 @@ flowchart LR svc_invariants --> pkg_session svc_jobs --> pkg_tool_bash svc_jobs --> pkg_tool_jobs + svc_jobs --> pkg_tool_pwsh svc_jobs --> pkg_tool_subagent svc_jobs --> pkg_tool_terminal svc_llm --> pkg_agent_loop @@ -491,7 +479,6 @@ flowchart LR | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | -| `ctx.activityController` | `core` | [`api-activity-controller`](../packages/api/activity-controller) | - | - | - | 通过生成的 Remote namespace 负责实时活动 roster 流与逐活动的非消费输出观察。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | @@ -537,8 +524,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 | | `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | -| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | -| `ctx.activities` | `seam` | [`activity`](../packages/activity/activity) | [`activity-local`](../packages/activity/activity-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-workflow`](../packages/workflow/tool-workflow), `activity-controller` | - | 生产者把实时输出与状态镜像进非消费的偏移寻址流;activity-controller 把它们提供给浏览器,模型可见路径(jobs 游标、工具结果)保持不动。 | +| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 88653dc4ad..c71be1f3c9 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 424eff1c5e6e4ab96687e0688280cfc20cd31729 -config-catalog.zh.md: 9591737dbf8593edb594ccc3373ab8354a2201aa +config-catalog.md: 64b72e0ff774bd7ecd6142a1ee1f0663e28194b7 +config-catalog.zh.md: ab2c0aac8f341e5855299236787421f4b60d357a diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9591737dbf..ab2c0aac8f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -35,22 +35,6 @@ export interface AcpConfig { 来源:[`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts) - - -## `@deepseek-ai/dsh-activity-local` - -```ts config-catalog -/** Configuration for the process-local observation registry. */ -export interface Config { - /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ - retainBytes?: number - /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ - settledRetainBytes?: number -} -``` - -来源:[`packages/activity/activity-local/src/index.ts:31`](../packages/activity/activity-local/src/index.ts) - ## `@deepseek-ai/dsh-agent-default-model` @@ -294,24 +278,6 @@ export interface Config { 来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) - - -## `@deepseek-ai/dsh-api-activity-controller` - -Requires: `typert` - -```ts config-catalog -/** Configuration for the Activity Remote owner. */ -export interface Config { - /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ - flushMs?: number - /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ - maxFrameBytes?: number -} -``` - -来源:[`packages/api/activity-controller/src/index.ts:26`](../packages/api/activity-controller/src/index.ts) - ## `@deepseek-ai/dsh-api-gateway` @@ -341,10 +307,14 @@ export interface Config { readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean + /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number } ``` -来源:[`packages/api/session-controller/src/index.ts:67`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts) @@ -990,10 +960,14 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number + /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number } ``` -来源:[`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts) +来源:[`packages/jobs/jobs-local/src/index.ts:38`](../packages/jobs/jobs-local/src/index.ts) @@ -2714,12 +2688,12 @@ export type TokenMeterConfig = Record export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ - activityPollMs?: number + /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ + recordPollMs?: number } ``` -来源:[`packages/shell/tool-bash/src/index.ts:33`](../packages/shell/tool-bash/src/index.ts) +来源:[`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) @@ -2881,12 +2855,12 @@ export interface Config { export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ - activityPollMs?: number + /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ + recordPollMs?: number } ``` -来源:[`packages/shell/tool-pwsh/src/index.ts:51`](../packages/shell/tool-pwsh/src/index.ts) +来源:[`packages/shell/tool-pwsh/src/index.ts:53`](../packages/shell/tool-pwsh/src/index.ts) @@ -3456,7 +3430,6 @@ export interface Config { - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-activity`([`packages/client/ui-activity/src/index.ts`](../packages/client/ui-activity/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-approval`([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) @@ -3470,6 +3443,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-directory-picker-native`([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-input-trigger`([`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-jobs`([`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout`([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-message-feedback`([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model-selection`([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) @@ -3530,7 +3504,6 @@ export interface Config { 抽象服务类——部署时应改为加载具体的实现包(参见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md))。 -- `@deepseek-ai/dsh-activity` — 抽象 `ActivityRegistry`([`packages/activity/activity/src/index.ts`](../packages/activity/activity/src/index.ts)) - `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — 抽象 `CodeRuntime`([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 7ff1ac079f..7ef2ec250e 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: 48aacf9dc6ece2431402ab72c171d236ed3b1359 -event-producer-consumer.zh.md: f644336b2cf540e28a88e2a6729508586021d46f +event-producer-consumer.md: fc05e2ec6ba8d3d5cfc409a617ee069759c93c08 +event-producer-consumer.zh.md: 42e79d5469f2b56e119b34b7dac63b8b590ff145 diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index f644336b2c..42e79d5469 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -71,8 +71,8 @@ | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## 包源码中出现的非 harness 或未声明事件字符串 diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 8587f8d35d..f6341b0335 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -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 docs/subsystems/README.md -README.md: d75bd368d5976813133bb8f62b1b5295bfb32b7c -README.zh.md: 589a72d81fd4e7d277b31626f896b41dc0ea1eb2 +README.md: f3340ebeb426fe9cc5c4570b3c8a3440cc1682ec +README.zh.md: 2e4523f03d1a942d9f93abf34ad2cc8687b413b8 diff --git a/docs/subsystems/activity.i18n.yaml b/docs/subsystems/activity.i18n.yaml deleted file mode 100644 index cf285e6ba7..0000000000 --- a/docs/subsystems/activity.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# 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 docs/subsystems/activity.md -activity.md: 000bdc30be05eb17d670367df3fd214e7d0af7f3 -activity.zh.md: 1fc79e9d596351a1f818ea83c15d1b5082689b79 diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 53ba3397ea..370043fe58 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -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 docs/subsystems/jobs.md -jobs.md: 092da7b6f7e10c5713d3d6c9c5c46f925084f86a -jobs.zh.md: 9dc390eb30901664edb47c6727a54ddd83839fa7 +jobs.md: 1b8e58f27be9e7198063960d436e61502c4f7e3b +jobs.zh.md: 0a1d04fcaf0ea0ceadcb457c756f0962e47eca34 diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 5928c640c4..0a1d04fcaf 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -23,7 +23,7 @@ interface JobKindMap { ## 生产方约定 -`JobStart` 声明身份和启动器。运行时会在调用 `run()` 前完成预检,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。 +`JobStart` 声明身份和启动器。运行时会在完成预检后携带该 job 的生产者面调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。 ```ts type-equiv /** @@ -38,7 +38,9 @@ interface JobStart { label: string /** * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. + * output read, including controller status metadata. Independent of record + * retention: it bounds the consuming model surface, never + * {@link JobRegistry.readRecord}. */ outputLimitBytes?: number /** @@ -49,11 +51,22 @@ interface JobStart { */ owner?: Agent /** - * Start the work after preflight and synchronously return its hooks. Called - * once; a throw leaves nothing registered, and the producer must clean up any - * partially started resources. + * Declares an observable output record beside the model-facing surfaces: + * {@link RunningJob.append} retains chunks in a bounded ring that any number + * of observers read at absolute byte offsets through + * {@link JobRegistry.readRecord}, and snapshots carry + * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + * Without the declaration `append` logs and drops. */ - run(): JobHooks + record?: true + /** + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the record append and live-detail writers. + */ + run(job: RunningJob): JobHooks } ``` @@ -71,13 +84,16 @@ interface JobHooks { * Resolves after the producer releases its resources, not merely when work * finishes. Must not reject; the runtime converts a rejection to `failed`. * If teardown cancellation throws, the runtime may force-fail only the - * registry record without claiming that the work stopped. + * registry record without claiming that the work stopped. Settlement also + * ends the record: fold any record-filling pump into this promise so the + * final drain lands before the registry trims and closes the stream. */ done: Promise /** * Consume output produced since the previous call. The producer formats * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. + * job has one consuming cursor. Independent of the record: this is the + * model-facing projection, {@link JobRegistry.readRecord} the observer one. */ readOutput?(): string } @@ -95,6 +111,70 @@ interface JobOutcome { } ``` +## 观测 record + +声明 `record: true` 的生产方通过 starter 收到的 `RunningJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。 + +```ts type-equiv +/** + * Producer face of one registered job, handed to {@link JobStart.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — both methods log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. + */ +interface RunningJob { + /** The registry-issued id (`-N`). */ + readonly id: JobId + /** + * Append one record chunk. Offsets advance by the chunk's UTF-8 byte + * length; an empty chunk is dropped without waking observers. Without a + * {@link JobStart.record} declaration the chunk is logged and dropped. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + */ + append(text: string, options?: JobAppendOptions): void + /** + * Replace the snapshot's status detail with a live progress line (`3/10`). + * Works for every job, with or without a record. + * @param detail - the new detail line. + */ + updateDetail(detail: string): void +} +``` + +```ts type-equiv +/** One retained record chunk returned by {@link JobRegistry.readRecord}. */ +interface JobRecordChunk { + /** Absolute offset of the chunk's first byte. */ + at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + text: string + /** Stream label, when the producer supplied one. */ + channel?: JobChannel + /** Producer-reported loss immediately before this chunk. */ + gapBefore?: true +} +``` + +```ts type-equiv +/** Result of one non-consuming {@link JobRegistry.readRecord}. */ +interface JobRecordRead { + /** Retained chunks overlapping `[from, total)`, in offset order. */ + chunks: readonly JobRecordChunk[] + /** + * Offset to resume from — the record's current `outputTotal`. Always a + * chunk boundary: appends land whole and trimming only advances chunk + * starts, and consumers concatenate `chunks` under that assumption, so a + * provider serving partial chunks would silently duplicate text. + */ + next: number + /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean +} +``` + ## 消费方视图 快照是每次新建的只读投影。`ownerSession` 携带用于授权的共享 `SessionId`;完成监听器则会另行收到用于生命周期清理的确切拥有者对象。另一个接口已经交付终止状态或承诺交付时,`reported` 会抑制完成通知;排空 owner 或服务的 teardown 取消同样计入。 @@ -121,7 +201,10 @@ interface JobSnapshot { ownerSession?: SessionId /** Current lifecycle state. */ status: JobStatus - /** Kind-specific status detail, present once the producer supplied one (usually terminal). */ + /** + * Kind-specific status detail: live progress while the producer updates it + * through {@link RunningJob.updateDetail}, the terminal detail once settled. + */ detail?: string /** Epoch ms when the job was registered. */ startedAt: number @@ -135,6 +218,17 @@ interface JobSnapshot { * otherwise spend a model request per teardown layer. */ reported: boolean + /** + * Total UTF-8 bytes ever appended to the record — the offset the next chunk + * starts at. Present exactly when the job declared {@link JobStart.record}. + */ + outputTotal?: number + /** + * Offset of the oldest retained record byte. Greater than zero exactly when + * retention dropped the head; a reader starting below it gets a lossy read. + * Present exactly when the job declared {@link JobStart.record}. + */ + outputEarliest?: number } ``` @@ -154,7 +248,7 @@ interface JobRead { ## 服务行为 -抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onJobDone` 与 `onJobsChanged` 监听器,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是其进程局部 Service Provider。授权会比较拥有者会话;拥有者清理与准入会使用确切的已注册 `Agent` 实例。本地 Service Provider 的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;它按确切 owner 统计 `running` 与 `stopping` 记录,所有无 owner 任务共享一个服务级桶,并在生产方终止结算后释放容量。Service Definition 约定见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md),注册表生命周期与准入策略见 [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md),面向模型的 Consumer 见 [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md)。 +抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定了原子化的 `start`、按调用方划定的 `get` 与 `list`、`read`、非消耗的 `readRecord`、`kill`、有界的 `wait`、故障隔离的 `onJobDone`、`onJobsChanged` 与 `onOutput` 监听器,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是进程本地的 Service Provider。授权比较 owner 会话;owner 清理与准入使用注册在案的确切 `Agent` 实例。本地 provider 的正安全整数配置 `maxConcurrentJobsPerOwner` 默认 `10`,按确切 owner 统计 `running` 加 `stopping` 记录,无主任务共享一个桶;生产方终态结算释放容量,`retainBytes`(默认 262144)与 `settledRetainBytes`(默认 16384)约束每个已声明 record 的运行期与结算后保留量。参见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md)(Service Definition 契约)、[`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md)(注册表生命周期与准入策略)与 [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md)(模型侧 Consumer)。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 92d25d2cfd..9b9953bdb4 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: 2f2192cde2941019c9687738f8e3f5e50ddd6d8e -README.zh.md: 273d304d3cd3bbcbadd3e40202d8d923f3a27498 +README.md: 2e5ca38bf51a1aca881e0a15b28f0b7856b51f73 +README.zh.md: e316aac4c07692720d1c6809d52bbe3ab06be033 diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 273d304d3c..e316aac4c0 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,6 +29,8 @@ kind: "package-reference" Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 +`SessionJob` 行恰在该 job 声明了观测 record 时携带 `outputTotal`,`session.observeJob` 从绝对字节偏移流式发送该 record——一个 `opened` 锚帧、聚合的 `output` 帧、最后一个终态 `status` 帧——围栏读取者由请求的 session 解析。record 读取是非消耗的:模型侧游标与完成通知状态永远观察不到它们。Client adapter 提供 `ctx.jobOutput`:按 job 引用计数的观测服务,任务列表 UI 渲染其累积视图;同一 job 的观察者共享一条流,重连时从模型游标续读。 + Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 ----- @@ -40,6 +42,8 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 |---|---:|---| | `coldBlankProbeMaxBytes` | `1,024` | 可进行空白状态验证的冷 Session 工件最大物理大小;`0` 禁用探测 | | `nativeOpen` | 平台探测 | 是否能把 Session 工作区路径交给原生桌面打开器 | +| `observeFlushMs` | `100` | 新 record 输出到一次 job 观测读取之间的聚合窗口,毫秒 | +| `observeMaxFrameBytes` | `65,536` | 每个 job 观测输出帧的软字节预算;更大的单块整块发送 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-session-controller)是所有受支持字段及其 JSDoc 的完整来源。 diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 6b75531c69..04d9e1336a 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -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/README.md -README.md: 3e45bac9b06e7006d955fc0cd8d600732e5f36ca -README.zh.md: e624cd5f87c450655c04b348cc72345b9389b0bd +README.md: 367a6d39ce1248b4246aa3e497093b5edb315fb0 +README.zh.md: 05a0143ce4eee253e1d94a1ff29e4c4f08da1274 diff --git a/packages/jobs/jobs-local/README.i18n.yaml b/packages/jobs/jobs-local/README.i18n.yaml index 6e514ac7e3..e4822080f4 100644 --- a/packages/jobs/jobs-local/README.i18n.yaml +++ b/packages/jobs/jobs-local/README.i18n.yaml @@ -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/jobs/jobs-local/README.md -README.md: cfbda98ccf59bcd12e931350c708cc15b30ec54c -README.zh.md: 07acfc3c75ef730328792ab1aa45197bfcd5c849 +README.md: 2da0a0b3399ce5a55b63cad491ab6ee48a2f8a90 +README.zh.md: cc362ad7935a91a564cca5a82228934bcc15077d diff --git a/packages/jobs/jobs-local/README.zh.md b/packages/jobs/jobs-local/README.zh.md index 07acfc3c75..cc362ad793 100644 --- a/packages/jobs/jobs-local/README.zh.md +++ b/packages/jobs/jobs-local/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-jobs-local` 在 harness 进程内运行后台任务:工作会在 agent 继续推进的同时保持运行,拥有它的 agent 可以读取、等待、列出和取消它;同时挂载 `dsh-tool-jobs` 时,完成以会话内通知送达。它用内存记录实现 `dsh-jobs` 约定,并且只交出全新快照,从不交出实时状态。按所有者的并发上限(默认 10)约束一个 agent 同时处于运行或停止中的任务数量;任务会随 harness 进程终止而消失,无法跨重启持久。 +`dsh-jobs-local` 在 harness 进程内运行后台任务:工作会在 agent 继续推进的同时保持运行,拥有它的 agent 可以读取、等待、列出和取消它;同时挂载 `dsh-tool-jobs` 时,完成以会话内通知送达。它用内存记录实现 `dsh-jobs` 约定,并且只交出全新快照,从不交出实时状态。按所有者的并发上限(默认 10)约束一个 agent 同时处于运行或停止中的任务数量,声明了 record 的任务另持有一个有界的内存输出环(默认运行期 256 KiB、结算时裁剪到 16 KiB),供观察者按绝对偏移读取;任务会随 harness 进程终止而消失,无法跨重启持久。 ## 目录 @@ -42,6 +42,8 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `maxConcurrentJobsPerOwner` | `10` | 每个精确所有者,或共享的无主桶中,`running` 加 `stopping` 任务的最大数量 | +| `retainBytes` | `262144` | 每个任务 record 的运行期保留量,UTF-8 字节 | +| `settledRetainBytes` | `16384` | 任务结算后保留的 record 量,UTF-8 字节 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-jobs-local)是每个受支持字段的穷尽式真源。 @@ -69,7 +71,8 @@ kind: "package-reference" ### 设计理念 -- **内存记录,全新快照。** `LocalJobRegistry` 为每个任务保存一条 `TrackedTask`,每次调用都投影出新的只读快照;调用方永远不会拿到实时状态。 +- **内存记录、全新快照。** `LocalJobRegistry` 为每个任务保存一份 `TrackedTask`——生命周期状态加可选的 record 环——每次调用都投影新的只读快照或块副本;调用方永远拿不到实时状态。 +- **record 保留量有界。** 追加超出运行期上限时丢弃最老的保留块(单个超限块保留其 UTF-8 安全尾部);低于保留窗口的读取得到 lossy 结果而非错误,结算时裁剪到结算上限。 - **按所有者分层,一个进程级注册表。** 控制器、完成监听器与变更观察者归档到注册方所在的 scope(`ScopedLayers`),读取把全局层与所有者的 scope 链求并集——因此某个 preset 的任务控制绝不会为自身组合未加载任何控制器的 agent 保持 `start()` 可用,一次结算也只会抵达其所有者所属组合注册的监听器。 - **启动前先预检。** `start()` 在调用生产方之前检查控制器服务、spec 有效性、仍存活的所有权与容量,因此拒绝不会留下 job id 或执行资源;注册一旦提交,后续不再有可失败步骤。 - **结算首次优先,完成最后。** 最早的终止结果只记录一次,释放等待方,并只通知监听器一次,各监听器故障单独隔离;完成在记录提交且可见集变更发布之后才宣布,因为报告方可能同步开启一个模型轮次。 @@ -84,11 +87,11 @@ kind: "package-reference" ### scope 分层 -`attachController`、`onJobDone` 与 `onJobsChanged` 注册到调用上下文所在的 scope 层。控制器问题(`servesOwner`)与监听器投递(`listenersFor`、`changedFor`)走同一条链:先是全局层,再沿所有者的链逐层。注册是无名 token,因此重复标签仍可独立释放。 +`attachController`、`onJobDone`、`onJobsChanged` 与 `onOutput` 注册到调用上下文所在的 scope 层。控制器问题(`servesOwner`)与监听器投递(`listenersFor`、`changedFor`、`outputFor`)走同一条链:先是全局层,再沿所有者的链逐层。注册是无名 token,因此重复标签仍可独立释放。 ### 准入与结算 -`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 在存在挂起等待方时把任务标为已报告,解析每个等待方,记录终止快照,宣布可见集变更,然后通知完成监听器。挂起的等待会在监听器运行前把任务标为已报告,因此完成报告方不会重复通知;销毁时的取消出于同样理由标记——面向正在被销毁的所有者的通知不会有人读到。 +`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 在存在挂起等待方时把任务标为已报告,把已声明的 record 裁剪到结算保留量,解析每个等待方,记录终止快照,宣布可见集变更与 record 的最终输出信号,然后通知完成监听器。挂起的等待会在监听器运行前把任务标为已报告,因此完成报告方不会重复通知;销毁时的取消出于同样理由标记——面向正在被销毁的所有者的通知不会有人读到。 ### 销毁 diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index f305c73ba6..9771023057 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -200,7 +200,7 @@ describe('LocalJobRegistry.start', () => { for (const job of live) ctx.jobs.start(job.spec) const blocked = producer() - const run = vi.fn(() => blocked.spec.run()) + const run = vi.fn((job: RunningJob) => blocked.spec.run(job)) expect(() => ctx.jobs.start({ ...blocked.spec, run })) .toThrow('background job limit reached for this owner (limit: 10)') expect(run).not.toHaveBeenCalled() @@ -213,7 +213,7 @@ describe('LocalJobRegistry.start', () => { expect(ctx.jobs.start(first.spec)).toBe('bash-1') const blocked = producer() - const run = vi.fn(() => blocked.spec.run()) + const run = vi.fn((job: RunningJob) => blocked.spec.run(job)) expect(() => ctx.jobs.start({ ...blocked.spec, run })) .toThrow('use job_kill to stop an unneeded job, wait for it to finish, then retry') expect(run).not.toHaveBeenCalled() @@ -658,7 +658,7 @@ describe('LocalJobRegistry owner isolation', () => { ctx.jobs.start(current.spec) // Attach the current owner's cleanup first. const stale = producer({ owner: staleOwner }) - const staleRun = vi.fn(() => stale.spec.run()) + const staleRun = vi.fn((job: RunningJob) => stale.spec.run(job)) expect(() => ctx.jobs.start({ ...stale.spec, run: staleRun })) .toThrow('is not the registered agent instance') expect(staleRun).not.toHaveBeenCalled() diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index faeca47c08..662a521a55 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -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/jobs/jobs/README.md -README.md: 7625c3672d24edff908c7be01723e970bd44d1f7 -README.zh.md: d62953d325ad14479539ccb61fb51378d21e3b52 +README.md: 10c74af908f64c18616e3c1eb71f19a3cbbf4eb3 +README.zh.md: 1fc500311c58a538ebfc7fbd89886d9c12b96acd diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index d62953d325..1fc500311c 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-jobs` 让工具可以把长时间工作注册为后台任务:工作获得稳定的 `-N` id,在 agent 继续推进的同时保持运行,拥有它的 agent 可以随时读取输出、带超时等待或请求取消。任务属于启动它的 agent 会话,因此一个 agent 的工作永远不会被另一个 agent 看到;完成以会话内通知而非轮询的方式送达给拥有者。本包只提供约定:进程本地注册表位于 `dsh-jobs-local`,模型侧控制与完成通知位于 `dsh-tool-jobs`。加载一个实现才能获得后台任务;没有实现时 `ctx.jobs` 不存在,`start()` 无法运行。 +`dsh-jobs` 让工具可以把长时间工作注册为后台任务:工作获得稳定的 `-N` id,在 agent 继续推进的同时保持运行,拥有它的 agent 可以随时读取输出、带超时等待或请求取消。任务属于启动它的 agent 会话,因此一个 agent 的工作永远不会被另一个 agent 看到;完成以会话内通知而非轮询的方式送达给拥有者。生产方还可以声明按 job 划分的观测 record:一条追加式的有界输出流,任意数量的观察者(Web 客户端)按绝对字节偏移读取,不触碰模型侧的消耗型游标。本包只提供约定:进程本地注册表位于 `dsh-jobs-local`,模型侧控制与完成通知位于 `dsh-tool-jobs`。加载一个实现才能获得后台任务;没有实现时 `ctx.jobs` 不存在,`start()` 无法运行。 ## 目录 @@ -31,6 +31,8 @@ kind: "package-reference" 生产方以 kind 和一行标签注册工作;注册表返回 `-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新快照,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,拥有它的 agent 会通过 `dsh-tool-jobs` 转成会话内通知的完成监听器得到通知,因此无需轮询。生产方还可以附加可选的字节上限,让每次完整的模型侧输出读取或完成通知保持有界。 +声明 `record: true` 的生产方还会通过 starter 收到的 `RunningJob` 面把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。 + ### 归属边界 任务属于启动它的 agent 会话:其他 agent 无法读取或停止它。`bash-1` 这样的 id 可预测,因此这道隔离是授权,而非保密。没有所有者启动的任务对任何调用方开放,并持续到服务被释放为止。 @@ -75,13 +77,14 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `JobRegistry` 服务及其约定 | -| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`JobSnapshot`、监听器类型 | +| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`JobSnapshot`、监听器类型 | | [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳与所有者字段 | +| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`:把生产方偏移读取器复制进 job record 的轮询泵 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳、所有者与 record 偏移字段 | ### 服务操作 -每个操作都是已注册任务之上的薄投影:`get` 与 `list` 返回非消费式快照,`read` 推进唯一的流游标,`kill` 在改变状态前调用生产方取消,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;监听器按所有者粒度观察终止记录与可见集变化,`attachController` 把控制器可用性限定在其 effect 生命周期内。确切签名与行为见 [`src/index.ts`](src/index.ts) 的 JSDoc 与生成的 [`ctx.jobs` cordis 接口面](../../../docs/subsystems/jobs.zh.md)。 +每个操作都是已注册任务之上的薄投影:`get` 与 `list` 返回非消费式快照,`read` 推进唯一的流游标,`readRecord` 按绝对偏移读取保留的 record 块且不消耗任何东西,`kill` 在改变状态前调用生产方取消,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;监听器按所有者粒度观察终止记录、可见集变化与 record 推进,`attachController` 把控制器可用性限定在其 effect 生命周期内。确切签名与行为见 [`src/index.ts`](src/index.ts) 的 JSDoc 与生成的 [`ctx.jobs` cordis 接口面](../../../docs/subsystems/jobs.zh.md)。 @@ -118,7 +121,7 @@ kind: "package-reference" 这些限制说明约定何时不合适。它们是当前包约束,不是任务积压。 - **约定是进程内的**——`JobStart.run()` 传入回调和确切的 `Agent` 对象;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。 -- **流输出只有一个消费游标**——它属于模型;独立观察者改用生产者镜像进去的非消费 [`dsh-activity`](../../activity/activity/README.zh.md) 观察面。 +- **流输出只有一个消费游标**——它属于模型;独立观察者改用非消耗的 `readRecord` 读取 job 声明的 record。 - **前台工作无法转为后台**——生产方在启动前选择前台或后台。 diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index 29ea9fb077..c979907a22 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -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/shell/tool-bash/README.md -README.md: 5e30ed598e894fc764e62366218f4e5ded0a7084 -README.zh.md: a62938a09f9a8ddd38094efca006932645d79d0d +README.md: 39e894970a92f68e09edf7b8dd10e040ed602b43 +README.zh.md: 025fb536a5196f4103a3476ba7ae20d5a59bda39 diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index a62938a09f..025fb536a5 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -55,7 +55,7 @@ kind: "package-reference" ### 后台运行长时间命令 -传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。加载了可选的 `ctx.activities` 注册表时,已提交的后台运行还会被尽力镜像进去:一个与调用和 job 关联的 `bash` activity 以 `activityPollMs`(默认 150)的节奏泵送句柄的非消费 `observed` 读取器,使 Web 客户端在不触碰 job 游标的情况下流式看到实时输出;任何观察失败都被记录并吞掉。 +传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。后台 job 声明观测 record:运行的非消费 `observed` 读取器以 `recordPollMs`(默认 150)的节奏泵入其中,使 Web 客户端在不触碰 job 消耗游标的情况下流式看到实时输出;泵失败会被记录并吞掉,job 继续运行。 ### 沙箱执行与升权 diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index bd08faa835..baefd11561 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/README.i18n.yaml @@ -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/shell/tool-pwsh/README.md -README.md: 8ea7e6f1e20f9c61f34fdc730c5c0b18706a2865 -README.zh.md: 2d8d51209a2ea66650904f10df263dfa0614aae3 +README.md: cfd6af5fe08a5f996d49c4b12c80930c687834fd +README.zh.md: 3b9a53047216d3ba80ca850f06bcf7a5c06bbfea diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index 2d8d51209a..3b9a530472 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-tool-pwsh` 为 agent 提供 `pwsh` 工具,通过已挂载的 shell 执行器运行 PowerShell 命令——它是 `dsh-tool-bash` 的 Windows 对应物,逐调用镜像。每次调用都运行在全新 pwsh 进程中,因此状态不会保留;`run_in_background` 把长时间运行的命令变成后台任务。命令是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量,不做方言翻译。每次调用都运行在受管 `DSH_*` 环境中;在沙箱执行器下,工具会教授并执行 Windows 特有的语言模式与命名管道约定。请与 `dsh-pwsh-local` 等 PowerShell 执行器以及 `dsh-shell-env` 插件一起挂载。加载了可选的 `ctx.activities` 注册表时,已提交的后台运行还会被尽力镜像为按 `activityPollMs`(默认 150)泵送的 `pwsh` activity,与 `dsh-tool-bash` 完全一致。 +`dsh-tool-pwsh` 为 agent 提供 `pwsh` 工具,通过已挂载的 shell 执行器运行 PowerShell 命令——它是 `dsh-tool-bash` 的 Windows 对应物,逐调用镜像。每次调用都运行在全新 pwsh 进程中,因此状态不会保留;`run_in_background` 把长时间运行的命令变成后台任务。命令是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量,不做方言翻译。每次调用都运行在受管 `DSH_*` 环境中;在沙箱执行器下,工具会教授并执行 Windows 特有的语言模式与命名管道约定。请与 `dsh-pwsh-local` 等 PowerShell 执行器以及 `dsh-shell-env` 插件一起挂载。后台运行的 job 声明按 `recordPollMs`(默认 150)泵送的观测 record,与 `dsh-tool-bash` 完全一致。 ## 目录 diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml index 719235042d..125c16d968 100644 --- a/packages/workflow/tool-workflow/README.i18n.yaml +++ b/packages/workflow/tool-workflow/README.i18n.yaml @@ -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/workflow/tool-workflow/README.md -README.md: a7096838da13f7f744bb1e6179a758d3dedc38a3 -README.zh.md: 254f498b4fe13397a035c97bfd7c708cde3415d7 +README.md: 2291b274a5cd52813266f4cb0e9276cd8019c4f8 +README.zh.md: d3009fa57d2ec2a16ed01c4926bb41066f1bbf2c diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md index 254f498b4f..d3009fa57d 100644 --- a/packages/workflow/tool-workflow/README.zh.md +++ b/packages/workflow/tool-workflow/README.zh.md @@ -68,7 +68,7 @@ kind: "package-reference" 对于根 transport 执行(`exec.parent` 缺省),工具会用四个 log-only 事件把运行投影到调用 Agent 的 Session:`start()` 返回后写 run-start,只记录 `run.id` 匹配的成员开始与结束,并且只在结果可用且 dispose 完全停稳后写 run-end。嵌套 transport 调用照常执行,但不写任何记录。任一次 Session append 首次失败后,本运行会停止后续记录并只告警一次,留下空记录或合法连续前缀,同时不改变工具结果和清理。包 invariant 会在冷加载与实时追加时拒绝重复 start、未配对成员、仍有开放成员的终点与 run-end 后更新,同时允许缺失终态后缀的连续前缀。 -在持久记录之外,每个被记录的顶层运行还会被尽力镜像进可选的 `ctx.activities` 注册表,作为一个 `workflow` activity:原本无人消费的 `workflow/phase` 与 `workflow/log` 事件加上成员生命周期行流入其中,使 Web 客户端看到会话日志刻意不逐行携带的实时叙述。没有注册表、或镜像出现任何失败时,运行本身不受影响。 +引擎的 `workflow/phase` 与 `workflow/log` 事件在本工具没有逐行的持久或观察面:会话日志刻意只记录 run 与成员生命周期,Web transcript 由这些记录派生。 ### 渲染意图 @@ -80,7 +80,6 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:工具注册、运行生命周期、记录器接线 | | [`src/types.ts`](src/types.ts) | 四个 log-only 记录事件 payload 及其 `SessionEventMap` 声明 | -| [`src/activity.ts`](src/activity.ts) | 顶层运行的尽力 activity 镜像 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:持久工作流记录协议校验 | diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 010c35a328..a217b3c619 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1016,6 +1016,21 @@ "symbol": "JobRead", "source": "packages/jobs/jobs/src/types.ts" }, + { + "doc": "docs/subsystems/jobs.md", + "symbol": "RunningJob", + "source": "packages/jobs/jobs/src/types.ts" + }, + { + "doc": "docs/subsystems/jobs.md", + "symbol": "JobRecordChunk", + "source": "packages/jobs/jobs/src/types.ts" + }, + { + "doc": "docs/subsystems/jobs.md", + "symbol": "JobRecordRead", + "source": "packages/jobs/jobs/src/types.ts" + }, { "doc": "docs/subsystems/terminal.md", "symbol": "TerminalWaitReason", From d2e9be40861f594c756745fa48d913b747627ca9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 18:36:29 +0800 Subject: [PATCH 018/284] test(api): stub session.observeJob on the fake client namespace The regenerated session namespace requires the new stream; the fake serves a never-yielding generator, and job-output.client.spec exercises the real consumer. --- packages/api/session-controller/tests/fake-api.client.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index e3ea48f783..e67650d4a2 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -10,7 +10,7 @@ import type { import type { SessionAddress, SessionControlBaseline, - SessionControlFrame, + SessionControlFrame, SessionObserveJobFrame, SessionFollowFrame, SessionFollowRequest, SessionPage, @@ -235,6 +235,9 @@ export class FakeApiClient { page: request => this.page(request), follow: (request, signal) => this.openFollow(request, signal), control: signal => this.openControl(signal), + // Job-record observation never yields here; the stream consumer is + // exercised directly by job-output.client.spec. + observeJob: () => (async function* (): AsyncGenerator {})(), }, subagents: { list: parentSessionId => this.record( From 419a985fc9741ae6f9eaa319d4f35dd24d98c6fc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 19:04:25 +0800 Subject: [PATCH 019/284] fix(api): capture the session namespace for job observation at apply time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observation (re)opens run on caller stacks — a React expand click, a carrier retry — whose dynamic context has not declared remote.session, so the ClientJobOutput wiring resolves the namespace to a concrete value while the plugin's own context is current, exactly as the activity client did. Caught by the live-job-stream web e2e, whose goldens (with the background-job-list pair) now carry the ui-jobs list name; the client-apply spec gains a jobOutput wiring smoke. --- .../snapshots/live-job-stream/settled.expected.md | 2 +- .../snapshots/live-job-stream/streaming.expected.md | 2 +- packages/api/session-controller/src/client/index.ts | 9 ++++++++- packages/api/session-controller/src/observe-job.ts | 6 ++++-- .../tests/client-apply.client.spec.ts | 12 ++++++++++++ .../web/background-job-list/running.expected.md | 2 +- .../web/background-job-list/settled.expected.md | 2 +- 7 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/snapshots/live-job-stream/settled.expected.md b/apps/web/tests/snapshots/live-job-stream/settled.expected.md index ecdcd4fea4..6ba8ada46d 100644 --- a/apps/web/tests/snapshots/live-job-stream/settled.expected.md +++ b/apps/web/tests/snapshots/live-job-stream/settled.expected.md @@ -1,4 +1,4 @@ -- list "Tasks": +- list "Background jobs": - listitem: - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM {{duration}}" diff --git a/apps/web/tests/snapshots/live-job-stream/streaming.expected.md b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md index aa23b11410..4054d5d42a 100644 --- a/apps/web/tests/snapshots/live-job-stream/streaming.expected.md +++ b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md @@ -1,4 +1,4 @@ -- list "Tasks": +- list "Background jobs": - listitem: - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - text: printf 'streamed-%s\n' marker-line; sleep 45 {{duration}} bash running diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 78a54b37ef..5fc9570a55 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -92,7 +92,14 @@ export const inject = [ export function apply(ctx: Context): void { const remotes = ctx.remote as unknown as SessionRemotes const sessions = new ClientSessions(ctx, remotes) - new ClientJobOutput(ctx, remotes, new ClientJobOutputModel()) + // Resolve the session namespace to a concrete value while this plugin's + // context is current: observation (re)opens run on caller stacks (a React + // event, a carrier retry) whose dynamic context has not declared + // `remote.session`, and a property access there fails the inject check. + new ClientJobOutput(ctx, { + $stream: options => remotes.$stream(options), + session: remotes.session, + }, new ClientJobOutputModel()) ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) }) ctx.remote.$on('api-session/status', (sessionId, running) => { diff --git a/packages/api/session-controller/src/observe-job.ts b/packages/api/session-controller/src/observe-job.ts index 516f48779f..795e786455 100644 --- a/packages/api/session-controller/src/observe-job.ts +++ b/packages/api/session-controller/src/observe-job.ts @@ -1,7 +1,7 @@ /** Per-job record observation generations: anchor, coalesced output, terminal status. */ import type { Agent } from '@deepseek-ai/dsh-agent' -import { JobId } from '@deepseek-ai/dsh-jobs' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { JobRegistry, JobSnapshot } from '@deepseek-ai/dsh-jobs' import type { SessionJobWireChunk, SessionObserveJobFrame, SessionObserveJobRequest } from './types.ts' @@ -99,7 +99,9 @@ export async function* observeJobRecord( throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`) } signal.throwIfAborted() - const id = JobId(String(request.jobId)) + // The brand is nominal typing only; the wire boundary stamps it here rather + // than value-importing the optional registry package's constructor. + const id = String(request.jobId) as JobId const waiter = new OutputWaiter() // Subscribe before the first read so an append between the anchor read and // the wait cannot be missed. diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 07f88b1a4f..b3fd854a68 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -106,6 +106,18 @@ async function flush(): Promise { } describe('Session Controller Client apply', () => { + it('installs ctx.jobOutput over the captured session namespace', async () => { + const bench = await mount() + expect(bench.ctx.jobOutput).toBeDefined() + // The fake observeJob never yields; opening and releasing must leave no + // view and no error behind. + const stop = bench.ctx.jobOutput.observe(sid('session-1'), 'bash-1' as never) + await flush() + stop() + await flush() + expect(bench.ctx.jobOutput.state.getSnapshot().observed).toEqual({}) + }) + it('routes Session Remote Events and connection generations into the object layer', async () => { const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected') const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError') diff --git a/snapshots/web/background-job-list/running.expected.md b/snapshots/web/background-job-list/running.expected.md index 95f226f226..811bddec3c 100644 --- a/snapshots/web/background-job-list/running.expected.md +++ b/snapshots/web/background-job-list/running.expected.md @@ -1,4 +1,4 @@ -- list "Tasks": +- list "Background jobs": - listitem: - button "Show live output of sleep 45": - text: sleep 45 {{duration}} bash running diff --git a/snapshots/web/background-job-list/settled.expected.md b/snapshots/web/background-job-list/settled.expected.md index 3343ae521c..44747d990d 100644 --- a/snapshots/web/background-job-list/settled.expected.md +++ b/snapshots/web/background-job-list/settled.expected.md @@ -1,4 +1,4 @@ -- list "Tasks": +- list "Background jobs": - listitem: - button "Show live output of sleep 45": - text: "bash sleep 45 signal: SIGTERM {{duration}}" From 1cec452f09a8fbe8f5a8c61cac39b257033ba9bc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 20:51:18 +0800 Subject: [PATCH 020/284] test(ci): close the observe-job coverage gaps Covers the woken-wait fast path (a wake landing while the generator is suspended on a yield), the opened frame's live-detail arm, and the sessionId-less caller resolution over an unowned job. --- .../tests/observe-job.host.spec.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/api/session-controller/tests/observe-job.host.spec.ts b/packages/api/session-controller/tests/observe-job.host.spec.ts index b61079d2b1..06a67848ec 100644 --- a/packages/api/session-controller/tests/observe-job.host.spec.ts +++ b/packages/api/session-controller/tests/observe-job.host.spec.ts @@ -54,6 +54,7 @@ function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent; return { id, append: (text: string, opts?: Parameters[1]) => { face.append(text, opts) }, + updateDetail: (detail: string) => { face.updateDetail(detail) }, settle: async (outcome: JobOutcome) => { settle(outcome) await new Promise(resolve => setTimeout(resolve, 0)) @@ -224,6 +225,31 @@ describe('observeJobRecord', () => { await job.settle({ status: 'completed' }) }) + it('anchors with the live detail and takes the fast wait path for a wake that lands mid-yield', async () => { + const ctx = await harness() + const job = startRecordJob(ctx) + job.updateDetail('3/10') + const abort = new AbortController() + const iterator = observeJobRecord(ctx.jobs, { jobId: job.id }, { ...OBSERVE, caller: undefined }, abort.signal)[Symbol.asyncIterator]() + + const anchor = await iterator.next() + expect(anchor.value).toMatchObject({ type: 'opened', detail: '3/10' }) + // Both chunks land while the generator is suspended on a yield, so each + // wake precedes the loop's wait(): draining the first chunk must pass + // through the already-woken fast path to read the second. + job.append('woken') + const first = await iterator.next() + expect(first.value).toMatchObject({ type: 'output' }) + job.append('again') + const second = await iterator.next() + expect(second.value).toMatchObject({ type: 'output' }) + const pending = iterator.next() + await job.settle({ status: 'completed' }) + const status = await pending + expect(status.value).toMatchObject({ type: 'status', status: 'completed' }) + expect((await iterator.next()).done).toBe(true) + }) + it('ignores output signals for other jobs while waiting', async () => { const ctx = await harness() const watched = startRecordJob(ctx, { label: 'watched' }) @@ -295,4 +321,24 @@ describe('SessionController.observeJob', () => { ) expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) }) + + it('observes an unowned job without a request session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + ctx.jobs.attachController('observe-job-test') + const controller = createSessionTestController(ctx, defaults) + const job = startRecordJob(ctx, { label: 'unowned run' }) + job.append('open access') + await job.settle({ status: 'completed' }) + + const abort = new AbortController() + const frames = await collect( + controller.observeJob({ jobId: job.id }, abort.signal), + 3, + abort, + ) + expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) + }) }) From 7ec142a3258ef63f5c2b4253a347f9d4797610b6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 22:53:36 +0800 Subject: [PATCH 021/284] refactor(jobs): move job observation into api-job-controller Review asked for the observation wire and its client service to leave session-controller: a job should reach the Host only through its own Remote namespace, and the session plugin's apply had to materialize { $stream, session } for a service that is not session state. @deepseek-ai/dsh-api-job-controller now owns the Host JobController (job.observe over ctx.jobs, requiring the agent and job registries) and the client ClientJobOutput (ctx.jobOutput) over ClientJobOutputModel, resolving both Remote faces in its own apply. The remotes assembly mounts the job namespace; ui-jobs injects the new package; the wire types drop their Session prefix and spell the status union out so the client face pulls no Host declaration merges into browser programs. session-controller keeps the jobsBySession roster and loses observe-job, job-output, and the observe cadence config. ui-chat's slots and snapshot-builder import CommandNode and friends from the declaring package: the typert analyzer's cross-package rule trips on the snapshot re-export as soon as another client face joins the root Client program (the new controller does), which is why this branch carried the change before. JobPumpSource documents why it is an offset snapshot reader rather than a Node.js Readable. --- ...9-01-jobs-absorb-activity-record.i18n.yaml | 4 +- .../2026-09-01-jobs-absorb-activity-record.md | 4 +- ...26-09-01-jobs-absorb-activity-record.zh.md | 4 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 7 +- docs/capability-seams.zh.md | 7 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 24 +- docs/config-catalog.zh.md | 26 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 10 +- docs/event-producer-consumer.zh.md | 10 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 2 + docs/module-graph.zh.md | 2 + docs/subsystems/jobs.i18n.yaml | 4 +- docs/subsystems/jobs.md | 24 +- docs/subsystems/jobs.zh.md | 24 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 12 - docs/subsystems/session.zh.md | 12 - packages/api/README.i18n.yaml | 4 +- packages/api/README.md | 1 + packages/api/README.zh.md | 1 + packages/api/job-controller/README.i18n.yaml | 6 + packages/api/job-controller/README.md | 66 ++++ packages/api/job-controller/README.zh.md | 66 ++++ packages/api/job-controller/package.json | 86 +++++ .../api/job-controller/src/client/index.ts | 34 ++ .../api/job-controller/src/client/model.ts | 179 +++++++++ .../api/job-controller/src/client/service.ts | 179 +++++++++ packages/api/job-controller/src/index.ts | 86 +++++ .../src/observe.ts} | 14 +- packages/api/job-controller/src/types.ts | 75 ++++ .../tests/client-apply.client.spec.ts | 65 ++++ .../tests/model.client.spec.ts} | 13 +- .../tests/observe.host.spec.ts} | 66 ++-- .../api/job-controller/tsconfig.client.json | 23 ++ .../api/job-controller/tsconfig.host.json | 21 ++ packages/api/job-controller/tsconfig.json | 7 + packages/api/job-controller/tsdown.config.ts | 7 + packages/api/remotes/package.json | 11 +- packages/api/remotes/src/client/index.ts | 5 +- packages/api/remotes/tsconfig.client.json | 4 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 4 +- packages/api/session-controller/README.zh.md | 4 +- .../session-controller/src/client/index.ts | 11 - .../src/client/job-output.ts | 348 ------------------ packages/api/session-controller/src/index.ts | 43 --- packages/api/session-controller/src/types.ts | 57 +-- .../tests/client-apply.client.spec.ts | 12 - .../tests/fake-api.client.ts | 5 +- .../session-controller/tests/test-remote.ts | 4 - .../api/session-controller/tsconfig.host.json | 101 +++-- packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 43 +-- .../ui-chat/src/client/contract/slots.ts | 7 +- .../chat-snapshot-builder.ts | 7 +- packages/client/ui-jobs/README.i18n.yaml | 4 +- packages/client/ui-jobs/README.md | 5 +- packages/client/ui-jobs/README.zh.md | 5 +- packages/client/ui-jobs/package.json | 2 + .../ui-jobs/src/client/JobListAction.tsx | 2 +- packages/client/ui-jobs/src/client/index.ts | 1 + .../tests/job-list-action.client.spec.tsx | 2 +- packages/client/ui-jobs/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 12 +- .../extensions/tool-cordis/src/api-catalog.ts | 47 ++- packages/jobs/jobs/src/pump.ts | 14 +- pnpm-lock.yaml | 49 +++ scripts/gen-cordis-catalog.ts | 8 +- scripts/gen-doc-graphs.ts | 9 +- .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 3 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + 77 files changed, 1339 insertions(+), 700 deletions(-) create mode 100644 packages/api/job-controller/README.i18n.yaml create mode 100644 packages/api/job-controller/README.md create mode 100644 packages/api/job-controller/README.zh.md create mode 100644 packages/api/job-controller/package.json create mode 100644 packages/api/job-controller/src/client/index.ts create mode 100644 packages/api/job-controller/src/client/model.ts create mode 100644 packages/api/job-controller/src/client/service.ts create mode 100644 packages/api/job-controller/src/index.ts rename packages/api/{session-controller/src/observe-job.ts => job-controller/src/observe.ts} (94%) create mode 100644 packages/api/job-controller/src/types.ts create mode 100644 packages/api/job-controller/tests/client-apply.client.spec.ts rename packages/api/{session-controller/tests/job-output.client.spec.ts => job-controller/tests/model.client.spec.ts} (98%) rename packages/api/{session-controller/tests/observe-job.host.spec.ts => job-controller/tests/observe.host.spec.ts} (89%) create mode 100644 packages/api/job-controller/tsconfig.client.json create mode 100644 packages/api/job-controller/tsconfig.host.json create mode 100644 packages/api/job-controller/tsconfig.json create mode 100644 packages/api/job-controller/tsdown.config.ts delete mode 100644 packages/api/session-controller/src/client/job-output.ts diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml index 87e45ad40a..3a332e7121 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md -2026-09-01-jobs-absorb-activity-record.md: cc2cc8606a50e2d7c06e183a09df709b75b9bd35 -2026-09-01-jobs-absorb-activity-record.zh.md: 8b6739192b56899899773ba1e7af2bf1bd846b0b +2026-09-01-jobs-absorb-activity-record.md: aa147778d06133b61f79defae6f9f6807dd97f41 +2026-09-01-jobs-absorb-activity-record.zh.md: 12e8f4bbd909a480f1461433c58d7937a8d7c857 diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md index cc2cc8606a..aa147778d0 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -18,7 +18,7 @@ The one activity without a job was the foreground workflow mirror. Everything el - **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented. - **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design. - **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it. -- **The wire moves to the session namespace.** `SessionJob.outputTotal` (present exactly for record jobs) marks a row observable; `session.observeJob({sessionId?, jobId, from?})` streams anchor/output/status frames with the fenced read resolved from the request's session — the killJob authorization shape. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery moved into `session-controller` (`observe-job.ts`, client `ctx.jobOutput`). +- **The wire splits by role.** `SessionJob.outputTotal` (present exactly for record jobs) marks a row observable on the session control stream; `job.observe({sessionId?, jobId, from?})` on the `job` namespace of `api-job-controller` streams anchor/output/status frames with the fenced read resolved from the request's session. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery lives in `api-job-controller` (`observe.ts`, client `ctx.jobOutput`), which resolves its Remote faces in its own apply. - **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `outputTotal` is present. The two-roster join is deleted. - **Foreground workflow loses its live panel deliberately.** `tool-workflow`'s activity mirror is removed; a foreground run surfaces through its recorded run/member lifecycle events only, and the per-line `workflow/phase` / `workflow/log` narration has no observer until workflow gains `run_in_background` and registers a record job. Jobs stay a pure background registry — no `foreground` mode bit, no model-invisible rows, no run-less rows. @@ -32,4 +32,4 @@ The [prior seam note](../feature/2026-08-24-activity-observation-seam.md)'s argu ## Consequences -Every observable row is a killable job; a pure-observation surface with no lifecycle would need a new home (harness diagnostics belong to the inspector plane, not here). A future remote job provider must implement lifecycle and record together. The record's retention config lives on `jobs-local`; the observation wire's cadence config (`observeFlushMs`, `observeMaxFrameBytes`) lives on `session-controller`. +Every observable row is a killable job; a pure-observation surface with no lifecycle would need a new home (harness diagnostics belong to the inspector plane, not here). A future remote job provider must implement lifecycle and record together. The record's retention config lives on `jobs-local`; the observation wire's cadence config (`observeFlushMs`, `observeMaxFrameBytes`) lives on `api-job-controller`. diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md index 8b6739192b..12e8f4bbd9 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -18,7 +18,7 @@ Status: implemented - **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。 - **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。 - **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。 -- **wire 移入 session 命名空间。** `SessionJob.outputTotal`(恰在 record job 上存在)标记行可观测;`session.observeJob({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析——与 killJob 相同的授权形态。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制移入 `session-controller`(`observe-job.ts`,客户端 `ctx.jobOutput`)。 +- **wire 按角色拆分。** `SessionJob.outputTotal`(恰在 record job 上存在)在会话控制流上标记行可观测;`api-job-controller` 的 `job` 命名空间上的 `job.observe({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制位于 `api-job-controller`(`observe.ts`,客户端 `ctx.jobOutput`),它在自己的 apply 里解析所依赖的 Remote 面。 - **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `outputTotal` 存在时可展开。双名册 join 被删除。 - **前台 workflow 有意失去实时面板。** `tool-workflow` 的 activity 镜像被移除;前台 run 只通过已记录的 run/member 生命周期事件呈现,逐行的 `workflow/phase` / `workflow/log` 叙述在 workflow 获得 `run_in_background` 并登记 record job 之前没有观察者。jobs 保持纯后台注册表——没有 `foreground` 模式位、没有模型不可见行、没有无 run 行。 @@ -32,4 +32,4 @@ Status: implemented ## 后果 -每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `session-controller`。 +每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `api-job-controller`。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 0f57f09f95..902b8a63bf 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -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 docs/capability-seams.md -capability-seams.md: 635ef1defb7acabf78d94b28c6bef66a43f518b3 -capability-seams.zh.md: 21619c9d20f66edee269a1599ced6f4d4f55df38 +capability-seams.md: 30b3a2f8fdbb66410ada6ab27551d6018a1ac8eb +capability-seams.zh.md: 834131588213627cefd4a6ea9c69d67f5d345ba7 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 635ef1defb..30b3a2f8fd 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -39,6 +39,8 @@ flowchart LR svc_sessionController["ctx.sessionController
        Host Session Remote controller"] svc_sessionFileReferences["ctx.sessionFileReferences
        Session-addressed file-reference Remote adapter"] svc_sessionSkillCatalog["ctx.sessionSkillCatalog
        Session-addressed skill Remote adapter"] + pkg_api_job_controller["api-job-controller"] + svc_jobController["ctx.jobController
        Host job Remote controller"] pkg_api_settings_controller["api-settings-controller"] svc_credentialsController["ctx.credentialsController
        Host credential-surface Remote controller"] svc_settingsController["ctx.settingsController
        Host settings-surface Remote controller"] @@ -223,6 +225,7 @@ flowchart LR pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway + pkg_api_job_controller --> svc_jobController pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences pkg_api_session_controller --> svc_sessionSkillCatalog @@ -369,6 +372,7 @@ flowchart LR svc_invariants --> pkg_agent_loop svc_invariants --> pkg_scope svc_invariants --> pkg_session + svc_jobs --> pkg_api_job_controller svc_jobs --> pkg_tool_bash svc_jobs --> pkg_tool_jobs svc_jobs --> pkg_tool_pwsh @@ -475,6 +479,7 @@ flowchart LR | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Owns Session commands, cold reads, durable-event following, live control state, model catalogs, workspace opening, and Agent activation policy. | | `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Delegates file-reference discovery through the Session Controller's established Agent lookup policy. | | `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Lists the Session composition's user-invocable skills without activating a cold Agent. | +| `ctx.jobController` | `core` | [`api-job-controller`](../packages/api/job-controller) | - | - | - | Streams one background job's observation record over the generated Remote namespace; the roster stays on the session control stream. | | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | @@ -524,7 +529,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. | | `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | -| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | +| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 21619c9d20..8341315882 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -41,6 +41,8 @@ flowchart LR svc_sessionController["ctx.sessionController
        Host Session Remote controller"] svc_sessionFileReferences["ctx.sessionFileReferences
        Session-addressed file-reference Remote adapter"] svc_sessionSkillCatalog["ctx.sessionSkillCatalog
        Session-addressed skill Remote adapter"] + pkg_api_job_controller["api-job-controller"] + svc_jobController["ctx.jobController
        Host job Remote controller"] pkg_api_settings_controller["api-settings-controller"] svc_credentialsController["ctx.credentialsController
        Host credential-surface Remote controller"] svc_settingsController["ctx.settingsController
        Host settings-surface Remote controller"] @@ -225,6 +227,7 @@ flowchart LR pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway + pkg_api_job_controller --> svc_jobController pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences pkg_api_session_controller --> svc_sessionSkillCatalog @@ -371,6 +374,7 @@ flowchart LR svc_invariants --> pkg_agent_loop svc_invariants --> pkg_scope svc_invariants --> pkg_session + svc_jobs --> pkg_api_job_controller svc_jobs --> pkg_tool_bash svc_jobs --> pkg_tool_jobs svc_jobs --> pkg_tool_pwsh @@ -477,6 +481,7 @@ flowchart LR | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态、模型目录、workspace 打开与 Agent 激活策略。 | | `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 通过 Session Controller 的既有 Agent lookup 策略委托文件引用发现。 | | `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 在不激活冷 Agent 的前提下列出 Session 组合中允许用户调用的 skill。 | +| `ctx.jobController` | `core` | [`api-job-controller`](../packages/api/job-controller) | - | - | - | 经生成的 Remote namespace 流式发送一个后台任务的观测 record;名册仍在会话控制流上。 | | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | @@ -526,7 +531,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 | | `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | -| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | +| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c065e16e00..102b6810cd 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 2c8043732edb0d47e5eaffd02f95e4293060a4dd -config-catalog.zh.md: 24c077ca51e64a4f6235170726a909be9e0e5207 +config-catalog.md: 36eb0f1e9385b9ddc4a09a9476d3b483075282ba +config-catalog.zh.md: 90e94c3e739dd854c1a4243297d1e5aef557f828 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2c8043732e..36eb0f1e93 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -199,6 +199,24 @@ export interface Config { Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/index.ts) + + +## `@deepseek-ai/dsh-api-job-controller` + +Requires: `agents` · `jobs` · `typert` + +```ts config-catalog +/** Job Controller deployment policy. */ +export interface Config { + /** Coalescing window after new record output before an observation read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number +} +``` + +Source: [`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -214,14 +232,10 @@ export interface Config { readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean - /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */ - readonly observeFlushMs?: number - /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */ - readonly observeMaxFrameBytes?: number } ``` -Source: [`packages/api/session-controller/src/index.ts:82`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 24c077ca51..90e94c3e73 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -201,6 +201,26 @@ export interface Config { 来源:[`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/index.ts) + + +## `@deepseek-ai/dsh-api-job-controller` + +需要:`agents` · `jobs` · `typert` + +```ts config-catalog +/** Job Controller deployment policy. */ +export interface Config { + /** Coalescing window after new record output before an observation read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number +} +``` + +来源:[`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts) + + + ## `@deepseek-ai/dsh-api-session-controller` @@ -216,14 +236,10 @@ export interface Config { readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean - /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */ - readonly observeFlushMs?: number - /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */ - readonly observeMaxFrameBytes?: number } ``` -来源:[`packages/api/session-controller/src/index.ts:82`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 1efb0f757c..8a378b23f1 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: 26096b99ef32702b85a622fa0342f215bb90ddf9 -event-producer-consumer.zh.md: 3e224d7a192e1c8c361fe2c7ad88b2d817bfbb1e +event-producer-consumer.md: 930ffba24789ee51db110310cb17a2231135cf1d +event-producer-consumer.zh.md: 6583b1d9f333ad934db2123a2bc5c40b12f1ae36 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 26096b99ef..930ffba247 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:598`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:550`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:557`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:536`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 3e224d7a19..6583b1d9f3 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:598`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:550`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:557`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:536`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 7a1a91a45a..64db381cab 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: 514d09c5883ec8196e0706fbd7a3ccf45b8aafb5 -module-graph.zh.md: 444100e17ce6794efa39ef2e0a10872a6e1c3484 +module-graph.md: e280a648b8550d34b129f05971ebd0547690f117 +module-graph.zh.md: b068b7216481fb86873c290d31e6d76ce85e3fbd diff --git a/docs/module-graph.md b/docs/module-graph.md index 514d09c588..e280a648b8 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -108,6 +108,7 @@ flowchart TD end subgraph group_api["packages/api"] pkg_api_gateway["api-gateway"] + pkg_api_job_controller["api-job-controller"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] @@ -1153,6 +1154,7 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | +| [`api-job-controller`](../packages/api/job-controller) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | | [`base`](../packages/bundle/base) | `bundle` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 444100e17c..b068b72164 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -110,6 +110,7 @@ flowchart TD end subgraph group_api["packages/api"] pkg_api_gateway["api-gateway"] + pkg_api_job_controller["api-job-controller"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] @@ -1155,6 +1156,7 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | +| [`api-job-controller`](../packages/api/job-controller) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | | [`base`](../packages/bundle/base) | `bundle` | — | diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 370043fe58..022befcbd2 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -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 docs/subsystems/jobs.md -jobs.md: 1b8e58f27be9e7198063960d436e61502c4f7e3b -jobs.zh.md: 0a1d04fcaf0ea0ceadcb457c756f0962e47eca34 +jobs.md: 0610d99892da22a25590ab7efe8ff71d9a004f01 +jobs.zh.md: 4c35fd7c879ddf7133c0db55df24948b40118b9d diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 1b8e58f27b..0610d99892 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -113,7 +113,7 @@ interface JobOutcome { ## Observation record -A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RunningJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. +A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RunningJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. Browsers reach the record through `job.observe`, the Remote stream of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below. ```ts type-equiv /** @@ -258,6 +258,28 @@ The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Defi Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.jobController` — `JobController` + +Host service backing the generated `ctx.remote.job` namespace. + +```ts cordis-catalog +/** + * Stream one job's retained record output from an absolute byte offset, + * then its terminal status once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session resolves the fenced-read caller; the registry rejects a + * job the session does not own, a job without a record, or an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts) + ### `ctx.jobs` — `JobRegistry` (abstract seam) diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 0a1d04fcaf..4c35fd7c87 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -113,7 +113,7 @@ interface JobOutcome { ## 观测 record -声明 `record: true` 的生产方通过 starter 收到的 `RunningJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。 +声明 `record: true` 的生产方通过 starter 收到的 `RunningJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。浏览器经 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.observe` 读取 record,其帧类型列在下方该控制器的 Cordis API 一节。 ```ts type-equiv /** @@ -258,6 +258,28 @@ interface JobRead { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.jobController` — `JobController` + +Host service backing the generated `ctx.remote.job` namespace. + +```ts cordis-catalog +/** + * Stream one job's retained record output from an absolute byte offset, + * then its terminal status once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session resolves the fenced-read caller; the registry rejects a + * job the session does not own, a job without a record, or an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts) + ### `ctx.jobs` — `JobRegistry` (abstract seam) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 3a2bd2ec51..f9653cecea 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -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 docs/subsystems/session.md -session.md: 36c15ffd5244da732dea003259d476c0d1812d89 -session.zh.md: b31fd6593cb9d72c6577ee7a94c02d6dd04994e4 +session.md: 48a80ccb92af774db2f5bedea8e2e00043932d31 +session.zh.md: df5cfd631a15bfe015c766842956cc0c344c2ccd diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 36c15ffd52..48a80ccb92 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -800,18 +800,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise - -/** - * Stream one job's retained record output from an absolute byte offset, - * then its terminal status once settled and drained. Non-consuming: the - * model-facing cursor and notice state never observe these reads. The - * request's session resolves the fenced-read caller; the registry rejects a - * job the session does not own, a job without a record, or an unknown job. - * @param request - target job, owning session, and optional resume offset. - * @param signal - cancellation owned by the Remote stream carrier. - * @returns anchor, coalesced output frames, and the terminal status. - */ -@Remote({ mode: 'stream' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable ``` Types: [SessionId](core.md) · [SessionInspection](persistence.md) · [SessionSearchRequest](session-query.md) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index b31fd6593c..df5cfd631a 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -804,18 +804,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise - -/** - * Stream one job's retained record output from an absolute byte offset, - * then its terminal status once settled and drained. Non-consuming: the - * model-facing cursor and notice state never observe these reads. The - * request's session resolves the fenced-read caller; the registry rejects a - * job the session does not own, a job without a record, or an unknown job. - * @param request - target job, owning session, and optional resume offset. - * @param signal - cancellation owned by the Remote stream carrier. - * @returns anchor, coalesced output frames, and the terminal status. - */ -@Remote({ mode: 'stream' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable ``` Types: [SessionId](core.zh.md) · [SessionInspection](persistence.zh.md) · [SessionSearchRequest](session-query.zh.md) diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index c47c67f406..d40b9dfa53 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -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/api/README.md -README.md: b4d8ddd84baa411a2675c95dda1701937e06fe6d -README.zh.md: 5dff7a59219a539c75d7ab85d293cd5b389018ec +README.md: ad89535e8d482e7a50868cd97e0af50ebda62e4b +README.zh.md: 7e1957967aadcd93df5e1bb7f6db45cb4636c750 diff --git a/packages/api/README.md b/packages/api/README.md index b4d8ddd84b..ad89535e8d 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -28,6 +28,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust |---|---|---| | [`remotes/`](remotes/README.md) | Chooses which Host capabilities and events the Client can consume. | — | | [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | +| [`job-controller/`](job-controller/README.md) | Streams one background job's observation record to the Client. | `ctx.jobController` / `ctx.remote.job` | | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 5dff7a5921..7e1957967a 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -28,6 +28,7 @@ kind: "package-group" |---|---|---| | [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — | | [`gateway/`](gateway/README.zh.md) | 承载带类型的单次调用、多路复用 stream 与转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | +| [`job-controller/`](job-controller/README.zh.md) | 把一个后台任务的观测 record 流式送到 Client。 | `ctx.jobController` / `ctx.remote.job` | | [`session-controller/`](session-controller/README.zh.md) | 拥有 Session 命令、历史 stream、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | diff --git a/packages/api/job-controller/README.i18n.yaml b/packages/api/job-controller/README.i18n.yaml new file mode 100644 index 0000000000..9bef50c478 --- /dev/null +++ b/packages/api/job-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/api/job-controller/README.md +README.md: 1af9cac6574b36fb9ee0fe2bd4ff4a529848c2fe +README.zh.md: a232d8e8655fea6ccddedb60e222837466705549 diff --git a/packages/api/job-controller/README.md b/packages/api/job-controller/README.md new file mode 100644 index 0000000000..1af9cac657 --- /dev/null +++ b/packages/api/job-controller/README.md @@ -0,0 +1,66 @@ +--- +description: "Host and Client job observation: stream one background job's output record to the browser without touching the model's consuming cursor." +kind: "package-reference" +--- +# Job Controller + +English | [中文](README.zh.md) + +## Summary + +`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its one Remote stream, `job.observe`, delivers a record-declaring job's retained output from an absolute byte offset; the Client half installs `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views the session-header job list renders. The roster itself is not here: `SessionJob` rows still ride the session control stream owned by [`dsh-api-session-controller`](../session-controller/README.md), and a row's `outputTotal` says whether this controller has anything to stream for it. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The Host controller requires the live Agent registry and the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without them. `job.observe({ sessionId?, jobId, from? })` resolves the fenced-read caller from the request's session — an unowned job needs no session — and yields one `opened` anchor, coalesced `output` frames, then one terminal `status` once the job has settled and its record is drained, after which the stream closes normally. Record reads are non-consuming: the model-facing `job_output` cursor and completion-notice state never observe them. A job the session does not own, a job without a record, or an unknown job rejects the stream. Reconnecting callers resume by passing the last frame's `next` as `from`; a `from` below the oldest retained byte gets a `lossy` first frame instead of an error. + +The Client entry provides `ClientJobOutput` (`ctx.jobOutput`) over `ClientJobOutputModel`. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, records the terminal status or a stream failure on the view, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because observation (re)opens run on caller stacks that have not declared `remote.job`. + +### Config + +| Field | Default | Meaning | +|---|---:|---| +| `observeFlushMs` | `100` | Coalescing window after new record output before an observation read, in milliseconds | +| `observeMaxFrameBytes` | `65,536` | Soft byte budget per observation output frame; one larger chunk ships whole | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-job-controller) is the exhaustive source for accepted fields and their JSDoc. + +----- + + +## Model Experience + +None, as job observation is browser and Host control state; it registers no prompt, tool, or session event. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). + +#### KV Cache effect + +No direct effect; observation reads never touch model requests. + +## Known Limitations and Deferred Work + + + +- The stream is process-local: a Host restart loses every record, and a resumed observation then anchors on an empty registry. +- Per-session fencing is enforced by the registry read; the Remote layer itself serves any connected browser, matching the session control stream's `jobsBySession` broadcast. + + +### Dev Note + +
        +Working context for maintainers — click to expand + +None. + +
        + +**Runtime invariant:** No companion is published. The controller is a stateless projection of `ctx.jobs` reads; the registry's own `@deepseek-ai/dsh-jobs/invariant` owns the offset relations this stream forwards. diff --git a/packages/api/job-controller/README.zh.md b/packages/api/job-controller/README.zh.md new file mode 100644 index 0000000000..a232d8e865 --- /dev/null +++ b/packages/api/job-controller/README.zh.md @@ -0,0 +1,66 @@ +--- +description: "Host 与 Client 的 job 观测:把一个后台任务的输出 record 流式送到浏览器,不触碰模型的消耗型游标。" +kind: "package-reference" +--- +# Job Controller + +[English](README.md) | 中文 + +## 概述 + +`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它唯一的 Remote 流 `job.observe` 从绝对字节偏移发送声明了 record 的 job 的保留输出;Client 半侧安装 `ctx.jobOutput`——按 job 引用计数的观测服务,会话头部任务列表渲染其累积视图。名册不在这里:`SessionJob` 行仍随 [`dsh-api-session-controller`](../session-controller/README.zh.md) 拥有的会话控制流到达,行上的 `outputTotal` 说明本控制器是否有东西可流。 + +## 目录 + +- [使用本包](#use-this-package) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +Host 控制器要求活体 Agent 注册表与 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少任一则不加载。`job.observe({ sessionId?, jobId, from? })` 由请求的 session 解析围栏读取者——无主 job 不需要 session——先产出一个 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且 record 排干后产出一个终态 `status`,随后流正常关闭。record 读取是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。session 不拥有的 job、没有 record 的 job 或未知 job 会拒绝该流。重连方把上一帧的 `next` 作为 `from` 续读;低于最老保留字节的 `from` 得到带 `lossy` 的首帧而非错误。 + +Client 入口在 `ClientJobOutputModel` 之上提供 `ClientJobOutput`(`ctx.jobOutput`)。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,把终态或流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为观测的(重)开启跑在未声明 `remote.job` 的调用栈上。 + +### 配置 + +| 字段 | 默认值 | 含义 | +|---|---:|---| +| `observeFlushMs` | `100` | 新 record 输出到一次观测读取之间的聚合窗口,毫秒 | +| `observeMaxFrameBytes` | `65,536` | 每个观测输出帧的软字节预算;更大的单块整块发送 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-job-controller)是所有受支持字段及其 JSDoc 的完整来源。 + +----- + + +## 模型体验 + +无。job 观测是浏览器与 Host 的控制状态;它不注册任何提示词、工具或会话事件。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 + +#### KV 缓存影响 + +无直接影响;观测读取从不触碰模型请求。 + +## 已知限制与延期工作 + + + +- 流是进程本地的:Host 重启丢失全部 record,续读的观测随后锚定在空注册表上。 +- 按 session 的围栏由注册表读取强制;Remote 层本身服务任何已连接浏览器,与会话控制流对 `jobsBySession` 的广播一致。 + + +### 开发备注 + +
        +维护者的工作上下文——点击展开 + +无。 + +
        + +**运行时不变式:** 不发布伴生入口。控制器是 `ctx.jobs` 读取的无状态投影;本流转发的偏移关系由注册表自己的 `@deepseek-ai/dsh-jobs/invariant` 拥有。 diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json new file mode 100644 index 0000000000..0f0e46aafe --- /dev/null +++ b/packages/api/job-controller/package.json @@ -0,0 +1,86 @@ +{ + "name": "@deepseek-ai/dsh-api-job-controller", + "description": "Job Remote observation stream and the reference-counted client job-output service", + "version": "0.1.2-alpha.5", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/job-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^" + } +} diff --git a/packages/api/job-controller/src/client/index.ts b/packages/api/job-controller/src/client/index.ts new file mode 100644 index 0000000000..a76d507332 --- /dev/null +++ b/packages/api/job-controller/src/client/index.ts @@ -0,0 +1,34 @@ +/** + * Job Controller client half: installs `ctx.jobOutput` over the generated + * `job` Remote namespace. The plugin resolves both Remote faces it drives + * while its own context is current, because observation (re)opens run on + * caller stacks — a React event, a carrier retry — whose dynamic context has + * not declared `remote.job`. + * @module @deepseek-ai/dsh-api-job-controller/client + */ + +import type { Context } from '@deepseek-ai/cordis' +import { ClientJobOutputModel } from './model.ts' +import { ClientJobOutput } from './service.ts' +import type { JobObserveRemote } from './service.ts' + +export { ClientJobOutputModel } from './model.ts' +export type { JobOutputSnapshot, JobOutputSource, ObservedJob } from './model.ts' +export { ClientJobOutput } from './service.ts' +export type { IJobOutput, JobObserveRemote, JobRemote } from './service.ts' +export type { JobObserveFrame, JobObserveRequest, JobObserveStatus, JobWireChunk } from '../types.ts' + +/** Required Client Remote services. */ +export const inject = ['remote', 'remote.job'] + +/** + * Install the client job-output service. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + const remotes = ctx.remote as unknown as JobObserveRemote + new ClientJobOutput(ctx, { + $stream: options => remotes.$stream(options), + job: remotes.job, + }, new ClientJobOutputModel()) +} diff --git a/packages/api/job-controller/src/client/model.ts b/packages/api/job-controller/src/client/model.ts new file mode 100644 index 0000000000..1c44aa039a --- /dev/null +++ b/packages/api/job-controller/src/client/model.ts @@ -0,0 +1,179 @@ +/** + * React-free client job-record observation state: per-job accumulated output + * views fed by `job.observe` frames. Pure data plus subscriptions — transport + * wiring stays in the client service, UI stays in slot components. + * @module @deepseek-ai/dsh-api-job-controller/client/model + */ + +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { JobObserveFrame, JobObserveStatus } from '../types.ts' + +/** Bounded per-job render tail, in UTF-16 code units. */ +const RENDER_TAIL_LIMIT = 128 * 1024 + +/** One observed job's live view state. */ +export interface ObservedJob { + readonly jobId: JobId + /** Accumulated output tail, bounded to the render limit. */ + readonly text: string + /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ + readonly gapBefore: boolean + /** Latest status delivered on the observation stream. */ + readonly status: JobObserveStatus + readonly detail?: string + /** True while the observation stream is open and the job has not settled. */ + readonly streaming: boolean + /** Terminal observation failure, when the stream ended abnormally. */ + readonly error?: string +} + +/** Immutable client job-output state. */ +export interface JobOutputSnapshot { + /** Live observation state keyed by job id. */ + readonly observed: Readonly> +} + +/** Bare observable source for the client job-output snapshot. */ +export interface JobOutputSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): JobOutputSnapshot + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void +} + +/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */ +interface ObservedState { + view: ObservedJob + /** Resume offset for the next generation's `from`. */ + cursor: number | undefined +} + +/** Owns per-job observation state. */ +export class ClientJobOutputModel implements JobOutputSource { + private readonly observedStates = new Map() + private readonly listeners = new Set<() => void>() + private snapshotCache: JobOutputSnapshot = { observed: {} } + private snapshotDirty = false + + getSnapshot(): JobOutputSnapshot { + if (this.snapshotDirty) { + const observed: Record = {} + for (const [id, state] of this.observedStates) observed[id] = state.view + this.snapshotCache = { observed } + this.snapshotDirty = false + } + return this.snapshotCache + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * The resume offset for one job's next observation generation. + * @param id - observed job. + * @returns the last accepted `next`, or undefined for a fresh observation. + */ + cursorOf(id: JobId): number | undefined { + return this.observedStates.get(String(id))?.cursor + } + + /** + * Install or reset observation state when a generation's anchor arrives. + * @param id - observed job. + * @param frame - the generation's `opened` anchor. + */ + observeOpened(id: JobId, frame: Extract): void { + const existing = this.observedStates.get(String(id)) + // A fresh view anchored past offset zero starts after an evicted head + // (fresh observations anchor at the registry's earliest retained byte), so + // it owes the same gap mark a live observer earned from lossy reads. A + // resume that already accumulated text keeps its recorded gap state. + const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0 + const view: ObservedJob = { + jobId: id, + text: existing?.view.text ?? '', + gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead, + status: frame.status, + ...frame.detail !== undefined ? { detail: frame.detail } : {}, + streaming: true, + } + this.observedStates.set(String(id), { view, cursor: frame.from }) + this.changed() + } + + /** + * Append one output frame's chunks to the bounded render tail. + * @param id - observed job. + * @param frame - a coalesced `output` frame. + */ + observeOutput(id: JobId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('') + let gapBefore = state.view.gapBefore || frame.lossy === true + || frame.chunks.some(chunk => chunk.gapBefore === true) + if (text.length > RENDER_TAIL_LIMIT) { + let cut = text.length - RENDER_TAIL_LIMIT + // Never split a surrogate pair at the render bound. + const unit = text.charCodeAt(cut) + if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1 + text = text.slice(cut) + gapBefore = true + } + state.view = { ...state.view, text, gapBefore } + state.cursor = frame.next + this.changed() + } + + /** + * Record the terminal status delivered at the end of the observation stream. + * @param id - observed job. + * @param frame - the terminal `status` frame. + */ + observeStatus(id: JobId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + state.view = { + ...state.view, + status: frame.status, + ...frame.detail !== undefined ? { detail: frame.detail } : {}, + streaming: false, + } + this.changed() + } + + /** + * Record a terminal observation failure. + * @param id - observed job. + * @param error - the stream's terminal failure. + */ + observeFailed(id: JobId, error: unknown): void { + const state = this.observedStates.get(String(id)) + if (state === undefined) return + state.view = { ...state.view, streaming: false, error: String(error) } + this.changed() + } + + /** + * Drop observation state after the last observer stops. + * @param id - the no-longer-observed job. + */ + observeStopped(id: JobId): void { + if (!this.observedStates.delete(String(id))) return + this.changed() + } + + private changed(): void { + this.snapshotDirty = true + notifySubscribers(this.listeners, 'job output') + } +} diff --git a/packages/api/job-controller/src/client/service.ts b/packages/api/job-controller/src/client/service.ts new file mode 100644 index 0000000000..385a0b92ad --- /dev/null +++ b/packages/api/job-controller/src/client/service.ts @@ -0,0 +1,179 @@ +/** + * The `ctx.jobOutput` client service: reference-counted per-job observation + * streams over `job.observe`, so overlapping viewers of one job share one + * stream and resume from the model's cursor across reconnects. + * @module @deepseek-ai/dsh-api-job-controller/client/service + */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { JobObserveFrame, JobObserveRequest } from '../types.ts' +import type { ClientJobOutputModel, JobOutputSource } from './model.ts' + +/** The generated `job` namespace face the observation runner drives. */ +export interface JobRemote { + /** + * Open one observation generation. + * @param request - target job, owning session, and optional resume offset. + * @param signal - generation cancellation. + * @returns the frame sequence of one generation. + */ + observe(request: JobObserveRequest, signal?: AbortSignal): AsyncIterable +} + +/** Remote faces the observation runner drives: the Gateway stream factory and the `job` namespace. */ +export interface JobObserveRemote { + readonly $stream: ClientRemote['$stream'] + readonly job: JobRemote +} + +/** The job-output client service face. */ +export interface IJobOutput { + /** Per-job observation state. */ + readonly state: JobOutputSource + /** + * Start observing one job's live record; reference-counted, so two viewers + * of the same job share one stream. + * @param sessionId - owning session used for the fenced read; undefined for an unowned job. + * @param id - job to observe. + * @returns stop function releasing this observer's reference. + */ + observe(sessionId: SessionId | undefined, id: JobId): () => void +} + +/** One reference-counted observation stream. */ +interface ObservationEntry { + refs: number + stopped: boolean + dispose: () => Promise +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** React-free client job-record observation control. */ + jobOutput: IJobOutput + } +} + +/** Owns the bare job-output snapshot and per-job observation streams. */ +export class ClientJobOutput extends Service implements IJobOutput { + readonly state: JobOutputSource + private readonly entries = new Map() + + /** + * @param ctx - client root Context. + * @param remote - the Gateway stream factory plus the generated `job` namespace, both resolved by the caller. + * @param model - shared client job-output model. + */ + constructor( + ctx: Context, + private readonly remote: JobObserveRemote, + private readonly model: ClientJobOutputModel, + ) { + super(ctx, 'jobOutput') + this.state = model + ctx.effect(() => () => { + const open = [...this.entries.values()] + this.entries.clear() + for (const entry of open) { + entry.stopped = true + void entry.dispose() + } + }, 'job-controller.client.observations') + } + + observe(sessionId: SessionId | undefined, id: JobId): () => void { + const key = String(id) + const existing = this.entries.get(key) + if (existing !== undefined && !existing.stopped) { + existing.refs += 1 + return this.releaser(key, existing) + } + const entry = this.startObservation(sessionId, id) + this.entries.set(key, entry) + return this.releaser(key, entry) + } + + /** + * Release closures bind the exact entry they were minted for, never the + * map's current occupant: a later `observe()` on the same id may have + * replaced a stopped entry, and decrementing or disposing through the key + * alone would tear down that newer stream's references. + */ + private releaser(key: string, entry: ObservationEntry): () => void { + let released = false + return () => { + if (released) return + released = true + entry.refs -= 1 + if (entry.refs > 0) return + if (this.entries.get(key) === entry) this.entries.delete(key) + entry.stopped = true + void entry.dispose().then(() => { + // Clear the view only while no successor observation holds the key: + // a re-expand inside the dispose round-trip already re-anchored the + // model, and a stale clear would blank its panel for good. + if (this.entries.has(key)) return + // The key is the stringified branded id this releaser was minted for. + this.model.observeStopped(key as JobId) + }) + } + } + + private startObservation(sessionId: SessionId | undefined, id: JobId): ObservationEntry { + const name = `job observation ${String(id)}` + const stream = this.remote.$stream({ + name, + open: (signal) => { + const from = this.model.cursorOf(id) + return this.remote.job.observe( + { + jobId: id, + ...sessionId !== undefined ? { sessionId } : {}, + ...from !== undefined ? { from } : {}, + }, + signal, + ) + }, + // A premature end after the anchor is retryable (a Host reload closes the + // generation); resuming from the cursor loses nothing. An end before the + // anchor is terminal. + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${name} ended before settlement`) + : new Error(`${name} ended before its anchor`), + }) + const entry: ObservationEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + } + void (async () => { + try { + for await (const item of stream) { + const frame = item.value + if (frame.type === 'opened') { + this.model.observeOpened(id, frame) + item.accept() + continue + } + if (frame.type === 'output') { + this.model.observeOutput(id, frame) + continue + } + // Terminal status: leave the loop before the generation end is + // classified, then close the stream for good. + this.model.observeStatus(id, frame) + break + } + } catch (error) { + if (!entry.stopped) this.model.observeFailed(id, error) + } finally { + entry.stopped = true + void entry.dispose() + } + })() + return entry + } +} diff --git a/packages/api/job-controller/src/index.ts b/packages/api/job-controller/src/index.ts new file mode 100644 index 0000000000..27e85a0ed0 --- /dev/null +++ b/packages/api/job-controller/src/index.ts @@ -0,0 +1,86 @@ +/** + * Host job Remote owner: streams one background job's observation record to + * browsers over the generated `job` namespace. The roster itself rides the + * session control stream (`jobsBySession`, owned by `dsh-api-session-controller`); + * this controller carries only the non-consuming record output. + * @module @deepseek-ai/dsh-api-job-controller + */ + +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-jobs' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { observeJobRecord } from './observe.ts' +import type { JobObserveFrame, JobObserveRequest } from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host job Remote namespace owner. */ + jobController: JobController + } +} + +/** Default coalescing window between record reads, in milliseconds. */ +const DEFAULT_OBSERVE_FLUSH_MS = 100 + +/** Default soft byte budget per output frame. */ +const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024 + +/** Job Controller deployment policy. */ +export interface Config { + /** Coalescing window after new record output before an observation read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number +} + +/** Host service backing the generated `ctx.remote.job` namespace. */ +export class JobController extends TypertRemoteService { + static inject = ['agents', 'jobs', 'typert'] + + static Config: z = z.object({ + observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS), + observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES), + }) + + private readonly observeFlushMs: number + private readonly observeMaxFrameBytes: number + + /** + * @param ctx - Host context carrying the live Agent registry and the job registry. + * @param config - observation cadence and framing policy. + */ + constructor(ctx: Context, config: Config) { + super(ctx, 'jobController', { namespace: 'job' }) + // schemastery (the exported Config schema) has already filled the defaulted + // fields; the assertion records that resolution, not a hidden fallback. + const resolved = config as Required + this.observeFlushMs = resolved.observeFlushMs + this.observeMaxFrameBytes = resolved.observeMaxFrameBytes + } + + /** + * Stream one job's retained record output from an absolute byte offset, + * then its terminal status once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session resolves the fenced-read caller; the registry rejects a + * job the session does not own, a job without a record, or an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ + @Remote({ mode: 'stream' }) + observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable { + const caller = request.sessionId === undefined ? undefined : this.ctx.agents.get(request.sessionId) + return observeJobRecord(this.ctx.jobs, request, { + flushMs: this.observeFlushMs, + maxFrameBytes: this.observeMaxFrameBytes, + caller, + }, signal) + } +} + +export default JobController diff --git a/packages/api/session-controller/src/observe-job.ts b/packages/api/job-controller/src/observe.ts similarity index 94% rename from packages/api/session-controller/src/observe-job.ts rename to packages/api/job-controller/src/observe.ts index 795e786455..731f40b2e2 100644 --- a/packages/api/session-controller/src/observe-job.ts +++ b/packages/api/job-controller/src/observe.ts @@ -3,7 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { JobRegistry, JobSnapshot } from '@deepseek-ai/dsh-jobs' -import type { SessionJobWireChunk, SessionObserveJobFrame, SessionObserveJobRequest } from './types.ts' +import type { JobObserveFrame, JobObserveRequest, JobWireChunk } from './types.ts' /** Cadence and framing bounds for one observation generation. */ export interface ObserveJobOptions { @@ -91,16 +91,16 @@ function sleep(ms: number, signal: AbortSignal): Promise { */ export async function* observeJobRecord( registry: JobRegistry, - request: SessionObserveJobRequest, + request: JobObserveRequest, options: ObserveJobOptions, signal: AbortSignal, -): AsyncIterable { +): AsyncIterable { if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) { throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`) } signal.throwIfAborted() // The brand is nominal typing only; the wire boundary stamps it here rather - // than value-importing the optional registry package's constructor. + // than value-importing the registry package's constructor. const id = String(request.jobId) as JobId const waiter = new OutputWaiter() // Subscribe before the first read so an append between the anchor read and @@ -153,12 +153,12 @@ export async function* observeJobRecord( /** Split one read into frames along the soft per-frame byte budget. */ function* outputFrames( - chunks: readonly SessionJobWireChunk[], + chunks: readonly JobWireChunk[], next: number, lossy: boolean, maxFrameBytes: number, -): Iterable { - let batch: SessionJobWireChunk[] = [] +): Iterable { + let batch: JobWireChunk[] = [] let batchBytes = 0 let flaggedLossy = lossy for (const chunk of chunks) { diff --git a/packages/api/job-controller/src/types.ts b/packages/api/job-controller/src/types.ts new file mode 100644 index 0000000000..98df4d8f65 --- /dev/null +++ b/packages/api/job-controller/src/types.ts @@ -0,0 +1,75 @@ +/** + * Wire types of the generated `job` Remote namespace: the observation request + * and the frames its stream yields. Client-safe: no Host imports. + * @module @deepseek-ai/dsh-api-job-controller/types + */ + +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** + * A job's lifecycle state on the wire. Spelled out rather than imported from + * the registry package so this client-safe face pulls no Host declaration + * merges into browser programs; it is the registry's `JobStatus` union. + */ +export type JobObserveStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + +/** Target of one `job.observe` stream: the job, its owning session, and an optional resume offset. */ +export interface JobObserveRequest { + /** + * Owning session, resolved to the live agent for the fenced record read. + * Omitted for an unowned job, which any caller may observe. + */ + readonly sessionId?: SessionId + readonly jobId: JobId + /** + * Absolute byte offset to resume from (a prior frame's `next`). Omitted, the + * stream starts at the oldest retained byte. + */ + readonly from?: number +} + +/** One retained record chunk on the wire. `channel` widens to string like a job's `kind`. */ +export interface JobWireChunk { + /** Absolute offset of the chunk's first byte. */ + readonly at: number + readonly text: string + readonly channel?: string + /** Bytes immediately before this chunk were lost at the producer or to retention. */ + readonly gapBefore?: true +} + +/** + * Job observation stream frames: one `opened` anchor, then coalesced `output` + * batches, then — once the job has settled and its retained record is + * drained — one terminal `status`, after which the stream closes normally. + * Status rides the same stream as output so settlement can never race a + * still-open output channel. + */ +export type JobObserveFrame = + | { + readonly type: 'opened' + readonly jobId: JobId + /** Offset the first `output` frame continues from. */ + readonly from: number + /** Oldest retained byte at open time; `from < earliest` means the head is gone. */ + readonly earliest: number + /** Total bytes produced at open time. */ + readonly total: number + readonly status: JobObserveStatus + readonly detail?: string + } + | { + readonly type: 'output' + readonly chunks: readonly JobWireChunk[] + /** Offset to resume from after this frame. */ + readonly next: number + /** Bytes between the requested offset and `chunks` were already evicted. */ + readonly lossy?: true + } + | { + readonly type: 'status' + readonly status: JobObserveStatus + readonly detail?: string + readonly finishedAt?: number + } diff --git a/packages/api/job-controller/tests/client-apply.client.spec.ts b/packages/api/job-controller/tests/client-apply.client.spec.ts new file mode 100644 index 0000000000..ebbe67e98e --- /dev/null +++ b/packages/api/job-controller/tests/client-apply.client.spec.ts @@ -0,0 +1,65 @@ +import { Context } from '@deepseek-ai/cordis' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { RemoteStream, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { afterEach, describe, expect, it } from 'vitest' +import * as JobClient from '../src/client/index.ts' +import type { JobObserveFrame } from '../src/types.ts' + +const contexts = new Set() + +afterEach(async () => { + await Promise.all([...contexts].map(async (ctx) => { await ctx.fiber.dispose() })) + contexts.clear() +}) + +/** Mount the client half over a captured `remote.job` namespace and a real Gateway stream carrier. */ +async function mount(): Promise<{ ctx: Context; observeCalls: unknown[] }> { + const ctx = new Context() + contexts.add(ctx) + const observeCalls: unknown[] = [] + const connection: ConnectionHandle = { + isLoopback: true, + generation: { getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {} }, + state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} }, + rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')) }, + reconnect: () => {}, + registerGenerationSource: () => () => {}, + start: () => ({ stop: () => {} }), + } + const job = { + // The fake stream never yields; opening and releasing must leave no view behind. + observe: (request: unknown) => { + observeCalls.push(request) + return (async function* (): AsyncGenerator {})() + }, + } + ctx.reflect.provide('remote', { + $stream: (options: RemoteStreamOptions) => new RemoteStream(connection, options), + job, + }) + ctx.reflect.provide('remote.job', job) + await ctx.plugin(JobClient) + return { ctx, observeCalls } +} + +async function flush(): Promise { + for (let index = 0; index < 12; index++) await Promise.resolve() +} + +describe('Job Controller Client apply', () => { + it('declares the Remote services it binds', () => { + expect(JobClient.inject).toEqual(['remote', 'remote.job']) + }) + + it('installs ctx.jobOutput over the captured job namespace', async () => { + const { ctx, observeCalls } = await mount() + expect(ctx.jobOutput).toBeDefined() + const stop = ctx.jobOutput.observe('session-1' as SessionId, 'bash-1' as never) + await flush() + expect(observeCalls).toEqual([{ sessionId: 'session-1', jobId: 'bash-1' }]) + stop() + await flush() + expect(ctx.jobOutput.state.getSnapshot().observed).toEqual({}) + }) +}) diff --git a/packages/api/session-controller/tests/job-output.client.spec.ts b/packages/api/job-controller/tests/model.client.spec.ts similarity index 98% rename from packages/api/session-controller/tests/job-output.client.spec.ts rename to packages/api/job-controller/tests/model.client.spec.ts index a4ca2933dc..69260ff3fc 100644 --- a/packages/api/session-controller/tests/job-output.client.spec.ts +++ b/packages/api/job-controller/tests/model.client.spec.ts @@ -2,8 +2,9 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { ClientJobOutput, ClientJobOutputModel } from '../src/client/job-output.ts' -import type { SessionObserveJobFrame } from '../src/types.ts' +import { ClientJobOutputModel } from '../src/client/model.ts' +import { ClientJobOutput } from '../src/client/service.ts' +import type { JobObserveFrame } from '../src/types.ts' const ID = 'bash-1' as JobId @@ -152,12 +153,12 @@ class FakeStream { deferDispose = false private disposeRelease: (() => void) | undefined private disposePending: Promise | undefined - private readonly frames: SessionObserveJobFrame[] = [] + private readonly frames: JobObserveFrame[] = [] private waiter: (() => void) | undefined private failure: Error | undefined readonly accepts: number[] = [] - push(frame: SessionObserveJobFrame): void { + push(frame: JobObserveFrame): void { this.frames.push(frame) this.waiter?.() } @@ -218,8 +219,8 @@ function bench() { streams.push({ options, stream }) return stream }, - session: { - observeJob: (request: unknown) => { + job: { + observe: (request: unknown) => { observeCalls.push(request) return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } }, diff --git a/packages/api/session-controller/tests/observe-job.host.spec.ts b/packages/api/job-controller/tests/observe.host.spec.ts similarity index 89% rename from packages/api/session-controller/tests/observe-job.host.spec.ts rename to packages/api/job-controller/tests/observe.host.spec.ts index 06a67848ec..5bbb5a8c66 100644 --- a/packages/api/session-controller/tests/observe-job.host.spec.ts +++ b/packages/api/job-controller/tests/observe.host.spec.ts @@ -6,10 +6,11 @@ import { JobId } from '@deepseek-ai/dsh-jobs' import type { JobOutcome, RunningJob } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' -import { observeJobRecord } from '../src/observe-job.ts' -import type { ObserveJobOptions } from '../src/observe-job.ts' -import type { SessionObserveJobFrame } from '../src/types.ts' -import { createSessionTestController } from './test-remote.ts' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { JobController } from '../src/index.ts' +import { observeJobRecord } from '../src/observe.ts' +import type { ObserveJobOptions } from '../src/observe.ts' +import type { JobObserveFrame } from '../src/types.ts' async function harness() { const ctx = new Context() @@ -88,7 +89,7 @@ const OBSERVE: Omit = { flushMs: 5, maxFrameBytes: describe('observeJobRecord', () => { function observed(ctx: Context, request: { jobId: JobId; from?: number }, caller?: Agent) { const abort = new AbortController() - const frames: SessionObserveJobFrame[] = [] + const frames: JobObserveFrame[] = [] const stream = observeJobRecord(ctx.jobs, request, { ...OBSERVE, caller }, abort.signal) const done = (async () => { for await (const frame of stream) frames.push(frame) @@ -142,7 +143,7 @@ describe('observeJobRecord', () => { job.append('y'.repeat(30)) await job.settle({ status: 'completed' }) const abort = new AbortController() - const frames = await collect( + const frames = await collect( observeJobRecord(ctx.jobs, { jobId: job.id }, { flushMs: 5, maxFrameBytes: 30, @@ -165,7 +166,7 @@ describe('observeJobRecord', () => { job.append('c'.repeat(40)) await job.settle({ status: 'completed' }) const abort = new AbortController() - const frames = await collect( + const frames = await collect( observeJobRecord(ctx.jobs, { jobId: job.id, from: 0 }, { flushMs: 5, maxFrameBytes: 40, @@ -278,28 +279,30 @@ describe('observeJobRecord', () => { }) }) -describe('SessionController.observeJob', () => { - const defaults = { - defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), - cwd: '/tmp', - } - - it('refuses observation without a job registry', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - const controller = createSessionTestController(ctx, defaults) - expect(() => controller.observeJob({ jobId: JobId('bash-1') }, new AbortController().signal)) - .toThrow(/job observation unavailable/) - }) - - it('resolves the fenced-read caller from the request session', async () => { +describe('JobController.observe', () => { + async function controllerHarness() { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) + await ctx.plugin(TypertRegistry) ctx.jobs.attachController('observe-job-test') - const controller = createSessionTestController(ctx, defaults) + await ctx.plugin(JobController, {}) + return ctx + } + + it('waits for the job registry instead of serving without one', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(TypertRegistry) + ctx.plugin(JobController, {}) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(ctx.get('jobController')).toBeUndefined() + }) + + it('resolves the fenced-read caller from the request session', async () => { + const ctx = await controllerHarness() const session = ctx.sessions.create(SessionId('observing-session')) const owner = { id: session.id, @@ -314,8 +317,8 @@ describe('SessionController.observeJob', () => { await job.settle({ status: 'completed' }) const abort = new AbortController() - const frames = await collect( - controller.observeJob({ sessionId: session.id, jobId: job.id }, abort.signal), + const frames = await collect( + ctx.jobController.observe({ sessionId: session.id, jobId: job.id }, abort.signal), 3, abort, ) @@ -323,19 +326,14 @@ describe('SessionController.observeJob', () => { }) it('observes an unowned job without a request session', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - ctx.jobs.attachController('observe-job-test') - const controller = createSessionTestController(ctx, defaults) + const ctx = await controllerHarness() const job = startRecordJob(ctx, { label: 'unowned run' }) job.append('open access') await job.settle({ status: 'completed' }) const abort = new AbortController() - const frames = await collect( - controller.observeJob({ jobId: job.id }, abort.signal), + const frames = await collect( + ctx.jobController.observe({ jobId: job.id }, abort.signal), 3, abort, ) diff --git a/packages/api/job-controller/tsconfig.client.json b/packages/api/job-controller/tsconfig.client.json new file mode 100644 index 0000000000..34a2202060 --- /dev/null +++ b/packages/api/job-controller/tsconfig.client.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/model.ts", + "src/client/service.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../gateway/tsconfig.client.json" }, + { "path": "../../client/connection/tsconfig.client.json" }, + { "path": "../../client/store" }, + { "path": "../../core/session" }, + { "path": "../../jobs/jobs" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/job-controller/tsconfig.host.json b/packages/api/job-controller/tsconfig.host.json new file mode 100644 index 0000000000..96de4dede2 --- /dev/null +++ b/packages/api/job-controller/tsconfig.host.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/observe.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../core/agent" }, + { "path": "../../core/session" }, + { "path": "../../jobs/jobs" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/job-controller/tsconfig.json b/packages/api/job-controller/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/job-controller/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/job-controller/tsdown.config.ts b/packages/api/job-controller/tsdown.config.ts new file mode 100644 index 0000000000..0391bc61bf --- /dev/null +++ b/packages/api/job-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-job-controller', + ['lib/types/index.js'], + { hostPhase: true }, +) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index a60099926e..6aabaa7039 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -62,8 +62,11 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-job-controller": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-settings-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", @@ -72,14 +75,12 @@ "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-user-questions": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/dsh-user-questions": "workspace:^" } } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index e7fc7757e9..ce08f3dbe3 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -12,6 +12,7 @@ import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' +import jobRemote from '@deepseek-ai/dsh-api-job-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -29,6 +30,8 @@ export type {} from '@deepseek-ai/dsh-subagent/remote' export type * from '@deepseek-ai/dsh-subagent/client' export type {} from '@deepseek-ai/dsh-api-session-controller/remote' export type * from '@deepseek-ai/dsh-api-session-controller/types' +export type {} from '@deepseek-ai/dsh-api-job-controller/remote' +export type * from '@deepseek-ai/dsh-api-job-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' @@ -146,7 +149,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, - subagentsRemote, sessionRemote, workspaceRemote, + subagentsRemote, sessionRemote, jobRemote, workspaceRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 56f664d499..ddd32b2af2 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -22,7 +22,6 @@ }, { "path": "../../credentials/credentials" - }, { "path": "../../context/file-reference" @@ -63,6 +62,9 @@ { "path": "../../interaction/user-questions" }, + { + "path": "../job-controller/tsconfig.client.json" + }, { "path": "../session-controller/tsconfig.client.json" }, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 8bfe2650e7..fa85bc3ebe 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: 5f1e78b8d95d3fce99c5a533f05ccc6da1455a14 -README.zh.md: f4276b4f8940d66fbf6e76f3b4b49e55b51179d1 +README.md: 4f94607e70a5b963fa50d7a497afbcc97d273cb9 +README.zh.md: 455769f77ce8ad304949624de938e56579024145 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 5f1e78b8d9..4f94607e70 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,7 +29,7 @@ Each endpoint states its activation policy. List, search, attachment, history pa The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. -A `SessionJob` row carries `outputTotal` exactly when the job declared an observation record, and `session.observeJob` streams that record from an absolute byte offset — one `opened` anchor, coalesced `output` frames, then one terminal `status` — with the fenced read resolved from the request's session. Record reads are non-consuming: the model-facing cursor and completion-notice state never observe them. The Client adapter exposes `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views a task-list UI renders; observers share one stream per job and resume from their model cursor across reconnects. +A `SessionJob` row carries `outputTotal` exactly when the job declared an observation record; streaming that record is the job controller's `job.observe` (see [`dsh-api-job-controller`](../job-controller/README.md)), while this package keeps the roster on the control stream. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. @@ -43,8 +43,6 @@ The Session object also carries local submission echoes: `session.beginSubmissio | `coldBlankProbeMaxEvents` | `16` | Maximum stat-reported event count of a cold Session eligible for blankness verification; `0` disables the event-count gate | | `coldBlankProbeMaxBytes` | `1,024` | Maximum stat-reported artifact byte size of a cold Session eligible for blankness verification when the backend offers no event count; `0` disables the byte-size gate | | `nativeOpen` | platform-detected | Whether Session workspace paths can be handed to a native desktop opener | -| `observeFlushMs` | `100` | Coalescing window after new record output before a job-observation read, in milliseconds | -| `observeMaxFrameBytes` | `65,536` | Soft byte budget per job-observation output frame; one larger chunk ships whole | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-session-controller) is the exhaustive source for accepted fields and their JSDoc. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index f4276b4f89..455769f77c 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 -`SessionJob` 行恰在该 job 声明了观测 record 时携带 `outputTotal`,`session.observeJob` 从绝对字节偏移流式发送该 record——一个 `opened` 锚帧、聚合的 `output` 帧、最后一个终态 `status` 帧——围栏读取者由请求的 session 解析。record 读取是非消耗的:模型侧游标与完成通知状态永远观察不到它们。Client adapter 提供 `ctx.jobOutput`:按 job 引用计数的观测服务,任务列表 UI 渲染其累积视图;同一 job 的观察者共享一条流,重连时从模型游标续读。 +`SessionJob` 行恰在该 job 声明了观测 record 时携带 `outputTotal`;流式发送该 record 的是 job 控制器的 `job.observe`(见 [`dsh-api-job-controller`](../job-controller/README.zh.md)),本包只在控制流上保留名册。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 @@ -43,8 +43,6 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 | `coldBlankProbeMaxEvents` | `16` | stat 报告的事件数不超过该值的冷 Session 才可进行空白状态验证;`0` 禁用事件数门槛 | | `coldBlankProbeMaxBytes` | `1,024` | 后端不提供事件数时,stat 报告的工件字节数不超过该值的冷 Session 才可进行空白状态验证;`0` 禁用字节数门槛 | | `nativeOpen` | 平台探测 | 是否能把 Session 工作区路径交给原生桌面打开器 | -| `observeFlushMs` | `100` | 新 record 输出到一次 job 观测读取之间的聚合窗口,毫秒 | -| `observeMaxFrameBytes` | `65,536` | 每个 job 观测输出帧的软字节预算;更大的单块整块发送 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-session-controller)是所有受支持字段及其 JSDoc 的完整来源。 diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 5fc9570a55..4bad2bb8c2 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -3,7 +3,6 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent/types' import { createSessionControlStream } from './transport.ts' -import { ClientJobOutput, ClientJobOutputModel } from './job-output.ts' import { ClientSessions } from './sessions/service.ts' import type { SessionRemotes } from './sessions/remotes.ts' import type {} from '../remote-events.ts' @@ -24,8 +23,6 @@ export type { } from './transport.ts' export { createScope, scopeOf } from './scope.ts' export type { AgentContext, AgentScopeHandle } from './scope.ts' -export { ClientJobOutput, ClientJobOutputModel } from './job-output.ts' -export type { IJobOutput, JobOutputSnapshot, JobOutputSource, ObservedJob } from './job-output.ts' export { SessionCreateError, SessionForkError } from './sessions/service.ts' export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' export type { @@ -92,14 +89,6 @@ export const inject = [ export function apply(ctx: Context): void { const remotes = ctx.remote as unknown as SessionRemotes const sessions = new ClientSessions(ctx, remotes) - // Resolve the session namespace to a concrete value while this plugin's - // context is current: observation (re)opens run on caller stacks (a React - // event, a carrier retry) whose dynamic context has not declared - // `remote.session`, and a property access there fails the inject check. - new ClientJobOutput(ctx, { - $stream: options => remotes.$stream(options), - session: remotes.session, - }, new ClientJobOutputModel()) ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) }) ctx.remote.$on('api-session/status', (sessionId, running) => { diff --git a/packages/api/session-controller/src/client/job-output.ts b/packages/api/session-controller/src/client/job-output.ts deleted file mode 100644 index 933c104497..0000000000 --- a/packages/api/session-controller/src/client/job-output.ts +++ /dev/null @@ -1,348 +0,0 @@ -/** - * React-free client job-record observation: per-job accumulated output views - * over the `session.observeJob` stream, reference-counted per job so - * overlapping panels share one stream. The roster itself rides the session - * control stream (`jobsBySession`); this module carries only the output. - * @module @deepseek-ai/dsh-api-session-controller/client/job-output - */ - -import { Service, type Context } from '@deepseek-ai/cordis' -import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' -import { notifySubscribers } from '@deepseek-ai/dsh-client-store' -import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SessionJob, SessionObserveJobFrame } from '../types.ts' -import type { SessionRemote } from './transport.ts' - -/** Bounded per-job render tail, in UTF-16 code units. */ -const RENDER_TAIL_LIMIT = 128 * 1024 - -/** One observed job's live view state. */ -export interface ObservedJob { - readonly jobId: JobId - /** Accumulated output tail, bounded to the render limit. */ - readonly text: string - /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ - readonly gapBefore: boolean - /** Latest status delivered on the observation stream. */ - readonly status: SessionJob['status'] - readonly detail?: string - /** True while the observation stream is open and the job has not settled. */ - readonly streaming: boolean - /** Terminal observation failure, when the stream ended abnormally. */ - readonly error?: string -} - -/** Immutable client job-output state. */ -export interface JobOutputSnapshot { - /** Live observation state keyed by job id. */ - readonly observed: Readonly> -} - -/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */ -interface ObservedState { - view: ObservedJob - /** Resume offset for the next generation's `from`. */ - cursor: number | undefined -} - -/** - * Owns per-job observation state. Pure data plus subscriptions — transport - * wiring stays in the client service, UI stays in slot components. - */ -export class ClientJobOutputModel { - private readonly observedStates = new Map() - private readonly listeners = new Set<() => void>() - private snapshotCache: JobOutputSnapshot = { observed: {} } - private snapshotDirty = false - - /** - * Read the identity-stable current snapshot. - * @returns the cached snapshot, rebuilt only after a change. - */ - getSnapshot(): JobOutputSnapshot { - if (this.snapshotDirty) { - const observed: Record = {} - for (const [id, state] of this.observedStates) observed[id] = state.view - this.snapshotCache = { observed } - this.snapshotDirty = false - } - return this.snapshotCache - } - - /** - * Subscribe to snapshot changes. - * @param listener - invalidation callback. - * @returns unsubscribe function. - */ - subscribe(listener: () => void): () => void { - this.listeners.add(listener) - return () => { this.listeners.delete(listener) } - } - - /** - * The resume offset for one job's next observation generation. - * @param id - observed job. - * @returns the last accepted `next`, or undefined for a fresh observation. - */ - cursorOf(id: JobId): number | undefined { - return this.observedStates.get(String(id))?.cursor - } - - /** - * Install or reset observation state when a generation's anchor arrives. - * @param id - observed job. - * @param frame - the generation's `opened` anchor. - */ - observeOpened(id: JobId, frame: Extract): void { - const existing = this.observedStates.get(String(id)) - // A fresh view anchored past offset zero starts after an evicted head - // (fresh observations anchor at the registry's earliest retained byte), so - // it owes the same gap mark a live observer earned from lossy reads. A - // resume that already accumulated text keeps its recorded gap state. - const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0 - const view: ObservedJob = { - jobId: id, - text: existing?.view.text ?? '', - gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead, - status: frame.status, - ...frame.detail !== undefined ? { detail: frame.detail } : {}, - streaming: true, - } - this.observedStates.set(String(id), { view, cursor: frame.from }) - this.changed() - } - - /** - * Append one output frame's chunks to the bounded render tail. - * @param id - observed job. - * @param frame - a coalesced `output` frame. - */ - observeOutput(id: JobId, frame: Extract): void { - const state = this.observedStates.get(String(id)) - /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ - if (state === undefined) return - let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('') - let gapBefore = state.view.gapBefore || frame.lossy === true - || frame.chunks.some(chunk => chunk.gapBefore === true) - if (text.length > RENDER_TAIL_LIMIT) { - let cut = text.length - RENDER_TAIL_LIMIT - // Never split a surrogate pair at the render bound. - const unit = text.charCodeAt(cut) - if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1 - text = text.slice(cut) - gapBefore = true - } - state.view = { ...state.view, text, gapBefore } - state.cursor = frame.next - this.changed() - } - - /** - * Record the terminal status delivered at the end of the observation stream. - * @param id - observed job. - * @param frame - the terminal `status` frame. - */ - observeStatus(id: JobId, frame: Extract): void { - const state = this.observedStates.get(String(id)) - /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ - if (state === undefined) return - state.view = { - ...state.view, - status: frame.status, - ...frame.detail !== undefined ? { detail: frame.detail } : {}, - streaming: false, - } - this.changed() - } - - /** - * Record a terminal observation failure. - * @param id - observed job. - * @param error - the stream's terminal failure. - */ - observeFailed(id: JobId, error: unknown): void { - const state = this.observedStates.get(String(id)) - if (state === undefined) return - state.view = { ...state.view, streaming: false, error: String(error) } - this.changed() - } - - /** - * Drop observation state after the last observer stops. - * @param id - the no-longer-observed job. - */ - observeStopped(id: JobId): void { - if (!this.observedStates.delete(String(id))) return - this.changed() - } - - private changed(): void { - this.snapshotDirty = true - notifySubscribers(this.listeners, 'job output') - } -} - -/** Bare observable source for the client job-output snapshot. */ -export interface JobOutputSource { - /** Read the identity-stable current snapshot. */ - getSnapshot(): JobOutputSnapshot - /** - * Subscribe to snapshot changes. - * @param listener - invalidation callback. - * @returns unsubscribe function. - */ - subscribe(listener: () => void): () => void -} - -/** The job-output client service face. */ -export interface IJobOutput { - /** Per-job observation state. */ - readonly state: JobOutputSource - /** - * Start observing one job's live record; reference-counted, so two viewers - * of the same job share one stream. - * @param sessionId - owning session used for the fenced read; undefined for an unowned job. - * @param id - job to observe. - * @returns stop function releasing this observer's reference. - */ - observe(sessionId: SessionId | undefined, id: JobId): () => void -} - -/** One reference-counted observation stream. */ -interface ObservationEntry { - refs: number - stopped: boolean - dispose: () => Promise -} - -/** Remote face the observation runner drives. */ -type JobObserveRemote = Pick & { readonly session: Pick } - -declare module '@deepseek-ai/cordis' { - interface Context { - /** React-free client job-record observation control. */ - jobOutput: IJobOutput - } -} - -/** Owns the bare job-output snapshot and per-job observation streams. */ -export class ClientJobOutput extends Service implements IJobOutput { - readonly state: JobOutputSource - private readonly entries = new Map() - - /** - * @param ctx - client root Context. - * @param remote - generated session namespace plus the Gateway stream factory. - * @param model - shared client job-output model. - */ - constructor( - ctx: Context, - private readonly remote: JobObserveRemote, - private readonly model: ClientJobOutputModel, - ) { - super(ctx, 'jobOutput') - this.state = model - ctx.effect(() => () => { - const open = [...this.entries.values()] - this.entries.clear() - for (const entry of open) { - entry.stopped = true - void entry.dispose() - } - }, 'session-controller.client.observations') - } - - observe(sessionId: SessionId | undefined, id: JobId): () => void { - const key = String(id) - const existing = this.entries.get(key) - if (existing !== undefined && !existing.stopped) { - existing.refs += 1 - return this.releaser(key, existing) - } - const entry = this.startObservation(sessionId, id) - this.entries.set(key, entry) - return this.releaser(key, entry) - } - - /** - * Release closures bind the exact entry they were minted for, never the - * map's current occupant: a later `observe()` on the same id may have - * replaced a stopped entry, and decrementing or disposing through the key - * alone would tear down that newer stream's references. - */ - private releaser(key: string, entry: ObservationEntry): () => void { - let released = false - return () => { - if (released) return - released = true - entry.refs -= 1 - if (entry.refs > 0) return - if (this.entries.get(key) === entry) this.entries.delete(key) - entry.stopped = true - void entry.dispose().then(() => { - // Clear the view only while no successor observation holds the key: - // a re-expand inside the dispose round-trip already re-anchored the - // model, and a stale clear would blank its panel for good. - if (this.entries.has(key)) return - // The key is the stringified branded id this releaser was minted for. - this.model.observeStopped(key as JobId) - }) - } - } - - private startObservation(sessionId: SessionId | undefined, id: JobId): ObservationEntry { - const name = `job observation ${String(id)}` - const stream = this.remote.$stream({ - name, - open: (signal) => { - const from = this.model.cursorOf(id) - return this.remote.session.observeJob( - { - jobId: id, - ...sessionId !== undefined ? { sessionId } : {}, - ...from !== undefined ? { from } : {}, - }, - signal, - ) - }, - // A premature end after the anchor is retryable (a Host reload closes the - // generation); resuming from the cursor loses nothing. An end before the - // anchor is terminal. - ended: accepted => accepted - ? new RemoteStreamCarrierError(`${name} ended before settlement`) - : new Error(`${name} ended before its anchor`), - }) - const entry: ObservationEntry = { - refs: 1, - stopped: false, - dispose: () => stream.dispose(), - } - void (async () => { - try { - for await (const item of stream) { - const frame = item.value - if (frame.type === 'opened') { - this.model.observeOpened(id, frame) - item.accept() - continue - } - if (frame.type === 'output') { - this.model.observeOutput(id, frame) - continue - } - // Terminal status: leave the loop before the generation end is - // classified, then close the stream for good. - this.model.observeStatus(id, frame) - break - } - } catch (error) { - if (!entry.stopped) this.model.observeFailed(id, error) - } finally { - entry.stopped = true - void entry.dispose() - } - })() - return entry - } -} diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b6dd8e08b5..1f8718e62f 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -2,7 +2,6 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type {} from '@deepseek-ai/dsh-jobs' import { errorChain } from '@deepseek-ai/dsh-llm' import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -17,7 +16,6 @@ import { import { SessionCommandController } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' -import { observeJobRecord } from './observe-job.ts' import { SessionFileReferences } from './file-references.ts' import { ApiSessionList, @@ -42,8 +40,6 @@ import type { SessionForkValue, SessionListRequest, SessionListValue, - SessionObserveJobFrame, - SessionObserveJobRequest, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, SessionPage, @@ -72,12 +68,6 @@ declare module '@deepseek-ai/cordis' { } } -/** Default coalescing window between job-observation reads, in milliseconds. */ -const DEFAULT_OBSERVE_FLUSH_MS = 100 - -/** Default soft byte budget per job-observation output frame. */ -const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024 - /** Session Controller deployment policy. */ export interface Config { /** Maximum stat-reported event count eligible for one full cold projection observation; `0` disables the event-count gate. */ @@ -86,10 +76,6 @@ export interface Config { readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean - /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */ - readonly observeFlushMs?: number - /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */ - readonly observeMaxFrameBytes?: number } /** Host integrations replaceable by direct unit tests. */ @@ -118,8 +104,6 @@ export class SessionController extends TypertRemoteService { coldBlankProbeMaxEvents: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS), coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), nativeOpen: z.boolean(), - observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS), - observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES), }) private readonly agents: ApiSessionAgentController @@ -130,8 +114,6 @@ export class SessionController extends TypertRemoteService { private readonly openPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean private readonly promotions = new Set>() - private readonly observeFlushMs: number - private readonly observeMaxFrameBytes: number /** * @param ctx - Host context containing the Session capability assembly. @@ -157,8 +139,6 @@ export class SessionController extends TypertRemoteService { this.openPath = internals.openPath ?? openNativePath this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) - this.observeFlushMs = config.observeFlushMs ?? DEFAULT_OBSERVE_FLUSH_MS - this.observeMaxFrameBytes = config.observeMaxFrameBytes ?? DEFAULT_OBSERVE_MAX_FRAME_BYTES ctx.plugin(SessionFileReferences) ctx.plugin(SessionSkillCatalog) @@ -419,29 +399,6 @@ export class SessionController extends TypertRemoteService { return this.controlState.control(signal) } - /** - * Stream one job's retained record output from an absolute byte offset, - * then its terminal status once settled and drained. Non-consuming: the - * model-facing cursor and notice state never observe these reads. The - * request's session resolves the fenced-read caller; the registry rejects a - * job the session does not own, a job without a record, or an unknown job. - * @param request - target job, owning session, and optional resume offset. - * @param signal - cancellation owned by the Remote stream carrier. - * @returns anchor, coalesced output frames, and the terminal status. - */ - @Remote({ mode: 'stream' }) - observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable { - const registry = this.ctx.get('jobs') - if (registry === undefined) { - throw new Error('job observation unavailable: no job registry is loaded (load @deepseek-ai/dsh-jobs-local)') - } - const caller = request.sessionId === undefined ? undefined : this.ctx.agents.get(request.sessionId) - return observeJobRecord(registry, request, { - flushMs: this.observeFlushMs, - maxFrameBytes: this.observeMaxFrameBytes, - caller, - }, signal) - } } diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index b02c69ede8..c5a6caeacb 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -491,7 +491,7 @@ export interface SessionJob { /** * Total UTF-8 bytes the job's observation record holds so far. Present * exactly when the job declared a record — the row is observable through - * `session.observeJob`. + * the job controller's `job.observe` stream. */ readonly outputTotal?: number } @@ -518,62 +518,7 @@ export type SessionControlFrame = | { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] } | ({ readonly type: 'projection' } & SessionProjectionUpdate) -/** Opens one observation stream over a job's retained record output. */ -export interface SessionObserveJobRequest { - /** Owning session used for the fenced read; omitted for an unowned job. */ - readonly sessionId?: SessionId - readonly jobId: JobId - /** - * Absolute byte offset to resume from — a previous frame's `next`. Omitted - * starts at the oldest retained byte. - */ - readonly from?: number -} -/** One retained record chunk on the wire. `channel` widens to string like `kind`. */ -export interface SessionJobWireChunk { - /** Absolute offset of the chunk's first byte. */ - readonly at: number - readonly text: string - readonly channel?: string - /** Bytes immediately before this chunk were lost at the producer or to retention. */ - readonly gapBefore?: true -} - -/** - * Job observation stream frames: one `opened` anchor, then coalesced `output` - * batches, then — once the job has settled and its retained record is - * drained — one terminal `status`, after which the stream closes normally. - * Status rides the same stream as output so settlement can never race a - * still-open output channel. - */ -export type SessionObserveJobFrame = - | { - readonly type: 'opened' - readonly jobId: JobId - /** Offset the first `output` frame continues from. */ - readonly from: number - /** Oldest retained byte at open time; `from < earliest` means the head is gone. */ - readonly earliest: number - /** Total bytes produced at open time. */ - readonly total: number - readonly status: SessionJob['status'] - readonly detail?: string - } - | { - readonly type: 'output' - readonly chunks: readonly SessionJobWireChunk[] - /** Offset to resume from after this frame. */ - readonly next: number - /** Bytes between the requested offset and `chunks` were already evicted. */ - readonly lossy?: true - } - | { - readonly type: 'status' - readonly status: SessionJob['status'] - readonly detail?: string - readonly finishedAt?: number - } declare module '@deepseek-ai/cordis' { interface Events { diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index b3fd854a68..07f88b1a4f 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -106,18 +106,6 @@ async function flush(): Promise { } describe('Session Controller Client apply', () => { - it('installs ctx.jobOutput over the captured session namespace', async () => { - const bench = await mount() - expect(bench.ctx.jobOutput).toBeDefined() - // The fake observeJob never yields; opening and releasing must leave no - // view and no error behind. - const stop = bench.ctx.jobOutput.observe(sid('session-1'), 'bash-1' as never) - await flush() - stop() - await flush() - expect(bench.ctx.jobOutput.state.getSnapshot().observed).toEqual({}) - }) - it('routes Session Remote Events and connection generations into the object layer', async () => { const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected') const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError') diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index e67650d4a2..e3ea48f783 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -10,7 +10,7 @@ import type { import type { SessionAddress, SessionControlBaseline, - SessionControlFrame, SessionObserveJobFrame, + SessionControlFrame, SessionFollowFrame, SessionFollowRequest, SessionPage, @@ -235,9 +235,6 @@ export class FakeApiClient { page: request => this.page(request), follow: (request, signal) => this.openFollow(request, signal), control: signal => this.openControl(signal), - // Job-record observation never yields here; the stream consumer is - // exercised directly by job-output.client.spec. - observeJob: () => (async function* (): AsyncGenerator {})(), }, subagents: { list: parentSessionId => this.record( diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index be09dd8d80..1b5859b509 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -40,8 +40,6 @@ import type { SessionFollowRequest, SessionListRequest, SessionListValue, - SessionObserveJobFrame, - SessionObserveJobRequest, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, SessionPage, @@ -79,7 +77,6 @@ export interface TestSessionRemote { page(request: SessionPageRequest, signal?: AbortSignal): Promise> follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable control(signal?: AbortSignal): AsyncIterable - observeJob(request: SessionObserveJobRequest, signal?: AbortSignal): AsyncIterable } /** Dependencies and policy supplied by a Session Controller unit harness. */ @@ -332,6 +329,5 @@ export function createSessionTestRemote( ), follow: (request, signal = new AbortController().signal) => direct.follow(request, signal), control: (signal = new AbortController().signal) => direct.control(signal), - observeJob: (request, signal = new AbortController().signal) => direct.observeJob(request, signal), } } diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 086ab4a66f..d13a698f67 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -17,34 +17,83 @@ "src/history.ts", "src/list.ts", "src/model-selection-projection.ts", - "src/observe-job.ts", "src/skill-catalog.ts" ], "references": [ - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../core/agent" }, - { "path": "../../core/agent-default-model" }, - { "path": "../../core/scope" }, - { "path": "../../core/session" }, - { "path": "../../context/file-reference" }, - { "path": "../../attachment/attachment" }, - { "path": "../../interaction/permission-presets" }, - { "path": "../../jobs/jobs" }, - { "path": "../../llm/llm" }, - { "path": "../../util/deque" }, - { "path": "../../util/native-command" }, - { "path": "../../preset/agent-presets" }, - { "path": "../../session/session-persistence" }, - { "path": "../../session/session-projection" }, - { "path": "../../session/session-projection-cache" }, - { "path": "../../session/session-title" }, - { "path": "../../session-query/session-query" }, - { "path": "../../skill/skill" }, - { "path": "../../subagent/subagent" }, - { "path": "../../util/time" }, - { "path": "../../typert/protocol" }, - { "path": "../../typert/registry" }, - { "path": "../../workspace/workspace" } + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/agent-default-model" + }, + { + "path": "../../core/scope" + }, + { + "path": "../../core/session" + }, + { + "path": "../../context/file-reference" + }, + { + "path": "../../attachment/attachment" + }, + { + "path": "../../interaction/permission-presets" + }, + { + "path": "../../jobs/jobs" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../util/deque" + }, + { + "path": "../../util/native-command" + }, + { + "path": "../../preset/agent-presets" + }, + { + "path": "../../session/session-persistence" + }, + { + "path": "../../session/session-projection" + }, + { + "path": "../../session/session-projection-cache" + }, + { + "path": "../../session/session-title" + }, + { + "path": "../../session-query/session-query" + }, + { + "path": "../../skill/skill" + }, + { + "path": "../../subagent/subagent" + }, + { + "path": "../../util/time" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../typert/registry" + }, + { + "path": "../../workspace/workspace" + } ] } diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index ef7539a7be..e7c42d038a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -91,6 +91,11 @@ - id: session-controller name: '@deepseek-ai/dsh-api-session-controller' + # One background job's observation record streamed over Typert Remote; + # the roster rides the session control stream above. + - id: job-controller + name: '@deepseek-ai/dsh-api-job-controller' + # Configuration-surface reads and writes over Typert Remote. Each method # reports an actionable error when its settings-domain provider is absent. - id: settings-controller diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 63f32d737e..5d8ce4524d 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -40,73 +40,74 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-api-job-controller": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-settings-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", - "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-approval": "workspace:^", + "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-brand-official": "workspace:^", - "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", - "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", + "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-model-selection": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", + "@deepseek-ai/dsh-client-ui-reference": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-schedule": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", - "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-skill": "workspace:^", - "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-reference": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", - "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", - "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", + "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", + "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-web-frontend": "workspace:^", - "@deepseek-ai/dsh-host-frontend-static": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-log-export": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-session-turn-outline": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-settings-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-web-frontend": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0", diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 12557c1c73..eb2ed7cf83 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -2,8 +2,8 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { - ConversationLocationDataStore, ConversationTurnDataMap, - MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, + CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap, + MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, ToolCallBlock, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, @@ -16,8 +16,7 @@ import type { createChatStore } from '../stores.ts' import type { ToolCallId, SelectionTarget } from './store.ts' import type { ChatConversationViewNode, ChatNode, ChatNodeKind } from './chat-nodes.ts' import type { - ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation, CommandNode, - CompactionSummaryNode, ToolCallBlock, + ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation, } from './snapshot.ts' import type { TurnProcessSpec } from './turn-process.ts' import type { TranscriptViewMode } from '../../chat-settings.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 6fa7671da6..64d74f28d8 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -1,15 +1,14 @@ import type { Context } from '@deepseek-ai/cordis' import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import type { - ConversationLocation, ConversationTimelineSnapshot, ConversationViewBuilder, - ConversationViewDefinition, + ConversationLocation, ConversationNode, ConversationTimelineSnapshot, ConversationViewBuilder, + ConversationViewDefinition, PartialAssistant, RunningToolCall, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' import type { ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, ChatSnapshot, - ChatTurnNavigationIndex, ChatTurnProcessPresentation, ConversationNode, LegacyConversationSlice, - PartialAssistant, RunningToolCall, TurnNavigationItem, + ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem, } from '../contract/snapshot.ts' import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' import { sessionRecallLabels } from './event-projection.ts' diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index b12c1d3926..fe508b8379 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -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/ui-jobs/README.md -README.md: 51e6916ed4a72fb8f618154bbd2ec156ad0af2bc -README.zh.md: f21e097e7860def07663376bf5de0f9d565a34c0 +README.md: a0ecc137ab1eb57aeaea9ccdfdbaf898794de2fe +README.zh.md: 744450c480b4d26c0558d992904817174ca2ed73 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index 51e6916ed4..a0ecc137ab 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -33,7 +33,7 @@ The session control stream's `jobsBySession` mirror is the single roster: each ` ### The expanded panel -Expanding an observable row opens that job's record observation stream from `ctx.jobOutput` into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream failures render as notices above the panel. +Expanding an observable row opens that job's record observation stream from `ctx.jobOutput` (installed by `dsh-api-job-controller`) into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream failures render as notices above the panel. ----- @@ -58,7 +58,8 @@ One slot entry in the header actions band (after the preset label, before the su ## Further Exploration -- [`dsh-api-session-controller`](../../api/session-controller/README.md) — the `jobsBySession` mirror and the `session.observeJob` stream behind the panel. +- [`dsh-api-job-controller`](../../api/job-controller/README.md) — the `job.observe` stream and the `ctx.jobOutput` service behind the panel. +- [`dsh-api-session-controller`](../../api/session-controller/README.md) — the `jobsBySession` roster mirror the rows come from. - [`dsh-jobs`](../../jobs/jobs/README.md) — the registry contract that owns the record semantics. - [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures. diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index f21e097e78..744450c480 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -33,7 +33,7 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo ### 展开的面板 -展开可观察的行会从 `ctx.jobOutput` 打开该 job 的 record 观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。 +展开可观察的行会从 `ctx.jobOutput`(由 `dsh-api-job-controller` 安装)打开该 job 的 record 观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。 ----- @@ -58,7 +58,8 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo ## 进一步探索 -- [`dsh-api-session-controller`](../../api/session-controller/README.zh.md) —— 面板背后的 `jobsBySession` 镜像与 `session.observeJob` 流。 +- [`dsh-api-job-controller`](../../api/job-controller/README.zh.md) —— 面板背后的 `job.observe` 流与 `ctx.jobOutput` 服务。 +- [`dsh-api-session-controller`](../../api/session-controller/README.zh.md) —— 行所来自的 `jobsBySession` 名册镜像。 - [`dsh-jobs`](../../jobs/jobs/README.zh.md) —— 拥有 record 语义的注册表契约。 - [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md) —— 面板所配置的 `TerminalBlock` 表面。 diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 885effb30c..fb85ed298d 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-job-controller": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", @@ -55,6 +56,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-job-controller", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index edac6497f6..87acb18a32 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' -import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-session-controller/client' +import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts index 164b6f6e3c..209cec1f23 100644 --- a/packages/client/ui-jobs/src/client/index.ts +++ b/packages/client/ui-jobs/src/client/index.ts @@ -8,6 +8,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import { JobListAction } from './JobListAction.tsx' import type { JobListInjected } from './JobListAction.tsx' +import type {} from '@deepseek-ai/dsh-api-job-controller/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index 15bd8ec4a9..9d659ca2ae 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-session-controller/client' +import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' diff --git a/packages/client/ui-jobs/tsconfig.json b/packages/client/ui-jobs/tsconfig.json index 0e66aa4958..38756e4d88 100644 --- a/packages/client/ui-jobs/tsconfig.json +++ b/packages/client/ui-jobs/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../api/job-controller/tsconfig.client.json" + }, { "path": "../../api/session-controller/tsconfig.client.json" }, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index fa02cba58f..0daf030fdd 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:228', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:227', }, { key: 'conversation.chat.commandview', @@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:216', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:215', }, { key: 'conversation.chat.node', @@ -314,7 +314,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:197', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:196', }, { key: 'conversation.chat.turnTail', @@ -359,7 +359,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:222', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:221', }, { key: 'conversation.composer', @@ -533,7 +533,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:234', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:233', }, { key: 'conversation.hero.agentPreset', @@ -1011,7 +1011,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:210', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:209', }, { key: 'conversation.session', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 2d81832d71..9de2adb4c9 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1033,6 +1033,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'jobController', + summary: 'Host service backing the generated `ctx.remote.job` namespace.', + description: 'Host service backing the generated `ctx.remote.job` namespace.', + methods: [ + { + signature: '@Remote({ mode: \'stream\' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable', + description: 'Stream one job\'s retained record output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session resolves the fenced-read caller; the registry rejects a job the session does not own, a job without a record, or an unknown job.', + parameters: [{ name: 'request', description: 'target job, owning session, and optional resume offset.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], + returns: 'anchor, coalesced output frames, and the terminal status.', + }, + ], + }, { key: 'jobs', summary: 'Abstract background job registry.', @@ -1455,12 +1468,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], returns: 'one complete baseline followed by live replacement frames.', }, - { - signature: '@Remote({ mode: \'stream\' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable', - description: 'Stream one job\'s retained record output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session resolves the fenced-read caller; the registry rejects a job the session does not own, a job without a record, or an unknown job.', - parameters: [{ name: 'request', description: 'target job, owning session, and optional resume offset.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], - returns: 'anchor, coalesced output frames, and the terminal status.', - }, ], }, { @@ -4190,6 +4197,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JobKindMap', declaration: 'export interface JobKindMap {\n bash: \'bash\';\n subagent: \'subagent\';\n}', }, + { + name: 'JobObserveFrame', + declaration: 'export type JobObserveFrame = {\n readonly type: \'opened\';\n readonly jobId: JobId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: JobObserveStatus;\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly JobWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: JobObserveStatus;\n readonly detail?: string;\n readonly finishedAt?: number;\n};', + }, + { + name: 'JobObserveRequest', + declaration: 'export interface JobObserveRequest {\n readonly sessionId?: SessionId;\n readonly jobId: JobId;\n readonly from?: number;\n}', + }, + { + name: 'JobObserveStatus', + declaration: 'export type JobObserveStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';', + }, { name: 'JobOutcome', declaration: 'export interface JobOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n output?: string;\n}', @@ -4226,6 +4245,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JobStatus', declaration: 'export type JobStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';', }, + { + name: 'JobWireChunk', + declaration: 'export interface JobWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}', + }, { name: 'JsonSchemaNode', declaration: 'export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n}', @@ -4966,10 +4989,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionJob', declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal?: number;\n}', }, - { - name: 'SessionJobWireChunk', - declaration: 'export interface SessionJobWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}', - }, { name: 'SessionLineageNode', declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n descendants: SessionLineageNode[];\n}', @@ -5002,14 +5021,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionObservationOptions', declaration: 'export interface SessionObservationOptions {\n readonly signal?: AbortSignal;\n readonly projectionMode?: \'all\' | \'none\';\n}', }, - { - name: 'SessionObserveJobFrame', - declaration: 'export type SessionObserveJobFrame = {\n readonly type: \'opened\';\n readonly jobId: JobId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: SessionJob[\'status\'];\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly SessionJobWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: SessionJob[\'status\'];\n readonly detail?: string;\n readonly finishedAt?: number;\n};', - }, - { - name: 'SessionObserveJobRequest', - declaration: 'export interface SessionObserveJobRequest {\n readonly sessionId?: SessionId;\n readonly jobId: JobId;\n readonly from?: number;\n}', - }, { name: 'SessionOpenWorkspacePathRequest', declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly path: string;\n}', diff --git a/packages/jobs/jobs/src/pump.ts b/packages/jobs/jobs/src/pump.ts index 8f56b6abc6..3d7d252d24 100644 --- a/packages/jobs/jobs/src/pump.ts +++ b/packages/jobs/jobs/src/pump.ts @@ -17,7 +17,19 @@ export interface JobPumpRead { lossy: boolean } -/** One producer-owned stream the pump copies into a job record. */ +/** + * One producer-owned stream the pump copies into a job record. + * + * The source is a pull-by-offset snapshot reader, not a Node.js `Readable`, + * because the substrate it reads (the subprocess `readFrom` family) is a + * retained ring that several readers share: the model-facing consuming + * cursor, the foreground result collector, and this record pump each hold + * their own absolute offset and read without disturbing the others. A + * `Readable` is single-consumer and consuming, so wrapping the ring in one + * would need a tee per reader and could not express `lossy` — the reader's + * offset having slid out of the retained window — which the record turns + * into a visible `gapBefore` instead of a silent splice. + */ export interface JobPumpSource { /** Stream label attached to every chunk this source yields. */ channel?: JobChannel diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e03d24425c..181c30268c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -665,6 +665,46 @@ importers: specifier: ^4.4.3 version: 4.4.3 + packages/api/job-controller: + dependencies: + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../gateway + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry + packages/api/remotes: dependencies: '@deepseek-ai/dsh-deque': @@ -686,6 +726,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway + '@deepseek-ai/dsh-api-job-controller': + specifier: workspace:^ + version: link:../job-controller '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../session-controller @@ -1446,6 +1489,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-api-job-controller': + specifier: workspace:^ + version: link:../../api/job-controller '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -2507,6 +2553,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-job-controller': + specifier: workspace:^ + version: link:../../api/job-controller '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4dde46a13a..ac52455c6d 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -109,6 +109,7 @@ export const SERVICE_PAGE: Record = { subprocess: 'subprocess.md', systemPrompt: 'system-prompt.md', jobs: 'jobs.md', + jobController: 'jobs.md', sessionTelemetry: 'session-telemetry.md', agentTeams: 'agent-team.md', tokenMeter: 'token-meter.md', @@ -173,7 +174,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API', workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API', - jobOutput: 'client-side interface-typed browser service — packages/api/session-controller/README.md owns the API', + jobOutput: 'client-side interface-typed browser service — packages/api/job-controller/README.md owns the API', } /** @@ -324,8 +325,6 @@ export const LINK_MAP: Readonly> = { OptionalSessionSeq: 'session.md', SessionListRequest: 'session.md', SessionListValue: 'session.md', - SessionObserveJobFrame: 'session.md', - SessionObserveJobRequest: 'session.md', ModelCatalog: 'session.md', SessionOpenWorkspacePathRequest: 'session.md', SessionOpenWorkspacePathValue: 'session.md', @@ -508,7 +507,10 @@ export const LINK_MAP: Readonly> = { ToolProviderResult: 'system-prompt.md', JobDoneListener: 'jobs.md', JobId: 'jobs.md', + JobObserveFrame: 'jobs.md', + JobObserveRequest: 'jobs.md', JobRead: 'jobs.md', + JobWireChunk: 'jobs.md', JobSnapshot: 'jobs.md', JobStart: 'jobs.md', JobsChangedListener: 'jobs.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index f97eb086ee..43ccb69a51 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -170,6 +170,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Lists the Session composition\'s user-invocable skills without activating a cold Agent.', }, + { + key: 'jobController', + pkg: 'api-job-controller', + title: 'Host job Remote controller', + mode: 'core', + note: 'Streams one background job\'s observation record over the generated Remote namespace; the roster stays on the session control stream.', + }, { key: 'credentialsController', pkg: 'api-settings-controller', @@ -576,7 +583,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Background job registry', mode: 'seam', implementations: ['jobs-local'], - consumers: ['tool-bash', 'tool-pwsh', 'tool-terminal', 'tool-subagent', 'tool-jobs'], + consumers: ['tool-bash', 'tool-pwsh', 'tool-terminal', 'tool-subagent', 'tool-jobs', 'api-job-controller'], note: 'Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.', }, { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1cd954a9ca..9bb2cc7956 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -165,6 +165,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/test-support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, 'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, + 'packages/api/job-controller': { kind: 'none', reason: 'Job observation is browser and Host control state; it registers no prompt, tool, or session event.' }, 'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' }, 'packages/api/settings-controller': { kind: 'none', reason: 'Configuration-surface API owner; it registers no prompt, tool, or session event.' }, 'packages/api/workspace-controller': { kind: 'none', reason: 'Workspace API and state projection owner; it registers no prompt, tool, or session event.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 0510427c3b..fc8d7e4e0f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -45,6 +45,9 @@ "@deepseek-ai/dsh-api-gateway": ["./packages/api/gateway/src/index.ts"], "@deepseek-ai/dsh-api-gateway/client": ["./packages/api/gateway/src/client/index.ts"], "@deepseek-ai/dsh-api-gateway/types": ["./packages/api/gateway/src/types.ts"], + "@deepseek-ai/dsh-api-job-controller": ["./packages/api/job-controller/src/index.ts"], + "@deepseek-ai/dsh-api-job-controller/client": ["./packages/api/job-controller/src/client/index.ts"], + "@deepseek-ai/dsh-api-job-controller/types": ["./packages/api/job-controller/src/types.ts"], "@deepseek-ai/dsh-api-session-controller": ["./packages/api/session-controller/src/index.ts"], "@deepseek-ai/dsh-api-session-controller/client": ["./packages/api/session-controller/src/client/index.ts"], "@deepseek-ai/dsh-api-session-controller/types": ["./packages/api/session-controller/src/types.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index bb6735bbc7..cc2c7ac7fd 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -58,6 +58,7 @@ { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/session-controller/tsconfig.client.json" }, { "path": "./packages/api/workspace-controller/tsconfig.client.json" }, + { "path": "./packages/api/job-controller/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/extensions/cordis-client-runner" }, { "path": "./packages/extensions/ui-cordis" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index c3c119a282..f8ae95c3d2 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -151,6 +151,7 @@ { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.host.json" }, { "path": "./packages/api/remotes/tsconfig.host.json" }, + { "path": "./packages/api/job-controller/tsconfig.host.json" }, { "path": "./packages/api/session-controller/tsconfig.host.json" }, { "path": "./packages/api/settings-controller" }, { "path": "./packages/api/workspace-controller/tsconfig.host.json" }, From e42eb13655f6773906f21e3c229941faed533d4f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 22:58:24 +0800 Subject: [PATCH 022/284] test(web): drop the stray slash after the sourceRoot token in three prompt pins --- snapshots/web/cordis-tool-round/system-prompt.expected.md | 2 +- snapshots/web/fresh-round-trip/system-prompt.expected.md | 2 +- snapshots/web/ptc-round/system-prompt.expected.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/snapshots/web/cordis-tool-round/system-prompt.expected.md b/snapshots/web/cordis-tool-round/system-prompt.expected.md index 4b4a62c230..a7337e1dd2 100644 --- a/snapshots/web/cordis-tool-round/system-prompt.expected.md +++ b/snapshots/web/cordis-tool-round/system-prompt.expected.md @@ -1,6 +1,6 @@ You are an AI agent powered by DeepSeek Harness. -The DeepSeek Harness implementation checkout is at {{sourceRoot}}/. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/snapshots/web/fresh-round-trip/system-prompt.expected.md b/snapshots/web/fresh-round-trip/system-prompt.expected.md index b20c5e5f77..fda55c1dea 100644 --- a/snapshots/web/fresh-round-trip/system-prompt.expected.md +++ b/snapshots/web/fresh-round-trip/system-prompt.expected.md @@ -1,6 +1,6 @@ You are an AI agent powered by DeepSeek Harness. -The DeepSeek Harness implementation checkout is at {{sourceRoot}}/. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/snapshots/web/ptc-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md index ab15ca15ac..fc9cac9176 100644 --- a/snapshots/web/ptc-round/system-prompt.expected.md +++ b/snapshots/web/ptc-round/system-prompt.expected.md @@ -1,6 +1,6 @@ You are an AI agent powered by DeepSeek Harness. -The DeepSeek Harness implementation checkout is at {{sourceRoot}}/. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. From 761282724b6ae6dbea39f9ad2dd4e5293ba67285 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 11:23:41 +0800 Subject: [PATCH 023/284] fix(jobs): constant pump waits, quiescent client disposal, record flag on the roster Review corrections on the observation record: - `pumpJobOutput` subscribes to `done` once and keeps one pending timer; racing the same pending promise every poll round retained a reaction per round for the life of the job. - The `ctx.jobOutput` effect disposer is async and awaits every open carrier's `dispose()`, so the fiber reports unloaded only once the old iterators are quiescent. - `SessionJob.record: true` replaces `outputTotal`: `append` never commits a roster change, so the mirrored count was stale while output flowed; the live offsets ride the observation stream's `opened` anchor. - The record is documented as a best-effort live preview whose cross-stream reordering is bounded by the poll interval. - The web e2e scenarios hold their background job on a test-owned barrier file instead of a wall-clock sleep, and each is one test rather than a chain sharing a job id. --- ...9-01-jobs-absorb-activity-record.i18n.yaml | 4 +-- .../2026-09-01-jobs-absorb-activity-record.md | 14 ++++++-- ...26-09-01-jobs-absorb-activity-record.zh.md | 14 ++++++-- apps/web/tests/background-job-list.e2e.ts | 34 +++++++++--------- apps/web/tests/live-job-stream.e2e.ts | 34 +++++++++--------- .../live-job-stream/settled.expected.md | 6 ++-- .../live-job-stream/streaming.expected.md | 6 ++-- docs/event-producer-consumer.i18n.yaml | 4 +-- docs/event-producer-consumer.md | 10 +++--- docs/event-producer-consumer.zh.md | 10 +++--- packages/api/job-controller/README.i18n.yaml | 4 +-- packages/api/job-controller/README.md | 3 +- packages/api/job-controller/README.zh.md | 3 +- .../api/job-controller/src/client/service.ts | 12 ++++--- .../job-controller/tests/model.client.spec.ts | 24 +++++++++++++ .../api/session-controller/README.i18n.yaml | 4 +-- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../api/session-controller/src/control.ts | 2 +- packages/api/session-controller/src/types.ts | 9 ++--- .../tests/control-jobs.host.spec.ts | 5 +-- packages/client/ui-jobs/README.i18n.yaml | 4 +-- packages/client/ui-jobs/README.md | 2 +- packages/client/ui-jobs/README.zh.md | 2 +- .../ui-jobs/src/client/JobListAction.tsx | 2 +- .../tests/job-list-action.client.spec.tsx | 4 +-- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/jobs/jobs/src/pump.ts | 34 ++++++++++++------ packages/jobs/jobs/tests/service.spec.ts | 36 +++++++++++++++++++ packages/shell/tool-bash/README.i18n.yaml | 4 +-- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-bash/src/observe.ts | 3 ++ packages/shell/tool-pwsh/src/observe.ts | 3 ++ .../background-job-list/running.expected.md | 4 +-- .../background-job-list/settled.expected.md | 4 +-- 36 files changed, 212 insertions(+), 102 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml index 3a332e7121..f7cf077333 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md -2026-09-01-jobs-absorb-activity-record.md: aa147778d06133b61f79defae6f9f6807dd97f41 -2026-09-01-jobs-absorb-activity-record.zh.md: 12e8f4bbd909a480f1461433c58d7937a8d7c857 +2026-09-01-jobs-absorb-activity-record.md: c1a668f8a008948957e62b5acf1d9db141441885 +2026-09-01-jobs-absorb-activity-record.zh.md: a23f3cda4c8c53663a6aee99469297d6878539d1 diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md index aa147778d0..c1a668f8a0 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -18,8 +18,8 @@ The one activity without a job was the foreground workflow mirror. Everything el - **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented. - **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design. - **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it. -- **The wire splits by role.** `SessionJob.outputTotal` (present exactly for record jobs) marks a row observable on the session control stream; `job.observe({sessionId?, jobId, from?})` on the `job` namespace of `api-job-controller` streams anchor/output/status frames with the fenced read resolved from the request's session. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery lives in `api-job-controller` (`observe.ts`, client `ctx.jobOutput`), which resolves its Remote faces in its own apply. -- **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `outputTotal` is present. The two-roster join is deleted. +- **The wire splits by role.** `SessionJob.record` (present exactly for record jobs) marks a row observable on the session control stream; `job.observe({sessionId?, jobId, from?})` on the `job` namespace of `api-job-controller` streams anchor/output/status frames with the fenced read resolved from the request's session. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery lives in `api-job-controller` (`observe.ts`, client `ctx.jobOutput`), which resolves its Remote faces in its own apply. +- **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `record` is present. The two-roster join is deleted. - **Foreground workflow loses its live panel deliberately.** `tool-workflow`'s activity mirror is removed; a foreground run surfaces through its recorded run/member lifecycle events only, and the per-line `workflow/phase` / `workflow/log` narration has no observer until workflow gains `run_in_background` and registers a record job. Jobs stay a pure background registry — no `foreground` mode bit, no model-invisible rows, no run-less rows. ## Alternatives considered @@ -30,6 +30,16 @@ The one activity without a job was the foreground workflow mirror. Everything el The [prior seam note](../feature/2026-08-24-activity-observation-seam.md)'s argument against durable session events and control-stream framing for live output still holds and carries over unchanged: the record is process-local observation state, never a session event, so "model-visible ⟺ logged" is untouched. +## Review corrections + +Review of the merged design settled five points that the sections above leave implicit: + +- **The pump's wait holds constant resources.** `pumpJobOutput` subscribes to the producer's `done` once and keeps one pending timer; settlement clears the timer and wakes the current wait. Racing the same pending promise every poll round retained a reaction per round until the job ended — a day-long job at the default cadence accumulated over a million closures. +- **Client disposal waits for carrier quiescence.** The `ctx.jobOutput` effect disposer is async and awaits every open `RemoteStream.dispose()`, because Cordis awaits async disposers and a fiber that reported unloaded while an old iterator was still closing could overlap the next plugin instance under HMR. +- **The roster flags a record, it does not count bytes.** `SessionJob.record: true` replaced `outputTotal`: `append` commits no roster change (only lifecycle commits fire `onJobsChanged`), so a mirrored count went stale the moment output arrived, and broadcasting the roster per append was rejected as traffic for a number no row needed. The live offsets ride the observation stream's `opened` anchor. +- **The record is a best-effort live preview.** Producers copy stdout and stderr per poll round, so writes inside one window land stdout first; pipe capture cannot recover the true interleaving (only a PTY or `2>&1` can), and the client concatenates chunks regardless of `channel`. `channel` stays on the wire because it is what makes that limit visible and what a distinct stderr rendering would need. +- **Web e2e keep-alive is a barrier, not a clock.** The background command holds on a file the test owns in the job's cwd and the scenario kills it explicitly, so no CI stall can settle the job first; each scenario is one test, never a chain of tests sharing a job id. + ## Consequences Every observable row is a killable job; a pure-observation surface with no lifecycle would need a new home (harness diagnostics belong to the inspector plane, not here). A future remote job provider must implement lifecycle and record together. The record's retention config lives on `jobs-local`; the observation wire's cadence config (`observeFlushMs`, `observeMaxFrameBytes`) lives on `api-job-controller`. diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md index 12e8f4bbd9..a23f3cda4c 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -18,8 +18,8 @@ Status: implemented - **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。 - **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。 - **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。 -- **wire 按角色拆分。** `SessionJob.outputTotal`(恰在 record job 上存在)在会话控制流上标记行可观测;`api-job-controller` 的 `job` 命名空间上的 `job.observe({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制位于 `api-job-controller`(`observe.ts`,客户端 `ctx.jobOutput`),它在自己的 apply 里解析所依赖的 Remote 面。 -- **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `outputTotal` 存在时可展开。双名册 join 被删除。 +- **wire 按角色拆分。** `SessionJob.record`(恰在 record job 上存在)在会话控制流上标记行可观测;`api-job-controller` 的 `job` 命名空间上的 `job.observe({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制位于 `api-job-controller`(`observe.ts`,客户端 `ctx.jobOutput`),它在自己的 apply 里解析所依赖的 Remote 面。 +- **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `record` 存在时可展开。双名册 join 被删除。 - **前台 workflow 有意失去实时面板。** `tool-workflow` 的 activity 镜像被移除;前台 run 只通过已记录的 run/member 生命周期事件呈现,逐行的 `workflow/phase` / `workflow/log` 叙述在 workflow 获得 `run_in_background` 并登记 record job 之前没有观察者。jobs 保持纯后台注册表——没有 `foreground` 模式位、没有模型不可见行、没有无 run 行。 ## 曾考虑的替代方案 @@ -30,6 +30,16 @@ Status: implemented [先前 seam note](../feature/2026-08-24-activity-observation-seam.zh.md) 反对用持久会话事件或 control 流承载实时输出的论证仍然成立并原样沿用:record 是进程本地观测状态,从不是会话事件,「模型可见 ⟺ 已记录」不受影响。 +## 评审修正 + +对已合并设计的评审确定了上文各节未言明的五点: + +- **泵的等待资源恒定。** `pumpJobOutput` 对生产者的 `done` 只订阅一次,同一时刻只保留一个待触发的 timer;结算清掉 timer 并唤醒当前等待。此前每个轮询轮次都对同一个 pending promise 赛跑,每轮留下一个 reaction 直到任务结束——默认节奏下跑一天的任务会累积上百万个闭包。 +- **客户端卸载等待载体静止。** `ctx.jobOutput` 的 effect disposer 是异步的,await 每条打开的 `RemoteStream.dispose()`:Cordis 会 await 异步 disposer,而一个在旧 iterator 仍在关闭时就宣告卸载完成的 fiber,在 HMR 下可能与下一个插件实例重叠。 +- **名册标记 record,不计字节。** `SessionJob.record: true` 取代了 `outputTotal`:`append` 不提交名册变更(只有生命周期提交触发 `onJobsChanged`),镜像的计数在输出到达的瞬间就陈旧了;而为一个没有任何行需要的数字按 append 广播整份名册被否决。实时偏移随观测流的 `opened` 锚帧到达。 +- **record 是尽力而为的实时预览。** 生产者按轮询轮次复制 stdout 与 stderr,同一窗口内的写入先 stdout 落地;pipe 捕获无法还原真实交错(只有 PTY 或 `2>&1` 可以),客户端拼接 chunk 时也不区分 `channel`。`channel` 留在 wire 上,因为它正是让这一限制可见、也是将来区分渲染 stderr 所需要的东西。 +- **Web e2e 的保活是 barrier 而非时钟。** 后台命令守在测试拥有的、位于 job cwd 的文件上,由场景显式 kill,因此 CI 卡顿不可能先把任务结算掉;每个场景是一个测试,绝不是共享 job id 的测试链。 + ## 后果 每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `api-job-controller`。 diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index 284b359eeb..96a7f4f833 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -1,6 +1,6 @@ // Session-header background jobs driven by a real `ctx.jobs` entry. No model // call is involved. -import { readFile } from 'node:fs/promises' +import { readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -22,9 +22,12 @@ const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'background-job-list-web-e2e' -// Long enough that the running assertions never race the process exiting on -// their own; the test kills it explicitly to reach the settled state. -const COMMAND = 'sleep 45' +// A hold on a barrier file the test owns in the job's cwd: the process never +// exits on its own, so no CI stall can settle it before the scenario kills it. +// The bounded loop only caps an orphan's lifetime if the runner dies before +// `afterAll` releases the barrier. +const RELEASE = '.background-job-list.release' +const COMMAND = `for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done` /** * Wait for opening a session to publish its live Agent. @@ -48,7 +51,6 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { let page: Page let tripwire: ReturnType let agent: Agent - let jobId: JobId beforeAll(async () => { scaffold = await launchWebScaffold({}) @@ -73,12 +75,14 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { }, 120_000) afterAll(async () => { + if (scaffold !== undefined) await writeFile(join(scaffold.workspaceCwd, RELEASE), '') await browser?.close() await scaffold?.close() }) - it('shows a running background job in the session header without a refresh', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running')) + it('shows a running background job in the header without a refresh, then flips it to cancelled on settlement', async () => { + let phase = 'running' + onTestFailed(() => saveFailureShot(page, `web-e2e-background-job-${phase}`)) // Polling for zero would pass at t=0 before delivery and prove nothing. const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) @@ -93,7 +97,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') const matched = /\bbash-\d+\b/.exec(reported) if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) - jobId = JobId(matched[0]) + const jobId = JobId(matched[0]) await trigger.waitFor({ timeout: 15_000 }) await trigger.click() @@ -101,24 +105,22 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { await row.waitFor({ timeout: 10_000 }) await expect.poll(() => row.textContent()).toContain(COMMAND) - const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(RUNNING_EXPECTED, snapshot, MODE) + const running = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - }, 60_000) - it('flips the open list to the cancelled outcome when the registry settles it', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled')) + phase = 'settled' expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') const idle = page.getByRole('button', { name: '1 background job', exact: true }) await idle.waitFor({ timeout: 20_000 }) - const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, settled, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - }, 60_000) + }, 90_000) it('keeps its snapshot inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md']) diff --git a/apps/web/tests/live-job-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts index 65926f7463..83736763c6 100644 --- a/apps/web/tests/live-job-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -2,7 +2,7 @@ // arrives over the session control stream, expanding it opens the record // observation stream, and the panel shows the process's real output while it // is still running. No model call is involved. -import { readFile } from 'node:fs/promises' +import { readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -24,9 +24,12 @@ const STREAMING_EXPECTED = join(SNAPSHOT_DIR, 'streaming.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'live-job-stream-web-e2e' -// One fixed line of early output, then silence long enough that the streaming -// assertions never race the process exiting; the test kills it explicitly. -const COMMAND = "printf 'streamed-%s\\n' marker-line; sleep 45" +// One fixed line of early output, then a hold on a barrier file the test owns +// in the job's cwd: the process never exits on its own, so no CI stall can +// settle it before the scenario kills it. The bounded loop only caps an +// orphan's lifetime if the runner dies before `afterAll` releases the barrier. +const RELEASE = '.live-job-stream.release' +const COMMAND = `printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done` /** * Wait for opening a session to publish its live Agent. @@ -50,7 +53,6 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { let page: Page let tripwire: ReturnType let agent: Agent - let jobId: JobId beforeAll(async () => { scaffold = await launchWebScaffold({}) @@ -74,12 +76,14 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { }, 120_000) afterAll(async () => { + if (scaffold !== undefined) await writeFile(join(scaffold.workspaceCwd, RELEASE), '') await browser?.close() await scaffold?.close() }) - it('streams a running background command\'s real output into the expanded panel', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-live-job-streaming')) + it('streams a running job\'s real output into the expanded panel, then settles it in place on kill', async () => { + let phase = 'streaming' + onTestFailed(() => saveFailureShot(page, `web-e2e-live-job-${phase}`)) const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) @@ -93,7 +97,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') const matched = /\bbash-\d+\b/.exec(reported) if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) - jobId = JobId(matched[0]) + const jobId = JobId(matched[0]) // The job row reaches the header over the session control stream. await trigger.waitFor({ timeout: 15_000 }) @@ -106,14 +110,12 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { await expand.click() await page.getByText('streamed-marker-line').waitFor({ timeout: 15_000 }) - const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(STREAMING_EXPECTED, snapshot, MODE) + const streaming = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STREAMING_EXPECTED, streaming, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - }, 60_000) - it('settles the open panel when the job is killed, keeping the streamed tail', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-live-job-settled')) + phase = 'settled' expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') // Settlement arrives on the observation stream itself, so the open panel @@ -122,11 +124,11 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { await idle.waitFor({ timeout: 20_000 }) await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 }) - const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, settled, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - }, 60_000) + }, 90_000) it('keeps its snapshot inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['settled.expected.md', 'streaming.expected.md']) diff --git a/apps/web/tests/snapshots/live-job-stream/settled.expected.md b/apps/web/tests/snapshots/live-job-stream/settled.expected.md index 6ba8ada46d..4c3fdfc70f 100644 --- a/apps/web/tests/snapshots/live-job-stream/settled.expected.md +++ b/apps/web/tests/snapshots/live-job-stream/settled.expected.md @@ -1,8 +1,8 @@ - list "Background jobs": - listitem: - - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - - text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM {{duration}}" + - button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: + - text: "bash printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done signal: SIGTERM {{duration}}" - img - - text: $ printf 'streamed-%s\n' marker-line; sleep 45 + - text: $ printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done - button "Copy" - text: streamed-marker-line diff --git a/apps/web/tests/snapshots/live-job-stream/streaming.expected.md b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md index 4054d5d42a..6b7336b859 100644 --- a/apps/web/tests/snapshots/live-job-stream/streaming.expected.md +++ b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md @@ -1,8 +1,8 @@ - list "Background jobs": - listitem: - - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - - text: printf 'streamed-%s\n' marker-line; sleep 45 {{duration}} bash running + - button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: + - text: printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done {{duration}} bash running - img - - text: $ printf 'streamed-%s\n' marker-line; sleep 45 + - text: $ printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done - button "Copy" - text: streamed-marker-line diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 8a378b23f1..9d71df140c 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: 930ffba24789ee51db110310cb17a2231135cf1d -event-producer-consumer.zh.md: 6583b1d9f333ad934db2123a2bc5c40b12f1ae36 +event-producer-consumer.md: e0b4709875723bd96f9910b1bf518600612f0a12 +event-producer-consumer.zh.md: 0f0fa1317eb5a017daf435648f5c3e36208626e0 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 930ffba247..e0b4709875 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:550`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:557`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:536`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:551`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 6583b1d9f3..0f0fa1317e 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:550`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:557`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:536`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:551`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/packages/api/job-controller/README.i18n.yaml b/packages/api/job-controller/README.i18n.yaml index 9bef50c478..49a565d20f 100644 --- a/packages/api/job-controller/README.i18n.yaml +++ b/packages/api/job-controller/README.i18n.yaml @@ -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/api/job-controller/README.md -README.md: 1af9cac6574b36fb9ee0fe2bd4ff4a529848c2fe -README.zh.md: a232d8e8655fea6ccddedb60e222837466705549 +README.md: 6aaab1bff1f13cc21672a039170f811b570230db +README.zh.md: 47e68802346fbc5e41e74171a42afa17289ee156 diff --git a/packages/api/job-controller/README.md b/packages/api/job-controller/README.md index 1af9cac657..6aaab1bff1 100644 --- a/packages/api/job-controller/README.md +++ b/packages/api/job-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its one Remote stream, `job.observe`, delivers a record-declaring job's retained output from an absolute byte offset; the Client half installs `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views the session-header job list renders. The roster itself is not here: `SessionJob` rows still ride the session control stream owned by [`dsh-api-session-controller`](../session-controller/README.md), and a row's `outputTotal` says whether this controller has anything to stream for it. +`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its one Remote stream, `job.observe`, delivers a record-declaring job's retained output from an absolute byte offset; the Client half installs `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views the session-header job list renders. The roster itself is not here: `SessionJob` rows still ride the session control stream owned by [`dsh-api-session-controller`](../session-controller/README.md), and a row's `record` says whether this controller has anything to stream for it. ## Table of Contents @@ -50,6 +50,7 @@ No direct effect; observation reads never touch model requests. +- The record is a best-effort live preview, not a terminal transcript: producers copy stdout and stderr per poll round, so the two streams' writes inside one poll window reach the record stdout first, and the client concatenates chunks regardless of `channel`. - The stream is process-local: a Host restart loses every record, and a resumed observation then anchors on an empty registry. - Per-session fencing is enforced by the registry read; the Remote layer itself serves any connected browser, matching the session control stream's `jobsBySession` broadcast. diff --git a/packages/api/job-controller/README.zh.md b/packages/api/job-controller/README.zh.md index a232d8e865..47e6880234 100644 --- a/packages/api/job-controller/README.zh.md +++ b/packages/api/job-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它唯一的 Remote 流 `job.observe` 从绝对字节偏移发送声明了 record 的 job 的保留输出;Client 半侧安装 `ctx.jobOutput`——按 job 引用计数的观测服务,会话头部任务列表渲染其累积视图。名册不在这里:`SessionJob` 行仍随 [`dsh-api-session-controller`](../session-controller/README.zh.md) 拥有的会话控制流到达,行上的 `outputTotal` 说明本控制器是否有东西可流。 +`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它唯一的 Remote 流 `job.observe` 从绝对字节偏移发送声明了 record 的 job 的保留输出;Client 半侧安装 `ctx.jobOutput`——按 job 引用计数的观测服务,会话头部任务列表渲染其累积视图。名册不在这里:`SessionJob` 行仍随 [`dsh-api-session-controller`](../session-controller/README.zh.md) 拥有的会话控制流到达,行上的 `record` 说明本控制器是否有东西可流。 ## 目录 @@ -50,6 +50,7 @@ Client 入口在 `ClientJobOutputModel` 之上提供 `ClientJobOutput`(`ctx.jo +- record 是尽力而为的实时预览,不是终端转录:生产者按轮询轮次复制 stdout 与 stderr,同一轮询窗口内两条流的写入先 stdout 后到达 record,客户端拼接 chunk 时也不区分 `channel`。 - 流是进程本地的:Host 重启丢失全部 record,续读的观测随后锚定在空注册表上。 - 按 session 的围栏由注册表读取强制;Remote 层本身服务任何已连接浏览器,与会话控制流对 `jobsBySession` 的广播一致。 diff --git a/packages/api/job-controller/src/client/service.ts b/packages/api/job-controller/src/client/service.ts index 385a0b92ad..07f3a00ec2 100644 --- a/packages/api/job-controller/src/client/service.ts +++ b/packages/api/job-controller/src/client/service.ts @@ -74,13 +74,15 @@ export class ClientJobOutput extends Service implements IJobOutput { ) { super(ctx, 'jobOutput') this.state = model - ctx.effect(() => () => { + ctx.effect(() => async () => { const open = [...this.entries.values()] this.entries.clear() - for (const entry of open) { - entry.stopped = true - void entry.dispose() - } + for (const entry of open) entry.stopped = true + // Cordis awaits an async disposer, so the fiber stays unloading until + // every carrier iterator has closed and a successor plugin instance + // cannot overlap one. A carrier whose teardown fails is stopped all the + // same; its failure has no consumer here. + await Promise.allSettled(open.map(entry => entry.dispose())) }, 'job-controller.client.observations') } diff --git a/packages/api/job-controller/tests/model.client.spec.ts b/packages/api/job-controller/tests/model.client.spec.ts index 69260ff3fc..5e49771318 100644 --- a/packages/api/job-controller/tests/model.client.spec.ts +++ b/packages/api/job-controller/tests/model.client.spec.ts @@ -391,6 +391,30 @@ describe('ClientJobOutput observation streams', () => { expect(model.getSnapshot().observed[String(ID)]?.error).toBeUndefined() }) + it('holds service disposal until every open stream reports quiescence', async () => { + const { ctx, output, streams } = bench() + output.observe(undefined, ID) + output.observe(undefined, 'bash-2' as JobId) + for (const { stream } of streams) stream.deferDispose = true + await tick() + + let settled = false + const disposal = ctx.fiber.dispose().then(() => { settled = true }) + await tick() + // Both carriers were told to stop, but neither iterator has closed yet: + // the fiber must still be unloading, or a successor plugin instance could + // overlap the old generation's iterator. + expect(streams.every(entry => entry.stream.disposed)).toBe(true) + expect(settled).toBe(false) + + streams[0]!.stream.releaseDispose() + await tick() + expect(settled).toBe(false) + streams[1]!.stream.releaseDispose() + await disposal + expect(settled).toBe(true) + }) + it('records a terminal stream failure and service disposal closes live streams', async () => { const { ctx, model, output, streams } = bench() output.observe(undefined, ID) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index fa85bc3ebe..9f55558821 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: 4f94607e70a5b963fa50d7a497afbcc97d273cb9 -README.zh.md: 455769f77ce8ad304949624de938e56579024145 +README.md: 4414cd7221b40cc880122415e83b6dc3818df481 +README.zh.md: 8f457d4a94f57c802f67624d9c84ba15c78b1ada diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 4f94607e70..4414cd7221 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,7 +29,7 @@ Each endpoint states its activation policy. List, search, attachment, history pa The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. -A `SessionJob` row carries `outputTotal` exactly when the job declared an observation record; streaming that record is the job controller's `job.observe` (see [`dsh-api-job-controller`](../job-controller/README.md)), while this package keeps the roster on the control stream. +A `SessionJob` row carries `record: true` exactly when the job declared an observation record; streaming that record is the job controller's `job.observe` (see [`dsh-api-job-controller`](../job-controller/README.md)), while this package keeps the roster on the control stream. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 455769f77c..8f457d4a94 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 -`SessionJob` 行恰在该 job 声明了观测 record 时携带 `outputTotal`;流式发送该 record 的是 job 控制器的 `job.observe`(见 [`dsh-api-job-controller`](../job-controller/README.zh.md)),本包只在控制流上保留名册。 +`SessionJob` 行恰在该 job 声明了观测 record 时携带 `record: true`;流式发送该 record 的是 job 控制器的 `job.observe`(见 [`dsh-api-job-controller`](../job-controller/README.zh.md)),本包只在控制流上保留名册。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index aa9f0a77be..b96cbb7f9d 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -212,6 +212,6 @@ function jobView(job: JobSnapshot): SessionJob { ...(job.detail === undefined ? {} : { detail: job.detail }), startedAt: job.startedAt, ...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }), - ...(job.outputTotal === undefined ? {} : { outputTotal: job.outputTotal }), + ...(job.outputTotal === undefined ? {} : { record: true as const }), } } diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c5a6caeacb..d3ab795ecb 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -489,11 +489,12 @@ export interface SessionJob { readonly startedAt: number readonly finishedAt?: number /** - * Total UTF-8 bytes the job's observation record holds so far. Present - * exactly when the job declared a record — the row is observable through - * the job controller's `job.observe` stream. + * Present exactly when the job declared an observation record: the row is + * observable through the job controller's `job.observe` stream, whose + * `opened` anchor carries the live byte offsets. The roster refreshes on + * lifecycle commits only, so it mirrors no byte count. */ - readonly outputTotal?: number + readonly record?: true } /** Complete live control baseline emitted once per control stream generation. */ diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts index 61a9a8156a..9d80501966 100644 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ b/packages/api/session-controller/tests/control-jobs.host.spec.ts @@ -162,7 +162,7 @@ describe('Session control jobs updates', () => { ]) }) - it('carries outputTotal exactly for record-declaring jobs', async () => { + it('flags record-declaring jobs instead of mirroring a byte count the roster never refreshes', async () => { const { ctx, agent, control } = await harness(true) const abort = new AbortController() const collected = collectJobs(control.control(abort.signal), 1, abort) @@ -178,7 +178,8 @@ describe('Session control jobs updates', () => { }) const [frame] = await collected - expect(frame?.jobs[0]?.outputTotal).toBe(2) + expect(frame?.jobs[0]).toMatchObject({ record: true }) + expect(frame?.jobs[0]).not.toHaveProperty('outputTotal') }) it('fans an unowned change out to every attached session', async () => { diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index fe508b8379..7a5e54a34f 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -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/ui-jobs/README.md -README.md: a0ecc137ab1eb57aeaea9ccdfdbaf898794de2fe -README.zh.md: 744450c480b4d26c0558d992904817174ca2ed73 +README.md: fe357b5555cdc2f7a94ea0a44e97c499652695d3 +README.zh.md: d23b96becc5b2598197f7720a355a37e5c8bf7cb diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index a0ecc137ab..fe357b5555 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -29,7 +29,7 @@ Load the plugin through the web-app manifest; it renders nothing until the sessi ### One row per job -The session control stream's `jobsBySession` mirror is the single roster: each `SessionJob` row carries lifecycle, duration, and the model-visible `detail`, and its `outputTotal` field is present exactly when the job declared a record — that presence is what makes a row expandable. There is no second roster to join. +The session control stream's `jobsBySession` mirror is the single roster: each `SessionJob` row carries lifecycle, duration, and the model-visible `detail`, and its `record` flag is present exactly when the job declared a record — that flag is what makes a row expandable. There is no second roster to join. ### The expanded panel diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index 744450c480..d23b96becc 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 一 job 一行 -session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJob` 行携带生命周期、时长与模型可见的 `detail`,其 `outputTotal` 字段恰在 job 声明了 record 时存在——这个存在性就是行可展开的依据。不存在需要 join 的第二份名册。 +session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJob` 行携带生命周期、时长与模型可见的 `detail`,其 `record` 标志恰在 job 声明了 record 时存在——这个标志就是行可展开的依据。不存在需要 join 的第二份名册。 ### 展开的面板 diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index 87acb18a32..bf369c9949 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -46,7 +46,7 @@ function isLive(job: SessionJob): boolean { /** True exactly when the job declared an observation record. */ function isObservable(job: SessionJob): boolean { - return job.outputTotal !== undefined + return job.record === true } /** Closed-union exhaustiveness fence for the wire status set. */ diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index 9d659ca2ae..9a8b9a1edc 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -30,7 +30,7 @@ function job(over: Partial = {}): SessionJob { /** A job that declared an observation record, so its row offers a panel. */ function recordJob(over: Partial = {}): SessionJob { - return job({ outputTotal: 0, ...over }) + return job({ record: true, ...over }) } function props( @@ -180,7 +180,7 @@ describe('JobListAction observation', () => { streaming: false, } render() diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 9de2adb4c9..3002c82a56 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4987,7 +4987,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionJob', - declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal?: number;\n}', + declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly record?: true;\n}', }, { name: 'SessionLineageNode', diff --git a/packages/jobs/jobs/src/pump.ts b/packages/jobs/jobs/src/pump.ts index 3d7d252d24..5203157a9f 100644 --- a/packages/jobs/jobs/src/pump.ts +++ b/packages/jobs/jobs/src/pump.ts @@ -60,6 +60,14 @@ export interface JobPumpOptions { * `gapBefore`, so the discontinuity stays visible to observers. Fold the * returned promise into the producer's `JobHooks.done` chain so the final * drain lands before settlement trims and closes the record. + * + * Each round drains the sources in array order, so bytes two sources + * produced inside one poll window land in that order, not in the order they + * were written; the record is a best-effort live view whose cross-source + * reordering is bounded by `pollMs`, not a terminal transcript. + * + * The wait holds constant resources however long the job runs: one + * subscription on `done` for the whole run and one pending timer at a time. * @param job - the running job's producer face receiving the copied chunks. * @param sources - producer streams, each pumped at its own offset. * @param options - poll cadence and the producer's settlement promise. @@ -91,18 +99,24 @@ export async function pumpJobOutput( } } let settled = false - const settlement = options.done.then(() => { settled = true }, () => { settled = true }) + let timer: ReturnType | undefined + let wake: (() => void) | undefined + const finish = (): void => { + settled = true + // Clear the pending poll so a finished pump holds no timer for up to one + // interval, and release the current wait so the final drain runs at once. + if (timer !== undefined) clearTimeout(timer) + wake?.() + } + void options.done.then(finish, finish) while (!settled) { drain() - await Promise.race([ - new Promise((resolve) => { - const timer = setTimeout(resolve, options.pollMs) - // The settlement branch wins the race; clear the loser so a finished - // pump holds no pending timer for up to one poll interval. - void settlement.then(() => { clearTimeout(timer); resolve() }) - }), - settlement, - ]) + await new Promise((resolve) => { + wake = resolve + timer = setTimeout(resolve, options.pollMs) + }) + wake = undefined + timer = undefined } drain() } diff --git a/packages/jobs/jobs/tests/service.spec.ts b/packages/jobs/jobs/tests/service.spec.ts index ff2c282a27..3938328478 100644 --- a/packages/jobs/jobs/tests/service.spec.ts +++ b/packages/jobs/jobs/tests/service.spec.ts @@ -173,6 +173,42 @@ describe('pumpJobOutput', () => { } }) + it('subscribes to done once and holds one poll timer however long the job runs', async () => { + vi.useFakeTimers() + try { + const { job } = recordingJob() + // A thenable stands in for the producer's done promise, and whatever the + // pump derives from it counts its own reactions too: a pending promise + // retains every reaction until it settles, so a day-long job must not + // gain one per poll round. + let subscriptions = 0 + let settle!: () => void + const settled = new Promise((resolve) => { settle = resolve }) + const counting = (promise: Promise): Promise => ({ + then(onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown) { + subscriptions += 1 + return counting(promise.then(onFulfilled, onRejected)) + }, + }) as unknown as Promise + const done = counting(settled) + const { source, offsets } = scriptedSource([]) + const pump = pumpJobOutput(job, [source], { pollMs: 50, done }) + + const rounds = 10_000 + await vi.advanceTimersByTimeAsync(50 * rounds) + expect(offsets).toHaveLength(rounds + 1) + expect(subscriptions).toBeLessThanOrEqual(2) + expect(vi.getTimerCount()).toBe(1) + + settle() + await pump + expect(offsets).toHaveLength(rounds + 2) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('marks a lossy source read as a gap so observers see the discontinuity', async () => { const { job, appends } = recordingJob() const { source } = scriptedSource([{ text: 'tail', lossy: true }]) diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index 194557fca7..57f2951bfa 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -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/shell/tool-bash/README.md -README.md: 24e13266a7ee24b39f524f165401076860f494b1 -README.zh.md: 3d2c9f22033d711ce33382054a372e2c26aa2d46 +README.md: 50047a5fe0d69da1feb38f75ada27595b88fa5c5 +README.zh.md: c74a45561298defb4baa5b34be7cebc1cdfbc744 diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 24e13266a7..50047a5fe0 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -55,7 +55,7 @@ The tool executes `bash -c ` and returns the combined output. Commands ### Running long commands in the background -Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted. The background job declares an observation record: the run's non-consuming `observed` readers are pumped into it at `recordPollMs` (default 150), so the Web client streams live output without touching the job's consuming cursor; a pump failure is logged and swallowed while the job runs on. +Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted. The background job declares an observation record: the run's non-consuming `observed` readers are pumped into it at `recordPollMs` (default 150), so the Web client streams live output without touching the job's consuming cursor; a pump failure is logged and swallowed while the job runs on. The record is a best-effort live preview: stdout and stderr are copied per poll round, so writes the two streams made inside one `recordPollMs` window appear stdout first rather than in write order. ### Sandboxed execution and escalation diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index 3d2c9f2203..c74a455612 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -55,7 +55,7 @@ kind: "package-reference" ### 后台运行长时间命令 -传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。后台 job 声明观测 record:运行的非消费 `observed` 读取器以 `recordPollMs`(默认 150)的节奏泵入其中,使 Web 客户端在不触碰 job 消耗游标的情况下流式看到实时输出;泵失败会被记录并吞掉,job 继续运行。 +传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。后台 job 声明观测 record:运行的非消费 `observed` 读取器以 `recordPollMs`(默认 150)的节奏泵入其中,使 Web 客户端在不触碰 job 消耗游标的情况下流式看到实时输出;泵失败会被记录并吞掉,job 继续运行。record 是尽力而为的实时预览:stdout 与 stderr 按轮询轮次复制,同一个 `recordPollMs` 窗口内两条流的写入会先 stdout 后 stderr 出现,而不是按写入顺序。 ### 沙箱执行与升权 diff --git a/packages/shell/tool-bash/src/observe.ts b/packages/shell/tool-bash/src/observe.ts index 2e2874f9c1..0758be7b7b 100644 --- a/packages/shell/tool-bash/src/observe.ts +++ b/packages/shell/tool-bash/src/observe.ts @@ -5,6 +5,9 @@ * `readOutput` cursor. Observation is strictly best-effort: absent backend * offset readers degrade to no observation, and a pump failure is logged and * swallowed — the job path is unaffected either way. + * The record interleaves stdout and stderr per poll round, not per write: + * output the two streams produced inside one `pollMs` window lands stdout + * first, so the panel is a live preview, not a terminal-exact transcript. * * @module @deepseek-ai/dsh-tool-bash/observe */ diff --git a/packages/shell/tool-pwsh/src/observe.ts b/packages/shell/tool-pwsh/src/observe.ts index 6e9e466981..911145bd93 100644 --- a/packages/shell/tool-pwsh/src/observe.ts +++ b/packages/shell/tool-pwsh/src/observe.ts @@ -5,6 +5,9 @@ * `readOutput` cursor. Observation is strictly best-effort: absent backend * offset readers degrade to no observation, and a pump failure is logged and * swallowed — the job path is unaffected either way. + * The record interleaves stdout and stderr per poll round, not per write: + * output the two streams produced inside one `pollMs` window lands stdout + * first, so the panel is a live preview, not a terminal-exact transcript. * * @module @deepseek-ai/dsh-tool-pwsh/observe */ diff --git a/snapshots/web/background-job-list/running.expected.md b/snapshots/web/background-job-list/running.expected.md index 811bddec3c..b3772a0cdf 100644 --- a/snapshots/web/background-job-list/running.expected.md +++ b/snapshots/web/background-job-list/running.expected.md @@ -1,5 +1,5 @@ - list "Background jobs": - listitem: - - button "Show live output of sleep 45": - - text: sleep 45 {{duration}} bash running + - button "Show live output of for _ in $(seq 1 3000); do [ -e .background-job-list.release ] && break; sleep 0.2; done": + - text: for _ in $(seq 1 3000); do [ -e .background-job-list.release ] && break; sleep 0.2; done {{duration}} bash running - img diff --git a/snapshots/web/background-job-list/settled.expected.md b/snapshots/web/background-job-list/settled.expected.md index 44747d990d..8d4846be9c 100644 --- a/snapshots/web/background-job-list/settled.expected.md +++ b/snapshots/web/background-job-list/settled.expected.md @@ -1,5 +1,5 @@ - list "Background jobs": - listitem: - - button "Show live output of sleep 45": - - text: "bash sleep 45 signal: SIGTERM {{duration}}" + - button "Show live output of for _ in $(seq 1 3000); do [ -e .background-job-list.release ] && break; sleep 0.2; done": + - text: "bash for _ in $(seq 1 3000); do [ -e .background-job-list.release ] && break; sleep 0.2; done signal: SIGTERM {{duration}}" - img From 6a4d7ee2429aee6a31a8d5b281526b40c339fba9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 11:33:05 +0800 Subject: [PATCH 024/284] chore(job-controller): align the package version with the 0.1.2-rc.1 bump --- packages/api/job-controller/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json index 0f0e46aafe..71cec52468 100644 --- a/packages/api/job-controller/package.json +++ b/packages/api/job-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-job-controller", "description": "Job Remote observation stream and the reference-counted client job-output service", - "version": "0.1.2-alpha.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, From b48c05d117fe06296cc0f21c5fc77ba7abbecf4e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 13:15:23 +0800 Subject: [PATCH 025/284] refactor(jobs): type the producer face by the record declaration, tidy review residue - `JobStart` is a union discriminated by `record`: `RecordingJobStart.run` receives `RecordingJob` (with `append`), `PlainJobStart.run` receives the plain `RunningJob`, and the registry builds the plain face without the method, so the former warn-and-drop path is unrepresentable. - Remove the duplicated JSDoc opener in the registry seam and correct the job-list slot comment (order 20 sits before the subagent catalog's 30). - Restore the `cordis-tool-round` session fixture whose only change was a re-recorded `createdAt`. - Update the 2026-08-08 job-display note's stale service-disposal and slot ordering facts; archive the superseded 2026-08-25 unified-task-list note and point its remaining inbound links at the archived snapshot. --- .../2026-08-25-unified-task-list.i18n.yaml | 6 ++ .../feature/2026-08-25-unified-task-list.md | 5 +- .../2026-08-25-unified-task-list.zh.md | 9 +- .agents/notes/archived/manifest.json | 3 + ...9-01-jobs-absorb-activity-record.i18n.yaml | 4 +- .../2026-09-01-jobs-absorb-activity-record.md | 3 +- ...26-09-01-jobs-absorb-activity-record.zh.md | 3 +- ...08-08-web-background-job-display.i18n.yaml | 4 +- .../2026-08-08-web-background-job-display.md | 4 +- ...026-08-08-web-background-job-display.zh.md | 4 +- ...-08-24-activity-observation-seam.i18n.yaml | 4 +- .../2026-08-24-activity-observation-seam.md | 4 +- ...2026-08-24-activity-observation-seam.zh.md | 4 +- .../2026-08-25-unified-task-list.i18n.yaml | 6 -- docs/subsystems/jobs.i18n.yaml | 4 +- docs/subsystems/jobs.md | 102 ++++++++++-------- docs/subsystems/jobs.zh.md | 102 ++++++++++-------- .../job-controller/tests/observe.host.spec.ts | 8 +- packages/client/ui-jobs/src/client/index.ts | 3 +- .../extensions/tool-cordis/src/api-catalog.ts | 18 +++- packages/jobs/jobs-local/src/index.ts | 39 +++---- packages/jobs/jobs-local/tests/jobs.spec.ts | 22 ++-- packages/jobs/jobs/README.i18n.yaml | 4 +- packages/jobs/jobs/README.md | 4 +- packages/jobs/jobs/README.zh.md | 4 +- packages/jobs/jobs/src/index.ts | 4 +- packages/jobs/jobs/src/pump.ts | 4 +- packages/jobs/jobs/src/types.ts | 81 +++++++++----- packages/jobs/jobs/tests/service.spec.ts | 42 +++++++- packages/shell/tool-bash/src/observe.ts | 4 +- .../shell/tool-bash/tests/observe.spec.ts | 6 +- packages/shell/tool-pwsh/src/observe.ts | 4 +- scripts/gen-cordis-catalog.ts | 3 + scripts/type-equiv.manifest.json | 15 +++ snapshots/web/cordis-tool-round/session.jsonl | 2 +- 35 files changed, 326 insertions(+), 212 deletions(-) create mode 100644 .agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-08-25-unified-task-list.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-08-25-unified-task-list.zh.md (94%) delete mode 100644 .agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml diff --git a/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml new file mode 100644 index 0000000000..c5b051c49c --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/archived/feature/2026-08-25-unified-task-list.md +2026-08-25-unified-task-list.md: f2f955e36a78eee8fa8ff8a804293bcc37a44acd +2026-08-25-unified-task-list.zh.md: 5a4c51a6cc3d0f331d2559debb73dc02e9cf12c5 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md b/.agents/notes/archived/feature/2026-08-25-unified-task-list.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-25-unified-task-list.md rename to .agents/notes/archived/feature/2026-08-25-unified-task-list.md index daf6582bf2..f2f955e36a 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.md @@ -1,11 +1,12 @@ # Agent Note: One session-header task list — jobs joined with activities in the UI projection only Status: implemented - -Superseded: the two-roster join described here was removed with the activity seam — the job rows are now the whole roster; see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). +Archived: 2026-09-03 English | [中文](2026-08-25-unified-task-list.zh.md) +Superseded: the two-roster join described here was removed with the activity seam — the job rows are now the whole roster; see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). + ## Problem The [activity observation seam](2026-08-24-activity-observation-seam.md) shipped its Web surface as a second session-header button beside the [background-job list](2026-08-08-web-background-job-display.md). A background bash run registers in both planes — `ctx.jobs` for control and `ctx.activities` for observation — so the same work rendered twice: once in "background jobs" with lifecycle and duration, once in "activities" with expandable live output. Two near-identical popovers with different names for overlapping row sets read as a product accident, and the flat activity rows (live and settled interleaved, single-line, no duration) had no visual hierarchy. diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md b/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md similarity index 94% rename from .agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md rename to .agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md index 624747396e..5a4c51a6cc 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md @@ -1,11 +1,12 @@ -# Agent Note:会话头部单一任务列表——jobs 与 activity 只在 UI 投影层合并 +# Agent Note: 会话头部单一任务列表——jobs 与 activity 只在 UI 投影层合并 -状态:implemented - -Superseded:本文描述的双名册 join 已随 activity seam 一并移除——job 行现在就是完整名册;见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 +Status: implemented +Archived: 2026-09-03 [English](2026-08-25-unified-task-list.md) | 中文 +Superseded: 本文描述的双名册 join 已随 activity seam 一并移除——job 行现在就是完整名册;见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 + ## 问题 [activity 观察面](2026-08-24-activity-observation-seam.zh.md)的 Web 呈现落地为[后台任务列表](2026-08-08-web-background-job-display.zh.md)旁边的第二个会话头部按钮。一次后台 bash 会同时登记在两个面里——`ctx.jobs` 负责控制、`ctx.activities` 负责观察——于是同一份工作渲染了两次:一次在"后台任务"里带生命周期与时长,一次在"活动"里带可展开的实时输出。两个行集合高度重叠、名字却不同的近似弹层,读起来像产品事故;而活动列表的扁平行(live 与 settled 混排、单行、无时长)也缺乏视觉层次。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index e2bf423d56..f4cae45ef7 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -328,6 +328,9 @@ "feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml": "sha256:c2cb839ed676040ed62153c2aab65b677fa59739f69253af50246e79a2347620", "feature/2026-08-19-high-cache-hit-decimal-display.md": "sha256:08cb68bfc379da47a05b816afac26126146d36d6248350d4380f7cb98607d573", "feature/2026-08-19-high-cache-hit-decimal-display.zh.md": "sha256:9d7afe3e2fc3029fbccc643b432a01bcb3b5671750adb6945ac414ec843ca063", + "feature/2026-08-25-unified-task-list.i18n.yaml": "sha256:fec0d4a3ecf59aafd440bc801c686b14d63152c937f51bebe0da61ff615f6f33", + "feature/2026-08-25-unified-task-list.md": "sha256:30bd4e338a3de2f59af98d512c7878928c61846e6700d6012fc45e51c0bf94df", + "feature/2026-08-25-unified-task-list.zh.md": "sha256:88fce0a52b5387ea98c1f68742c03b2f475c5465c680befb32c587066e52cfce", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml index f7cf077333..e895f7c65b 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md -2026-09-01-jobs-absorb-activity-record.md: c1a668f8a008948957e62b5acf1d9db141441885 -2026-09-01-jobs-absorb-activity-record.zh.md: a23f3cda4c8c53663a6aee99469297d6878539d1 +2026-09-01-jobs-absorb-activity-record.md: 2f2bf7a65b5c26c80ec7ddc307c0f31a87f4de54 +2026-09-01-jobs-absorb-activity-record.zh.md: ed62a04e8f9da9de35bd721cceee3a2e56644cdb diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md index c1a668f8a0..2f2bf7a65b 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -14,7 +14,7 @@ The one activity without a job was the foreground workflow mirror. Everything el `ctx.jobs` owns the observation record; `packages/activity/`, `packages/api/activity-controller`, and the correlation vocabulary are removed. -- **`JobStart.record?: true`** declares an observable output record. `run(job)` now receives the job's producer face — `RunningJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }` — so the id is issued before the starter runs (a throwing starter still registers nothing; its ordinal is skipped). Writes staged inside the starter surface at the registration commit. `updateDetail` works for every job and gives `job_list` a live progress line; `append` without a record declaration logs and drops. +- **`JobStart.record?: true`** declares an observable output record. `run(job)` now receives the job's producer face — `RecordingJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }` for a `record: true` start, the plain `RunningJob { id, updateDetail(detail) }` otherwise — so the id is issued before the starter runs (a throwing starter still registers nothing; its ordinal is skipped). Writes staged inside the starter surface at the registration commit. `updateDetail` works for every job and gives `job_list` a live progress line; `append` without a record declaration logs and drops. - **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented. - **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design. - **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it. @@ -38,6 +38,7 @@ Review of the merged design settled five points that the sections above leave im - **Client disposal waits for carrier quiescence.** The `ctx.jobOutput` effect disposer is async and awaits every open `RemoteStream.dispose()`, because Cordis awaits async disposers and a fiber that reported unloaded while an old iterator was still closing could overlap the next plugin instance under HMR. - **The roster flags a record, it does not count bytes.** `SessionJob.record: true` replaced `outputTotal`: `append` commits no roster change (only lifecycle commits fire `onJobsChanged`), so a mirrored count went stale the moment output arrived, and broadcasting the roster per append was rejected as traffic for a number no row needed. The live offsets ride the observation stream's `opened` anchor. - **The record is a best-effort live preview.** Producers copy stdout and stderr per poll round, so writes inside one window land stdout first; pipe capture cannot recover the true interleaving (only a PTY or `2>&1` can), and the client concatenates chunks regardless of `channel`. `channel` stays on the wire because it is what makes that limit visible and what a distinct stderr rendering would need. +- **The declaration types the face.** `JobStart` is a union discriminated by `record`: `RecordingJobStart.run` receives `RecordingJob`, `PlainJobStart.run` receives `RunningJob` without `append`, and the registry builds the plain face without the method — output the registry has nowhere to keep is unrepresentable instead of warned about and dropped. - **Web e2e keep-alive is a barrier, not a clock.** The background command holds on a file the test owns in the job's cwd and the scenario kills it explicitly, so no CI stall can settle the job first; each scenario is one test, never a chain of tests sharing a job id. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md index a23f3cda4c..ed62a04e8f 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -14,7 +14,7 @@ Status: implemented `ctx.jobs` 拥有观测 record;`packages/activity/`、`packages/api/activity-controller` 与 correlation 词表被移除。 -- **`JobStart.record?: true`** 声明可观测输出 record。`run(job)` 现在收到 job 的生产者面——`RunningJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }`——因此 id 在 starter 运行前签发(starter 抛出仍然不登记任何东西;序号被跳过)。starter 内暂存的写入在登记提交时可见。`updateDetail` 对所有 job 可用,让 `job_list` 也能看到实时进度行;未声明 record 的 `append` 记日志并丢弃。 +- **`JobStart.record?: true`** 声明可观测输出 record。`run(job)` 现在收到 job 的生产者面——`record: true` 的 start 收到 `RecordingJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }`,其余收到普通的 `RunningJob { id, updateDetail(detail) }`——因此 id 在 starter 运行前签发(starter 抛出仍然不登记任何东西;序号被跳过)。starter 内暂存的写入在登记提交时可见。`updateDetail` 对所有 job 可用,让 `job_list` 也能看到实时进度行;未声明 record 的 `append` 记日志并丢弃。 - **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。 - **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。 - **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。 @@ -38,6 +38,7 @@ Status: implemented - **客户端卸载等待载体静止。** `ctx.jobOutput` 的 effect disposer 是异步的,await 每条打开的 `RemoteStream.dispose()`:Cordis 会 await 异步 disposer,而一个在旧 iterator 仍在关闭时就宣告卸载完成的 fiber,在 HMR 下可能与下一个插件实例重叠。 - **名册标记 record,不计字节。** `SessionJob.record: true` 取代了 `outputTotal`:`append` 不提交名册变更(只有生命周期提交触发 `onJobsChanged`),镜像的计数在输出到达的瞬间就陈旧了;而为一个没有任何行需要的数字按 append 广播整份名册被否决。实时偏移随观测流的 `opened` 锚帧到达。 - **record 是尽力而为的实时预览。** 生产者按轮询轮次复制 stdout 与 stderr,同一窗口内的写入先 stdout 落地;pipe 捕获无法还原真实交错(只有 PTY 或 `2>&1` 可以),客户端拼接 chunk 时也不区分 `channel`。`channel` 留在 wire 上,因为它正是让这一限制可见、也是将来区分渲染 stderr 所需要的东西。 +- **声明决定生产者面的类型。** `JobStart` 是按 `record` 判别的联合类型:`RecordingJobStart.run` 收到 `RecordingJob`,`PlainJobStart.run` 收到没有 `append` 的 `RunningJob`,注册表给普通 job 构造的面根本没有这个方法——注册表无处存放的输出成为不可表达,而不是警告后丢弃。 - **Web e2e 的保活是 barrier 而非时钟。** 后台命令守在测试拥有的、位于 job cwd 的文件上,由场景显式 kill,因此 CI 卡顿不可能先把任务结算掉;每个场景是一个测试,绝不是共享 job id 的测试链。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 444bbac0ad..ee733d059e 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 3dd003c0e972ec7a5857e84980520eee93b53955 -2026-08-08-web-background-job-display.zh.md: 5d1ee4bada9c1966e4329d35f5a4db64300ca3b4 +2026-08-08-web-background-job-display.md: 7787466154d65ba0970010c3fdaf964ae6d8507f +2026-08-08-web-background-job-display.zh.md: 478acdf00936baed1700d18db2f63b1da1749e37 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 3dd003c0e9..7787466154 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -66,7 +66,7 @@ The listener is owner-granular rather than task-granular. The only consumer push `onJobDone` is not a subset of this. It delivers the terminal record with the exact owner `Agent` under first-wins semantics that `dsh-tool-jobs` couples to `reported`; `onJobsChanged` is pure observation with no delivery meaning and marks nothing reported. Listener throws are contained and never awaited, matching `onJobDone`, and each registration is an effect on the calling fiber. -Service disposal deliberately announces nothing. Every `onJobsChanged` registration is an effect on the registry's own fiber, so the listeners are already gone by the time teardown clears the store; an observer learns the registry left through its own disposal, not through a final empty set. +Service disposal commits one final emptying and announces it: after the registry has cancelled and awaited its jobs, `onJobsChanged` fires once per owner whose visible set the teardown emptied, so a listener registered on a longer-lived fiber sees the final empty set instead of a stale roster (a listener registered on the registry's own fiber is already gone by then). ### The Session Controller carrier @@ -89,7 +89,7 @@ Two replacement points keep it honest. Each control-stream generation clears the ### The header action -`@deepseek-ai/dsh-client-ui-jobs` (since merged into `dsh-client-ui-activity`) registers one entry in `conversation.session.header.actions`, ordered after the subagent catalog. Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. +`@deepseek-ai/dsh-client-ui-jobs` registers one entry in `conversation.session.header.actions`, ordered between the preset label and the subagent catalog (`order: 20` against the catalog's 30). Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. A running one-shot background subagent therefore appears both there and in the subagent catalog. The two answer different questions — the catalog navigates into the child's transcript, this list is the only handle a cancellation can ever attach to — and suppressing `kind: 'subagent'` here would leave the cancellation phase with no entry point for exactly those tasks. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 5d1ee4bada..478acdf009 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -66,7 +66,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void `onJobDone` 不是它的子集。后者按 first-wins 语义投递终态记录和确切的 owner `Agent`,`dsh-tool-jobs` 把这套语义与 `reported` 绑在一起;`onJobsChanged` 是纯观察,不含任何投递含义,也不把任何东西标为已上报。监听器抛错被包住且从不 await,与 `onJobDone` 一致,每次注册都是调用方 fiber 上的 effect。 -服务销毁刻意什么都不通告。每个 `onJobsChanged` 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。 +服务销毁会提交最后一次清空并通告它:注册表取消并等完自己的 job 之后,`onJobsChanged` 对每个可见集被 teardown 清空的 owner 触发一次,因此注册在更长命 fiber 上的监听器看到的是最终空集而不是陈旧名册(注册在注册表自身 fiber 上的监听器此时早已消失)。 ### Session Controller 载体 @@ -89,7 +89,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void ### header 入口 -`@deepseek-ai/dsh-client-ui-jobs`(后并入 `dsh-client-ui-activity`) 在 `conversation.session.header.actions` 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 +`@deepseek-ai/dsh-client-ui-jobs` 在 `conversation.session.header.actions` 注册一个条目,排在 preset 标签与 subagent 目录之间(`order: 20`,目录为 30)。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 因此一个运行中的一次性后台 subagent 会同时出现在那里和 subagent 目录里。两者回答不同的问题——目录负责进入子会话的 transcript,而这个列表是中断能力唯一可能附着的句柄——在这里屏蔽 `kind: 'subagent'` 会让中断那一期恰好对这批任务没有入口。 diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml index 651ef99e3c..548e9f5b36 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md -2026-08-24-activity-observation-seam.md: 29ee08b1bb7f72a63d2f84278af62587060de3fe -2026-08-24-activity-observation-seam.zh.md: 97ac4f195c83cdd4f16d2f0a108abd2b3338d95d +2026-08-24-activity-observation-seam.md: 21fe08fd21ad95eec7a83f6f29b9b94514358bb9 +2026-08-24-activity-observation-seam.zh.md: ffb6766b3e5d226d324ea2894a6e3f758cf1932a diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md index 29ee08b1bb..21fe08fd21 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md @@ -19,7 +19,7 @@ A new capability seam, `packages/activity/`, is the non-consuming observation pl - **`@deepseek-ai/dsh-activity` (Service Definition)** — abstract `ActivityRegistry` on `ctx.activities`: `open(spec) → ActivityHandle { append, updateDetail, end }`, fenced `get`/`list`, non-consuming `read(id, from)` over absolute UTF-8 byte offsets, owner-relative `onActivitiesChanged` (roster) and `onOutput` (advancement signal, id only). `ActivityCorrelation { callId?, jobId? }` links a row to its tool call and job. `pumpActivityOutput` is the shared pull-substrate pump. - **`@deepseek-ai/dsh-activity-local` (Service Provider)** — in-memory chunk ring per activity. Offsets stay absolute across eviction (`outputEarliest` marks the oldest retained byte; a read below it is `lossy`, never an error); a single over-cap chunk keeps its UTF-8-safe tail with `gapBefore`. Config `retainBytes` (256 KiB) live, trimmed to `settledRetainBytes` (16 KiB) at settlement. Records outlive producer fibers; owner disposal force-ends and removes; `end` is first-wins and post-end writes log-and-drop so a producer's trailing flush cannot break its own teardown. - **`@deepseek-ai/dsh-api-activity-controller`** — the wire, in the workspace-controller shape: `activity.control` streams one roster baseline then whole-bucket replacement frames per owner session (the jobs-frame self-healing semantics), and `activity.observe({ activityId, from? })` streams one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget), then the terminal `status` on the same stream before closing — so settlement can never race a still-open output channel. Reconnects resume from the last frame's `next`. The client half installs `ctx.activityFeed` (roster mirror plus reference-counted observation streams with a bounded render tail). -- **`@deepseek-ai/dsh-client-ui-activity`** — the session-header task list ([unified with the job rows](2026-08-25-unified-task-list.md)); expanding a row opens its observation stream into a `TerminalBlock` panel, collapsing closes it, so output only flows while someone is watching. `TerminalBlock` itself gained live rendering: a running block with supplied `output` shows the text under the running state instead of the historical prompt-only frame. +- **`@deepseek-ai/dsh-client-ui-activity`** — the session-header task list ([unified with the job rows](../../archived/feature/2026-08-25-unified-task-list.md)); expanding a row opens its observation stream into a `TerminalBlock` panel, collapsing closes it, so output only flows while someone is watching. `TerminalBlock` itself gained live rendering: a running block with supplied `output` shows the text under the running state instead of the historical prompt-only frame. Producers mirror best-effort behind `ctx.get('activities')`, never a declared inject, and every observation failure is logged and swallowed — the seam is strictly optional and can never break the work it observes: @@ -50,4 +50,4 @@ Unit suites pin the registry (offsets, eviction, UTF-8-safe tail trims, lossy re ## Consequences -The Web client now shows live output for background bash/pwsh and workflow narration with zero model-facing change, and any plugin can join with three calls (`open`/`append`/`end`) against an optional service. Bought at the cost of one more seam family and a per-producer tap; the initial two-header-list duplication was folded into [one task list](2026-08-25-unified-task-list.md). Deferred, recorded on the package READMEs: foreground streaming (needs a shell-seam run/observe variant), terminal `pty-send` observation (needs a non-consuming face on terminal buffers), a human kill control (blocked on the jobs `reported` contract), SDK/ACP consumers, and durable replay after restart. +The Web client now shows live output for background bash/pwsh and workflow narration with zero model-facing change, and any plugin can join with three calls (`open`/`append`/`end`) against an optional service. Bought at the cost of one more seam family and a per-producer tap; the initial two-header-list duplication was folded into [one task list](../../archived/feature/2026-08-25-unified-task-list.md). Deferred, recorded on the package READMEs: foreground streaming (needs a shell-seam run/observe variant), terminal `pty-send` observation (needs a non-consuming face on terminal buffers), a human kill control (blocked on the jobs `reported` contract), SDK/ACP consumers, and durable replay after restart. diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md index 97ac4f195c..ffb6766b3e 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md @@ -19,7 +19,7 @@ bash 之下的底座其实早有正确形态:`SubprocessOutputReader.readFrom( - **`@deepseek-ai/dsh-activity`(Service Definition)**——`ctx.activities` 上的抽象 `ActivityRegistry`:`open(spec) → ActivityHandle { append, updateDetail, end }`、有栅栏的 `get`/`list`、按绝对 UTF-8 字节 offset 的非消费 `read(id, from)`、owner 相对投递的 `onActivitiesChanged`(roster)与 `onOutput`(推进信号,只带 id)。`ActivityCorrelation { callId?, jobId? }` 把行关联到工具调用与 job。`pumpActivityOutput` 是 pull 型底座的共享泵。 - **`@deepseek-ai/dsh-activity-local`(Service Provider)**——每个 activity 一个内存 chunk 环。offset 跨淘汰稳定(`outputEarliest` 指明最老保留字节;低于它的读取是 `lossy` 而非错误);单个超上限 chunk 保留 UTF-8 安全尾部并带 `gapBefore`。配置 `retainBytes`(256 KiB)存活,结算时裁到 `settledRetainBytes`(16 KiB)。记录存续期长于生产者 fiber;owner 销毁强制终结并移除;`end` first-wins,end 后写入记日志后丢弃,生产者的收尾 flush 砸不掉自己的 teardown。 - **`@deepseek-ai/dsh-api-activity-controller`**——wire 层,取 workspace-controller 的形态:`activity.control` 流出一份 roster baseline 加按 owner 会话的整桶替换帧(jobs 帧的自愈语义);`activity.observe({ activityId, from? })` 流出一帧 `opened` 锚点、合并的 `output` 帧(`flushMs` 窗口、`maxFrameBytes` 软预算)、再在同一条流上发终态 `status` 后关闭——结算永远不会与仍开着的输出通道竞态。重连以上一帧的 `next` 续传。客户端半区安装 `ctx.activityFeed`(roster 镜像 + 按引用计数的观察流与有界渲染尾巴)。 -- **`@deepseek-ai/dsh-client-ui-activity`**——会话头部任务列表([与 job 行合并](2026-08-25-unified-task-list.zh.md));展开某行即把其观察流打开进 `TerminalBlock` 面板,收起即关——只有有人在看时输出才流动。`TerminalBlock` 本身获得实时渲染能力:running 且提供了 `output` 的块在 running 状态下显示文本,而非历史上的仅提示行画面。 +- **`@deepseek-ai/dsh-client-ui-activity`**——会话头部任务列表([与 job 行合并](../../archived/feature/2026-08-25-unified-task-list.md));展开某行即把其观察流打开进 `TerminalBlock` 面板,收起即关——只有有人在看时输出才流动。`TerminalBlock` 本身获得实时渲染能力:running 且提供了 `output` 的块在 running 状态下显示文本,而非历史上的仅提示行画面。 生产者一律经 `ctx.get('activities')` 尽力镜像,绝不声明 inject,一切观察失败只记日志并吞掉——该 seam 严格可选,永远不能破坏它所观察的工作: @@ -50,4 +50,4 @@ bash 之下的底座其实早有正确形态:`SubprocessOutputReader.readFrom( ## 后果 -Web 客户端在零模型面改动下获得后台 bash/pwsh 实时输出与 workflow 叙述;任何插件对着一个可选服务用三个调用(`open`/`append`/`end`)即可接入。代价是多一个 seam 家族与每生产者一个接入点;最初的双头部列表重复已折并为[单一任务列表](2026-08-25-unified-task-list.zh.md)。暂缓项记录在各包 README:前台流式(需 shell seam 的 run/observe 变体)、terminal `pty-send` 观察(需给 terminal 缓冲加非消费面)、人工 kill 控件(被 jobs `reported` 契约阻塞)、SDK/ACP 消费端、重启后的持久回放。 +Web 客户端在零模型面改动下获得后台 bash/pwsh 实时输出与 workflow 叙述;任何插件对着一个可选服务用三个调用(`open`/`append`/`end`)即可接入。代价是多一个 seam 家族与每生产者一个接入点;最初的双头部列表重复已折并为[单一任务列表](../../archived/feature/2026-08-25-unified-task-list.md)。暂缓项记录在各包 README:前台流式(需 shell seam 的 run/observe 变体)、terminal `pty-send` 观察(需给 terminal 缓冲加非消费面)、人工 kill 控件(被 jobs `reported` 契约阻塞)、SDK/ACP 消费端、重启后的持久回放。 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml deleted file mode 100644 index eab6f6fa1b..0000000000 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# 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 .agents/notes/implemented/feature/2026-08-25-unified-task-list.md -2026-08-25-unified-task-list.md: daf6582bf2c9e4b4a5ce26d6ae632fb78b30dbcb -2026-08-25-unified-task-list.zh.md: 624747396e175a216047239333575322ec4b1808 diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 022befcbd2..8e4c26b4bb 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -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 docs/subsystems/jobs.md -jobs.md: 0610d99892da22a25590ab7efe8ff71d9a004f01 -jobs.zh.md: 4c35fd7c879ddf7133c0db55df24948b40118b9d +jobs.md: 639faa8da3836cb3efc865c494cd8ca8c60f49fb +jobs.zh.md: 3d008671657847520eccd41b8a3bd75014777680 diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 0610d99892..639faa8da3 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -23,42 +23,37 @@ interface JobKindMap { ## Producer contract -`JobStart` declares identity and a starter. The runtime finishes preflight before calling `run()` with the job's producer face and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. +`JobStart` declares identity and a starter, discriminated by `record`: a `RecordingJobStart` receives the `RecordingJob` face, a `PlainJobStart` the `RunningJob` face without `append`. The runtime finishes preflight before calling `run()` with that face and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. ```ts type-equiv /** - * Producer declaration passed to {@link JobRegistry.start}. The runtime - * preflights access and cleanup before invoking {@link run}; the producer owns - * execution resources while the runtime owns identity and lifecycle state. + * A start with the model-facing surfaces only. Its producer face carries no + * `append`, so output the registry would have nowhere to keep is + * unrepresentable rather than dropped. */ -interface JobStart { - /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ - kind: JobKind - /** One-line model-facing label (the command; the delegation description). */ - label: string +interface PlainJobStart extends JobStartBase { + record?: undefined /** - * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. Independent of record - * retention: it bounds the consuming model surface, never - * {@link JobRegistry.readRecord}. + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the live-detail writer. */ - outputLimitBytes?: number - /** - * Owning live agent. Access is fenced by its session id, and agent disposal - * cancels and awaits the job. The instance must be the one currently - * registered under its agent id. Omitting the owner creates an unowned job, - * open to any caller until service disposal. - */ - owner?: Agent - /** - * Declares an observable output record beside the model-facing surfaces: - * {@link RunningJob.append} retains chunks in a bounded ring that any number - * of observers read at absolute byte offsets through - * {@link JobRegistry.readRecord}, and snapshots carry - * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. - * Without the declaration `append` logs and drops. - */ - record?: true + run(job: RunningJob): JobHooks +} +``` + +```ts type-equiv +/** + * A start that declares an observable output record beside the model-facing + * surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that + * any number of observers read at absolute byte offsets through + * {@link JobRegistry.readRecord}, and snapshots carry + * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + */ +interface RecordingJobStart extends JobStartBase { + record: true /** * Start the work after preflight and synchronously return its hooks. Called * once with the job's producer face; a throw leaves nothing registered (the @@ -66,10 +61,20 @@ interface JobStart { * started resources. * @param job - the issued id plus the record append and live-detail writers. */ - run(job: RunningJob): JobHooks + run(job: RecordingJob): JobHooks } ``` +```ts type-equiv +/** + * Producer declaration passed to {@link JobRegistry.start}, discriminated by + * `record`. The runtime preflights access and cleanup before invoking `run`; + * the producer owns execution resources while the runtime owns identity and + * lifecycle state. + */ +type JobStart = PlainJobStart | RecordingJobStart +``` + `JobHooks.done` resolves after the producer releases its resources, not merely when work finishes. Optional `readOutput` distinguishes consuming stream jobs from final-output-only jobs. ```ts type-equiv @@ -113,34 +118,38 @@ interface JobOutcome { ## Observation record -A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RunningJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. Browsers reach the record through `job.observe`, the Remote stream of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below. +A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RecordingJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. Browsers reach the record through `job.observe`, the Remote stream of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below. ```ts type-equiv /** - * Producer face of one registered job, handed to {@link JobStart.run} and - * valid for the job's whole life. All methods are synchronous. Writes staged - * inside the starter call are retained and become visible with the - * registration commit; after settlement — the producer's own outcome, a kill, - * or a registry-forced teardown end — both methods log and drop instead of - * throwing, so a producer's trailing flush cannot break its own teardown path. + * Producer face of one registered job, handed to `run` and valid for the + * job's whole life. All methods are synchronous. Writes staged inside the + * starter call are retained and become visible with the registration commit; + * after settlement — the producer's own outcome, a kill, or a registry-forced + * teardown end — writes log and drop instead of throwing, so a producer's + * trailing flush cannot break its own teardown path. */ interface RunningJob { /** The registry-issued id (`-N`). */ readonly id: JobId + /** + * Replace the snapshot's status detail with a live progress line (`3/10`). + * @param detail - the new detail line. + */ + updateDetail(detail: string): void +} +``` + +```ts type-equiv +/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */ +interface RecordingJob extends RunningJob { /** * Append one record chunk. Offsets advance by the chunk's UTF-8 byte - * length; an empty chunk is dropped without waking observers. Without a - * {@link JobStart.record} declaration the chunk is logged and dropped. + * length; an empty chunk is dropped without waking observers. * @param text - the chunk text, exactly as produced. * @param options - stream label and gap marker. */ append(text: string, options?: JobAppendOptions): void - /** - * Replace the snapshot's status detail with a live progress line (`3/10`). - * Works for every job, with or without a record. - * @param detail - the new detail line. - */ - updateDetail(detail: string): void } ``` @@ -368,7 +377,6 @@ abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal */ abstract onJobDone(listener: JobDoneListener): () => void -/** /** * Register an effect-scoped observer of visible-set changes. It fires after * every commit that changes what {@link list} returns for that owner — diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 4c35fd7c87..3d00867165 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -23,42 +23,37 @@ interface JobKindMap { ## 生产方约定 -`JobStart` 声明身份和启动器。运行时会在完成预检后携带该 job 的生产者面调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。 +`JobStart` 声明身份和启动器,并按 `record` 判别:`RecordingJobStart` 收到 `RecordingJob` 面,`PlainJobStart` 收到没有 `append` 的 `RunningJob` 面。运行时会在完成预检后携带该面调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。 ```ts type-equiv /** - * Producer declaration passed to {@link JobRegistry.start}. The runtime - * preflights access and cleanup before invoking {@link run}; the producer owns - * execution resources while the runtime owns identity and lifecycle state. + * A start with the model-facing surfaces only. Its producer face carries no + * `append`, so output the registry would have nowhere to keep is + * unrepresentable rather than dropped. */ -interface JobStart { - /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ - kind: JobKind - /** One-line model-facing label (the command; the delegation description). */ - label: string +interface PlainJobStart extends JobStartBase { + record?: undefined /** - * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. Independent of record - * retention: it bounds the consuming model surface, never - * {@link JobRegistry.readRecord}. + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the live-detail writer. */ - outputLimitBytes?: number - /** - * Owning live agent. Access is fenced by its session id, and agent disposal - * cancels and awaits the job. The instance must be the one currently - * registered under its agent id. Omitting the owner creates an unowned job, - * open to any caller until service disposal. - */ - owner?: Agent - /** - * Declares an observable output record beside the model-facing surfaces: - * {@link RunningJob.append} retains chunks in a bounded ring that any number - * of observers read at absolute byte offsets through - * {@link JobRegistry.readRecord}, and snapshots carry - * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. - * Without the declaration `append` logs and drops. - */ - record?: true + run(job: RunningJob): JobHooks +} +``` + +```ts type-equiv +/** + * A start that declares an observable output record beside the model-facing + * surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that + * any number of observers read at absolute byte offsets through + * {@link JobRegistry.readRecord}, and snapshots carry + * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + */ +interface RecordingJobStart extends JobStartBase { + record: true /** * Start the work after preflight and synchronously return its hooks. Called * once with the job's producer face; a throw leaves nothing registered (the @@ -66,10 +61,20 @@ interface JobStart { * started resources. * @param job - the issued id plus the record append and live-detail writers. */ - run(job: RunningJob): JobHooks + run(job: RecordingJob): JobHooks } ``` +```ts type-equiv +/** + * Producer declaration passed to {@link JobRegistry.start}, discriminated by + * `record`. The runtime preflights access and cleanup before invoking `run`; + * the producer owns execution resources while the runtime owns identity and + * lifecycle state. + */ +type JobStart = PlainJobStart | RecordingJobStart +``` + `JobHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。 ```ts type-equiv @@ -113,34 +118,38 @@ interface JobOutcome { ## 观测 record -声明 `record: true` 的生产方通过 starter 收到的 `RunningJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。浏览器经 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.observe` 读取 record,其帧类型列在下方该控制器的 Cordis API 一节。 +声明 `record: true` 的生产方通过 starter 收到的 `RecordingJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。浏览器经 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.observe` 读取 record,其帧类型列在下方该控制器的 Cordis API 一节。 ```ts type-equiv /** - * Producer face of one registered job, handed to {@link JobStart.run} and - * valid for the job's whole life. All methods are synchronous. Writes staged - * inside the starter call are retained and become visible with the - * registration commit; after settlement — the producer's own outcome, a kill, - * or a registry-forced teardown end — both methods log and drop instead of - * throwing, so a producer's trailing flush cannot break its own teardown path. + * Producer face of one registered job, handed to `run` and valid for the + * job's whole life. All methods are synchronous. Writes staged inside the + * starter call are retained and become visible with the registration commit; + * after settlement — the producer's own outcome, a kill, or a registry-forced + * teardown end — writes log and drop instead of throwing, so a producer's + * trailing flush cannot break its own teardown path. */ interface RunningJob { /** The registry-issued id (`-N`). */ readonly id: JobId + /** + * Replace the snapshot's status detail with a live progress line (`3/10`). + * @param detail - the new detail line. + */ + updateDetail(detail: string): void +} +``` + +```ts type-equiv +/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */ +interface RecordingJob extends RunningJob { /** * Append one record chunk. Offsets advance by the chunk's UTF-8 byte - * length; an empty chunk is dropped without waking observers. Without a - * {@link JobStart.record} declaration the chunk is logged and dropped. + * length; an empty chunk is dropped without waking observers. * @param text - the chunk text, exactly as produced. * @param options - stream label and gap marker. */ append(text: string, options?: JobAppendOptions): void - /** - * Replace the snapshot's status detail with a live progress line (`3/10`). - * Works for every job, with or without a record. - * @param detail - the new detail line. - */ - updateDetail(detail: string): void } ``` @@ -368,7 +377,6 @@ abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal */ abstract onJobDone(listener: JobDoneListener): () => void -/** /** * Register an effect-scoped observer of visible-set changes. It fires after * every commit that changes what {@link list} returns for that owner — diff --git a/packages/api/job-controller/tests/observe.host.spec.ts b/packages/api/job-controller/tests/observe.host.spec.ts index 5bbb5a8c66..bc6827fdc2 100644 --- a/packages/api/job-controller/tests/observe.host.spec.ts +++ b/packages/api/job-controller/tests/observe.host.spec.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { JobId } from '@deepseek-ai/dsh-jobs' -import type { JobOutcome, RunningJob } from '@deepseek-ai/dsh-jobs' +import type { JobOutcome, RecordingJob } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' @@ -38,13 +38,13 @@ function registerAgent(ctx: Context, rawId: string): Agent { /** Start one record job whose done settles on demand; expose the producer face. */ function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent; record?: boolean } = {}) { let settle!: (outcome: JobOutcome) => void - let face!: RunningJob + let face!: RecordingJob const id = ctx.jobs.start({ kind: 'bash', label: options.label ?? 'echo', ...options.owner !== undefined ? { owner: options.owner } : {}, ...options.record === false ? {} : { record: true }, - run: (job) => { + run: (job: RecordingJob) => { face = job return { cancel() {}, @@ -54,7 +54,7 @@ function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent; }) return { id, - append: (text: string, opts?: Parameters[1]) => { face.append(text, opts) }, + append: (text: string, opts?: Parameters[1]) => { face.append(text, opts) }, updateDetail: (detail: string) => { face.updateDetail(detail) }, settle: async (outcome: JobOutcome) => { settle(outcome) diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts index 209cec1f23..3edaf89efb 100644 --- a/packages/client/ui-jobs/src/client/index.ts +++ b/packages/client/ui-jobs/src/client/index.ts @@ -38,7 +38,8 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'job-list', - // After the subagent catalog: session lineage reads before running work. + // Between the preset label and the subagent catalog (order 30): running + // work reads before the session lineage. order: 20, locale: NS, inject: (): JobListInjected => ({ diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 3002c82a56..ff56058008 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1095,7 +1095,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract onJobsChanged(listener: JobsChangedListener): () => void', - description: '/** Register an effect-scoped observer of visible-set changes. It fires after every commit that changes what list returns for that owner — registration, every stopping transition (including the one teardown performs before it awaits a slow producer), settlement, owner-disposal removal, and the emptying that service disposal commits — so an observer re-reads rather than accumulating deltas.\n\nDelivery is owner-relative on the same terms as onJobDone: an observer registered from an unscoped context — a host composition\'s own carrier — sees every owner, while one registered under an agent composition\'s scope sees exactly the agents composed under it.\n\nThis is not a superset of onJobDone: that one delivers the terminal record under first-wins semantics a job controller couples to notice delivery, while this one carries no delivery meaning and marks nothing reported. Listeners are contained and never awaited.', + description: 'Register an effect-scoped observer of visible-set changes. It fires after every commit that changes what list returns for that owner — registration, every stopping transition (including the one teardown performs before it awaits a slow producer), settlement, owner-disposal removal, and the emptying that service disposal commits — so an observer re-reads rather than accumulating deltas.\n\nDelivery is owner-relative on the same terms as onJobDone: an observer registered from an unscoped context — a host composition\'s own carrier — sees every owner, while one registered under an agent composition\'s scope sees exactly the agents composed under it.\n\nThis is not a superset of onJobDone: that one delivers the terminal record under first-wins semantics a job controller couples to notice delivery, while this one carries no delivery meaning and marks nothing reported. Listeners are contained and never awaited.', parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned job changed and every caller\'s set did.' }], returns: 'disposer that unregisters the listener.', }, @@ -4239,7 +4239,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'JobStart', - declaration: 'export interface JobStart {\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n owner?: Agent;\n record?: true;\n run(job: RunningJob): JobHooks;\n}', + declaration: 'export type JobStart = PlainJobStart | RecordingJobStart;', }, { name: 'JobStatus', @@ -4537,6 +4537,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PermissionSelect', declaration: 'export interface PermissionSelect {\n options: PresetOption[];\n currentValue: string;\n}', }, + { + name: 'PlainJobStart', + declaration: 'export interface PlainJobStart extends JobStartBase {\n record?: undefined;\n run(job: RunningJob): JobHooks;\n}', + }, { name: 'PostToolDecision', declaration: 'export type PostToolDecision = {\n kind: \'accept\';\n content?: ContentBlock[];\n value?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'accept\';\n value: JsonValue;\n content?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'block\';\n feedback: ContentBlock[];\n additionalContexts?: UserMessage[];\n};', @@ -4657,6 +4661,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ReasoningEffortId', declaration: 'export type ReasoningEffortId = Branded<\'ReasoningEffortId\'>;', }, + { + name: 'RecordingJob', + declaration: 'export interface RecordingJob extends RunningJob {\n append(text: string, options?: JobAppendOptions): void;\n}', + }, + { + name: 'RecordingJobStart', + declaration: 'export interface RecordingJobStart extends JobStartBase {\n record: true;\n run(job: RecordingJob): JobHooks;\n}', + }, { name: 'RedactedSecret', declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}', @@ -4739,7 +4751,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RunningJob', - declaration: 'export interface RunningJob {\n readonly id: JobId;\n append(text: string, options?: JobAppendOptions): void;\n updateDetail(detail: string): void;\n}', + declaration: 'export interface RunningJob {\n readonly id: JobId;\n updateDetail(detail: string): void;\n}', }, { name: 'SandboxEnforcement', diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index 15cb5336b0..e5415aee15 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -19,7 +19,7 @@ import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' import type { JobAppendOptions, JobChannel, JobDoneListener, JobKind, JobOutcome, JobOutputListener, JobRead, - JobRecordChunk, JobRecordRead, JobSnapshot, JobStart, JobStatus, JobsChangedListener, RunningJob, + JobRecordChunk, JobRecordRead, JobSnapshot, JobStart, JobStatus, JobsChangedListener, RecordingJob, RunningJob, } from '@deepseek-ai/dsh-jobs' /** Timeout code that distinguishes a bounded wait from caller cancellation. */ @@ -236,17 +236,13 @@ export class LocalJobRegistry extends JobRegistry { const count = (this.counters.get(spec.kind) ?? 0) + 1 this.counters.set(spec.kind, count) const id = JobId(`${spec.kind}-${count}`) - const state: ProducerState = { - record: spec.record === true ? { chunks: [], retainedBytes: 0, total: 0, earliest: 0 } : undefined, - detail: undefined, - job: undefined, - } - const handle: RunningJob = { + const state: ProducerState = { record: undefined, detail: undefined, job: undefined } + const face: RunningJob = { id, - append: (text, options) => { this.appendRecord(id, state, text, options) }, updateDetail: (detail) => { this.updateDetail(state, detail) }, } - const hooks = spec.run(handle) + // The declaration decides the face: a plain start never receives `append`. + const hooks = spec.record === true ? spec.run(this.recordingFace(face, state)) : spec.run(face) let markSettled!: () => void const settled = new Promise((resolve) => { markSettled = resolve }) @@ -556,23 +552,28 @@ export class LocalJobRegistry extends JobRegistry { } } + /** Install the job's record and extend the producer face with its append. */ + private recordingFace(face: RunningJob, state: ProducerState): RecordingJob { + const record: RecordState = { chunks: [], retainedBytes: 0, total: 0, earliest: 0 } + state.record = record + return { + ...face, + append: (text, options) => { this.appendRecord(state, record, text, options) }, + } + } + /** - * Append one chunk through a producer face. A chunk against a settled job or - * without a record declaration is logged and dropped; an empty chunk is - * dropped silently. A chunk staged inside the starter call is retained and - * signals no observer — the registration commit publishes it. + * Append one chunk through a recording producer face. A chunk against a + * settled job is logged and dropped; an empty chunk is dropped silently. A + * chunk staged inside the starter call is retained and signals no observer — + * the registration commit publishes it. */ - private appendRecord(id: JobId, state: ProducerState, text: string, options?: JobAppendOptions): void { + private appendRecord(state: ProducerState, record: RecordState, text: string, options?: JobAppendOptions): void { const job = state.job if (job !== undefined && isTerminal(job.status)) { this.selfCtx.logger.warn(`jobs: append to settled job ${job.id} dropped`) return } - const record = state.record - if (record === undefined) { - this.selfCtx.logger.warn(`jobs: append to job ${id} without a record declaration dropped`) - return - } if (text.length === 0) return const bytes = Buffer.byteLength(text, 'utf8') record.chunks.push({ diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 9771023057..5bcb9b156e 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -6,7 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { JobId } from '@deepseek-ai/dsh-jobs' -import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RunningJob } from '@deepseek-ai/dsh-jobs' +import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RecordingJob } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local' declare module '@deepseek-ai/dsh-jobs' { @@ -66,14 +66,14 @@ function producer(overrides: Partial & JobHooks> = {}) { done: new Promise((res, rej) => { settle = res; reject = rej }), ...hookOverrides, } - let started: RunningJob | undefined + let started: RecordingJob | undefined const spec: JobStart = { kind, label, ...owner !== undefined ? { owner } : {}, ...outputLimitBytes !== undefined ? { outputLimitBytes } : {}, ...record !== undefined ? { record } : {}, - run: (job) => { + run: (job: RecordingJob) => { started = job return hooks }, @@ -83,7 +83,7 @@ function producer(overrides: Partial & JobHooks> = {}) { settle, reject, cancels, - job(): RunningJob { + job(): RecordingJob { if (started === undefined) throw new Error('producer not started') return started }, @@ -200,7 +200,7 @@ describe('LocalJobRegistry.start', () => { for (const job of live) ctx.jobs.start(job.spec) const blocked = producer() - const run = vi.fn((job: RunningJob) => blocked.spec.run(job)) + const run = vi.fn((job: RecordingJob) => blocked.spec.run(job)) expect(() => ctx.jobs.start({ ...blocked.spec, run })) .toThrow('background job limit reached for this owner (limit: 10)') expect(run).not.toHaveBeenCalled() @@ -213,7 +213,7 @@ describe('LocalJobRegistry.start', () => { expect(ctx.jobs.start(first.spec)).toBe('bash-1') const blocked = producer() - const run = vi.fn((job: RunningJob) => blocked.spec.run(job)) + const run = vi.fn((job: RecordingJob) => blocked.spec.run(job)) expect(() => ctx.jobs.start({ ...blocked.spec, run })) .toThrow('use job_kill to stop an unneeded job, wait for it to finish, then retry') expect(run).not.toHaveBeenCalled() @@ -658,7 +658,7 @@ describe('LocalJobRegistry owner isolation', () => { ctx.jobs.start(current.spec) // Attach the current owner's cleanup first. const stale = producer({ owner: staleOwner }) - const staleRun = vi.fn((job: RunningJob) => stale.spec.run(job)) + const staleRun = vi.fn((job: RecordingJob) => stale.spec.run(job)) expect(() => ctx.jobs.start({ ...stale.spec, run: staleRun })) .toThrow('is not the registered agent instance') expect(staleRun).not.toHaveBeenCalled() @@ -1223,14 +1223,12 @@ describe('LocalJobRegistry record', () => { expect(ctx.jobs.get(id).outputTotal).toBe(0) }) - it('logs and drops an append without a record declaration; updateDetail still works', async () => { + it('hands a start without a record declaration no append; updateDetail still works', async () => { const ctx = await harness() - const warn = vi.fn() - ctx.logger.warn = warn as never const p = producer() const id = ctx.jobs.start(p.spec) - p.job().append('nowhere to land') - expect(warn).toHaveBeenCalledWith(expect.stringContaining(`append to job ${id} without a record declaration dropped`)) + // The plain face is built without the method, not with a dropping stub. + expect(p.job()).not.toHaveProperty('append') expect(() => ctx.jobs.readRecord(id, 0)).toThrow(`job ${id} declared no output record`) const snapshot = ctx.jobs.get(id) expect(snapshot.outputTotal).toBeUndefined() diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index 662a521a55..54d4ecc8e9 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -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/jobs/jobs/README.md -README.md: 10c74af908f64c18616e3c1eb71f19a3cbbf4eb3 -README.zh.md: 1fc500311c58a538ebfc7fbd89886d9c12b96acd +README.md: 5d4e10de9fa852dc4a38c521321bae53c744909b +README.zh.md: b584191a6b94e79ba8908875e7a0706a525edf4f diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index 10c74af908..5d4e10de9f 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -31,7 +31,7 @@ Use this package when you are composing a background-job capability or writing a A producer registers work with a kind and a one-line label; the registry returns a `-N` id such as `bash-1`. Anyone who owns the job can read output, list jobs, wait up to a timeout for settlement, and request cancellation — each call returns a fresh snapshot of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the owning agent is notified through the completion listener that `dsh-tool-jobs` turns into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing output read or completion notice stays bounded. -A producer that declares `record: true` additionally streams raw output into the job's bounded record through the `RunningJob` face its starter receives: observers read retained chunks at absolute byte offsets and get signaled on advancement, the settlement that ends the job also ends the stream, and `updateDetail` publishes a live progress line into every snapshot. The record is invisible to the model: reads consume nothing and never touch notice state. +A producer that declares `record: true` additionally streams raw output into the job's bounded record through the `RecordingJob` face its starter receives — a start without the declaration receives the plain `RunningJob` face, which has no `append`: observers read retained chunks at absolute byte offsets and get signaled on advancement, the settlement that ends the job also ends the stream, and `updateDetail` publishes a live progress line into every snapshot. The record is invisible to the model: reads consume nothing and never touch notice state. ### The ownership boundary @@ -77,7 +77,7 @@ This section explains the design decisions behind the contract and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `JobRegistry` service and its contract | -| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `JobSnapshot`, listener types | +| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `RecordingJob`, `JobSnapshot`, listener types | | [`src/brand.ts`](src/brand.ts) | `JobId` branded identifier, importable without the agent dependency | | [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`: poll pump copying producer offset-readers into a job record | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates snapshot identity, status, timestamps, owner, and record-offset fields | diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index 1fc500311c..b584191a6b 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 生产方以 kind 和一行标签注册工作;注册表返回 `-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新快照,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,拥有它的 agent 会通过 `dsh-tool-jobs` 转成会话内通知的完成监听器得到通知,因此无需轮询。生产方还可以附加可选的字节上限,让每次完整的模型侧输出读取或完成通知保持有界。 -声明 `record: true` 的生产方还会通过 starter 收到的 `RunningJob` 面把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。 +声明 `record: true` 的生产方还会通过 starter 收到的 `RecordingJob` 面(未声明的 start 收到的是没有 `append` 的普通 `RunningJob` 面)把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。 ### 归属边界 @@ -77,7 +77,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `JobRegistry` 服务及其约定 | -| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`JobSnapshot`、监听器类型 | +| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`RecordingJob`、`JobSnapshot`、监听器类型 | | [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 | | [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`:把生产方偏移读取器复制进 job record 的轮询泵 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳、所有者与 record 偏移字段 | diff --git a/packages/jobs/jobs/src/index.ts b/packages/jobs/jobs/src/index.ts index 796afc0882..4b6d6d9d43 100644 --- a/packages/jobs/jobs/src/index.ts +++ b/packages/jobs/jobs/src/index.ts @@ -33,6 +33,9 @@ export type { JobStart, JobStatus, JobsChangedListener, + PlainJobStart, + RecordingJob, + RecordingJobStart, RunningJob, } from './types.ts' export { pumpJobOutput } from './pump.ts' @@ -163,7 +166,6 @@ export abstract class JobRegistry extends Service { */ abstract onJobDone(listener: JobDoneListener): () => void - /** /** * Register an effect-scoped observer of visible-set changes. It fires after * every commit that changes what {@link list} returns for that owner — diff --git a/packages/jobs/jobs/src/pump.ts b/packages/jobs/jobs/src/pump.ts index 5203157a9f..a3c1956019 100644 --- a/packages/jobs/jobs/src/pump.ts +++ b/packages/jobs/jobs/src/pump.ts @@ -5,7 +5,7 @@ * @module */ -import type { JobChannel, RunningJob } from './types.ts' +import type { JobChannel, RecordingJob } from './types.ts' /** One non-consuming offset read from a producer-owned stream. */ export interface JobPumpRead { @@ -74,7 +74,7 @@ export interface JobPumpOptions { * @returns resolves after the final post-settlement drain. */ export async function pumpJobOutput( - job: RunningJob, + job: RecordingJob, sources: readonly JobPumpSource[], options: JobPumpOptions, ): Promise { diff --git a/packages/jobs/jobs/src/types.ts b/packages/jobs/jobs/src/types.ts index b42d7815e4..3ec3f87357 100644 --- a/packages/jobs/jobs/src/types.ts +++ b/packages/jobs/jobs/src/types.ts @@ -56,12 +56,8 @@ export interface JobAppendOptions { gapBefore?: true } -/** - * Producer declaration passed to {@link JobRegistry.start}. The runtime - * preflights access and cleanup before invoking {@link run}; the producer owns - * execution resources while the runtime owns identity and lifecycle state. - */ -export interface JobStart { +/** Fields shared by every producer declaration passed to {@link JobRegistry.start}. */ +interface JobStartBase { /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ kind: JobKind /** One-line model-facing label (the command; the delegation description). */ @@ -80,15 +76,34 @@ export interface JobStart { * open to any caller until service disposal. */ owner?: Agent +} + +/** + * A start with the model-facing surfaces only. Its producer face carries no + * `append`, so output the registry would have nowhere to keep is + * unrepresentable rather than dropped. + */ +export interface PlainJobStart extends JobStartBase { + record?: undefined /** - * Declares an observable output record beside the model-facing surfaces: - * {@link RunningJob.append} retains chunks in a bounded ring that any number - * of observers read at absolute byte offsets through - * {@link JobRegistry.readRecord}, and snapshots carry - * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. - * Without the declaration `append` logs and drops. + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the live-detail writer. */ - record?: true + run(job: RunningJob): JobHooks +} + +/** + * A start that declares an observable output record beside the model-facing + * surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that + * any number of observers read at absolute byte offsets through + * {@link JobRegistry.readRecord}, and snapshots carry + * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + */ +export interface RecordingJobStart extends JobStartBase { + record: true /** * Start the work after preflight and synchronously return its hooks. Called * once with the job's producer face; a throw leaves nothing registered (the @@ -96,34 +111,44 @@ export interface JobStart { * started resources. * @param job - the issued id plus the record append and live-detail writers. */ - run(job: RunningJob): JobHooks + run(job: RecordingJob): JobHooks } /** - * Producer face of one registered job, handed to {@link JobStart.run} and - * valid for the job's whole life. All methods are synchronous. Writes staged - * inside the starter call are retained and become visible with the - * registration commit; after settlement — the producer's own outcome, a kill, - * or a registry-forced teardown end — both methods log and drop instead of - * throwing, so a producer's trailing flush cannot break its own teardown path. + * Producer declaration passed to {@link JobRegistry.start}, discriminated by + * `record`. The runtime preflights access and cleanup before invoking `run`; + * the producer owns execution resources while the runtime owns identity and + * lifecycle state. + */ +export type JobStart = PlainJobStart | RecordingJobStart + +/** + * Producer face of one registered job, handed to `run` and valid for the + * job's whole life. All methods are synchronous. Writes staged inside the + * starter call are retained and become visible with the registration commit; + * after settlement — the producer's own outcome, a kill, or a registry-forced + * teardown end — writes log and drop instead of throwing, so a producer's + * trailing flush cannot break its own teardown path. */ export interface RunningJob { /** The registry-issued id (`-N`). */ readonly id: JobId + /** + * Replace the snapshot's status detail with a live progress line (`3/10`). + * @param detail - the new detail line. + */ + updateDetail(detail: string): void +} + +/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */ +export interface RecordingJob extends RunningJob { /** * Append one record chunk. Offsets advance by the chunk's UTF-8 byte - * length; an empty chunk is dropped without waking observers. Without a - * {@link JobStart.record} declaration the chunk is logged and dropped. + * length; an empty chunk is dropped without waking observers. * @param text - the chunk text, exactly as produced. * @param options - stream label and gap marker. */ append(text: string, options?: JobAppendOptions): void - /** - * Replace the snapshot's status detail with a live progress line (`3/10`). - * Works for every job, with or without a record. - * @param detail - the new detail line. - */ - updateDetail(detail: string): void } /** Hooks through which the runtime controls and observes producer work. */ diff --git a/packages/jobs/jobs/tests/service.spec.ts b/packages/jobs/jobs/tests/service.spec.ts index 3938328478..331bf6596b 100644 --- a/packages/jobs/jobs/tests/service.spec.ts +++ b/packages/jobs/jobs/tests/service.spec.ts @@ -3,8 +3,8 @@ import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { JobId, JobRegistry, pumpJobOutput } from '@deepseek-ai/dsh-jobs' import type { - JobAppendOptions, JobDoneListener, JobOutputListener, JobPumpSource, JobRead, JobRecordRead, - JobSnapshot, JobStart, JobsChangedListener, RunningJob, + JobAppendOptions, JobDoneListener, JobHooks, JobOutputListener, JobPumpSource, JobRead, JobRecordRead, + JobSnapshot, JobStart, JobsChangedListener, PlainJobStart, RecordingJob, RecordingJobStart, } from '@deepseek-ai/dsh-jobs' /** @@ -71,6 +71,40 @@ class StubJobRegistry extends JobRegistry { } } +/** Compile-time probe: does the producer face `Face` carry `append`? */ +type HasAppend = 'append' extends keyof Face ? true : false + +describe('JobStart producer faces', () => { + it('hands append only to a start that declares a record', () => { + const hooks: JobHooks = { cancel() {}, done: new Promise(() => {}) } + // The literal's `record` discriminant selects the face `run` receives. + const plain: JobStart = { + kind: 'bash', + label: 'no record', + run: (job) => { + job.updateDetail('works for every job') + return hooks + }, + } + const recording: JobStart = { + kind: 'bash', + label: 'record', + record: true, + run: (job) => { + job.append('lands in the record') + return hooks + }, + } + // Negative proof: a plain start's face has no `append` at the type level. + const plainFace: HasAppend[0]> = false + const recordingFace: HasAppend[0]> = true + expect(plainFace).toBe(false) + expect(recordingFace).toBe(true) + expect(plain.record).toBeUndefined() + expect(recording.record).toBe(true) + }) +}) + describe('JobRegistry seam', () => { it('a concrete subclass registers as ctx.jobs and serves the abstract API', async () => { const ctx = new Context() @@ -109,9 +143,9 @@ describe('JobRegistry seam', () => { }) /** Record every append so pump behavior is observable without a registry. */ -function recordingJob(): { job: RunningJob; appends: { text: string; options?: JobAppendOptions }[] } { +function recordingJob(): { job: RecordingJob; appends: { text: string; options?: JobAppendOptions }[] } { const appends: { text: string; options?: JobAppendOptions }[] = [] - const job: RunningJob = { + const job: RecordingJob = { id: JobId('bash-1'), append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) }, updateDetail() {}, diff --git a/packages/shell/tool-bash/src/observe.ts b/packages/shell/tool-bash/src/observe.ts index 0758be7b7b..89a28817bc 100644 --- a/packages/shell/tool-bash/src/observe.ts +++ b/packages/shell/tool-bash/src/observe.ts @@ -15,7 +15,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ShellProcess } from '@deepseek-ai/dsh-shell' import { pumpJobOutput } from '@deepseek-ai/dsh-jobs' -import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs' +import type { JobPumpSource, RecordingJob } from '@deepseek-ai/dsh-jobs' /** * Pump the process's non-consuming offset readers into the job record until @@ -31,7 +31,7 @@ import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs' */ export function observeProcessRecord( ctx: Context, - job: RunningJob, + job: RecordingJob, proc: ShellProcess, pollMs: number, ): Promise { diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts index 8a4ac6ae2a..4586ada7a5 100644 --- a/packages/shell/tool-bash/tests/observe.spec.ts +++ b/packages/shell/tool-bash/tests/observe.spec.ts @@ -10,7 +10,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' -import type { JobId, RunningJob } from '@deepseek-ai/dsh-jobs' +import type { JobId, RecordingJob } from '@deepseek-ai/dsh-jobs' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import type { ShellProcess } from '@deepseek-ai/dsh-shell' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -121,8 +121,8 @@ describe('background bash record', () => { }) describe('observeProcessRecord', () => { - /** A RunningJob face recording appends for pump assertions. */ - function recordingJob(): { job: RunningJob; appends: string[] } { + /** A RecordingJob face recording appends for pump assertions. */ + function recordingJob(): { job: RecordingJob; appends: string[] } { const appends: string[] = [] return { job: { diff --git a/packages/shell/tool-pwsh/src/observe.ts b/packages/shell/tool-pwsh/src/observe.ts index 911145bd93..357567e31e 100644 --- a/packages/shell/tool-pwsh/src/observe.ts +++ b/packages/shell/tool-pwsh/src/observe.ts @@ -16,7 +16,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ShellProcess } from '@deepseek-ai/dsh-shell' import { pumpJobOutput } from '@deepseek-ai/dsh-jobs' -import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs' +import type { JobPumpSource, RecordingJob } from '@deepseek-ai/dsh-jobs' /** * Pump the process's non-consuming offset readers into the job record until @@ -32,7 +32,7 @@ import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs' */ export function observeProcessRecord( ctx: Context, - job: RunningJob, + job: RecordingJob, proc: ShellProcess, pollMs: number, ): Promise { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ac52455c6d..b78e1dcf07 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -513,6 +513,9 @@ export const LINK_MAP: Readonly> = { JobWireChunk: 'jobs.md', JobSnapshot: 'jobs.md', JobStart: 'jobs.md', + PlainJobStart: 'jobs.md', + RecordingJob: 'jobs.md', + RecordingJobStart: 'jobs.md', JobsChangedListener: 'jobs.md', JobAppendOptions: 'jobs.md', JobChannel: 'jobs.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 2c24e9b407..327f58f9e3 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1006,6 +1006,16 @@ "symbol": "JobKindMap", "source": "packages/jobs/jobs/src/types.ts" }, + { + "doc": "docs/subsystems/jobs.md", + "symbol": "PlainJobStart", + "source": "packages/jobs/jobs/src/types.ts" + }, + { + "doc": "docs/subsystems/jobs.md", + "symbol": "RecordingJobStart", + "source": "packages/jobs/jobs/src/types.ts" + }, { "doc": "docs/subsystems/jobs.md", "symbol": "JobStart", @@ -1036,6 +1046,11 @@ "symbol": "RunningJob", "source": "packages/jobs/jobs/src/types.ts" }, + { + "doc": "docs/subsystems/jobs.md", + "symbol": "RecordingJob", + "source": "packages/jobs/jobs/src/types.ts" + }, { "doc": "docs/subsystems/jobs.md", "symbol": "JobRecordChunk", diff --git a/snapshots/web/cordis-tool-round/session.jsonl b/snapshots/web/cordis-tool-round/session.jsonl index e69dbc7f2a..354033a43e 100644 --- a/snapshots/web/cordis-tool-round/session.jsonl +++ b/snapshots/web/cordis-tool-round/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1788357779640,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736473857,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} From 09bd6bcf54a1dbc792fb8b79bac9d20515dd59cc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 13:45:09 +0800 Subject: [PATCH 026/284] chore(http-proxy): align the package version with the 0.1.2-rc.1 bump --- packages/util/http-proxy/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/util/http-proxy/package.json b/packages/util/http-proxy/package.json index b626e431e3..d75ba94f36 100644 --- a/packages/util/http-proxy/package.json +++ b/packages/util/http-proxy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-http-proxy", "description": "Process-wide outbound HTTP proxy policy for DeepSeek Harness: resolve it from the launch environment and install it as undici's global dispatcher", - "version": "0.1.2-alpha.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, From 07941fe5e2a18abd89faa1a09f395dad164a8b66 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 15:51:39 +0800 Subject: [PATCH 027/284] refactor(jobs): consolidate the seam into JobSpec, VisibleJobs, and one event stream The registry seam keeps three members plus the controller handshake: `start(spec)`, `visibleTo(caller)` (one authorization, then list / get / read / readAt / kill / wait without a caller argument), and `events` (one typed stream with an explicit filter instead of three listeners). Every job owns an output ring: `JobSpec.output` names pull sources the registry pumps, `JobHandle.append` pushes, and the model's consuming `read` and observers' `readAt` read the same bytes through separate cursors. `reported`, the `record` declaration, the producer `readOutput` hook, and the `JobStart`/`RunningJob` split are gone; `JobView` is the one projection, split into `progress` and terminal `detail`. The client-safe `./view` leaf carries the projection vocabulary. --- packages/jobs/jobs/package.json | 4 + packages/jobs/jobs/src/index.ts | 207 +++------- packages/jobs/jobs/src/invariant.ts | 53 ++- packages/jobs/jobs/src/pump.ts | 122 ------ packages/jobs/jobs/src/types.ts | 430 +++++++++++---------- packages/jobs/jobs/src/view.ts | 81 ++++ packages/jobs/jobs/tests/invariant.spec.ts | 91 +++-- packages/jobs/jobs/tests/service.spec.ts | 286 ++++---------- tsconfig.base.json | 1 + 9 files changed, 513 insertions(+), 762 deletions(-) delete mode 100644 packages/jobs/jobs/src/pump.ts create mode 100644 packages/jobs/jobs/src/view.ts diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index 34b32d40e7..dfaba10771 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -26,6 +26,10 @@ "types": "./lib/types/brand.d.ts", "default": "./lib/types/brand.js" }, + "./view": { + "types": "./lib/types/view.d.ts", + "default": "./lib/types/view.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/packages/jobs/jobs/src/index.ts b/packages/jobs/jobs/src/index.ts index 4b6d6d9d43..a1c60cbeef 100644 --- a/packages/jobs/jobs/src/index.ts +++ b/packages/jobs/jobs/src/index.ts @@ -1,45 +1,43 @@ /** * The background-job Service Definition (`ctx.jobs`). It owns the contract for - * job ids, session-scoped access, lifecycle state, completion listeners, - * owner cleanup, and the optional per-job observation record — an append-only - * bounded output stream any number of independent observers read at absolute - * byte offsets, invisible to the model-facing consuming cursor — while - * producers retain their execution resources. The process-local registry - * lives in `@deepseek-ai/dsh-jobs-local`. + * job ids, session-scoped access, lifecycle state, the per-job output ring — + * one bounded stream that the model consumes through a registry-kept cursor + * and that any number of observers read at absolute byte offsets — and the + * event stream announcing every commit, while producers retain their + * execution resources. The process-local registry lives in + * `@deepseek-ai/dsh-jobs-local`. * @module @deepseek-ai/dsh-jobs */ import { Context, Service } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { - JobDoneListener, JobId, JobOutputListener, JobRead, JobRecordRead, JobSnapshot, JobStart, - JobsChangedListener, -} from './types.ts' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { JobEvents, JobId, JobSpec, VisibleJobs } from './types.ts' export { JobId } from './types.ts' export type { JobAppendOptions, JobChannel, - JobDoneListener, + JobChunk, + JobEvent, + JobEventFilter, + JobEventListener, + JobEvents, + JobHandle, JobHooks, + JobKillOptions, JobKind, JobKindMap, JobOutcome, - JobOutputListener, + JobOutputRead, + JobOutputSource, JobRead, - JobRecordChunk, - JobRecordRead, - JobSnapshot, - JobStart, + JobSettleCause, + JobSourceRead, + JobSpec, JobStatus, - JobsChangedListener, - PlainJobStart, - RecordingJob, - RecordingJobStart, - RunningJob, + JobView, + VisibleJobs, } from './types.ts' -export { pumpJobOutput } from './pump.ts' -export type { JobPumpOptions, JobPumpRead, JobPumpSource } from './pump.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -48,7 +46,7 @@ declare module '@deepseek-ai/cordis' { } /** - * Abstract background job registry. Subclass, implement the abstract methods, + * Abstract background job registry. Subclass, implement the abstract members, * and load the subclass as a plugin — it registers as `ctx.jobs` (one * implementation per context; loading a second throws, which is cordis' * standard duplicate-service behavior). @@ -56,32 +54,30 @@ declare module '@deepseek-ai/cordis' { * Implementations must honor these semantics: * - Registrations outlive producer and controller fibers. Owner and * service disposal cancel live work and await compliant producers; a - * throwing teardown cancel force-fails only the record. Teardown - * cancellation also marks the record reported, because a record its owner - * is being destroyed for has no reader left. + * throwing teardown cancel force-fails only the record. Such settlements + * announce `cause: 'teardown'`, because a job whose owner is being destroyed + * has no reader left. * - Owned-job access is fenced by the owner's session id. Ids are * predictable, so authorization — not secrecy — is the boundary. - * - Settlement is first-wins: one terminal record, released waiters, and one - * round of contained listener notification, even against a late producer - * outcome. Completion is announced last, after the record is committed and - * every other observer of the settlement has seen it, because a reporter - * may open a model turn synchronously. + * - Settlement is first-wins: one terminal record, released waiters, then one + * round of contained event delivery, even against a late producer outcome. + * The `settled` event follows every released waiter, so a consumer that + * claims a settlement while waiting always claims before the event. * - {@link start} refuses work while no attached job controller serves the * spec's owner, so a producer cannot start work that owner cannot collect * or stop. One registry serves every composition in the process, so this - * question — and completion-listener delivery — is owner-relative rather - * than process-wide: registrations made from an unscoped context serve - * every owner, and registrations made under an agent composition's scope - * serve exactly the agents composed under it. - * - Record observation never consumes. Any number of {@link readRecord} - * readers hold their own absolute byte offsets; a record read changes no - * cursor and no notice state, so the model-facing surfaces (the consuming - * {@link read}, completion notices) are unaffected. - * - Record retention is bounded. Appends past the live cap drop the oldest + * question — and event delivery under `{ owners: 'scope' }` — is + * owner-relative rather than process-wide: registrations made from an + * unscoped context serve every owner, and registrations made under an agent + * composition's scope serve exactly the agents composed under it. + * - Every job owns one output ring. Pull sources named by the spec are pumped + * by the registry and drained once more before settlement; pushed appends + * land whole. The model's consuming cursor and observers' absolute offsets + * read the same bytes and never disturb each other. + * - Ring retention is bounded. Appends past the live cap drop the oldest * retained bytes; a reader below the retained window gets a lossy read, - * never an error. Settlement — the producer outcome, a kill, or teardown — - * trims retention to the settled cap and ends the stream; the record has no - * separate lifecycle. + * never an error. Settlement trims retention to the settled cap and ends + * the stream; the ring has no separate lifecycle. */ export abstract class JobRegistry extends Service { constructor(ctx: Context) { @@ -94,125 +90,28 @@ export abstract class JobRegistry extends Service { super(ctx, 'jobs') } + /** Lifecycle and output events, filtered per subscription. */ + abstract readonly events: JobEvents + /** * Preflight access, validation, owner cleanup, and implementation-owned * admission before starting and atomically registering work. Any preflight * rejection leaves no job id or execution resource. A throwing starter * leaves nothing registered; after it returns, registration cannot fail. - * Settlement records the outcome, notifies listeners, and releases waiters. - * @param spec - job identity, owner, and synchronous starter. + * @param spec - job identity, owner, output sources, and synchronous starter. * @returns the registry-issued `-N` id. */ - abstract start(spec: JobStart): JobId + abstract start(spec: JobSpec): JobId /** - * List caller-owned and unowned jobs in registration order without exposing - * another session's labels. - * @param caller - reading agent; a non-agent caller sees only unowned jobs. - * @returns fresh snapshots. + * Bind the caller's identity once and return its operations. A caller sees + * its own jobs and every unowned job; a caller-less view sees unowned jobs + * only. The view resolves visibility on every call, so it stays valid as + * jobs come and go. + * @param caller - the reading session, or undefined for an anonymous caller. + * @returns the operations available to that caller. */ - abstract list(caller?: Agent): JobSnapshot[] - - /** - * Return a non-consuming snapshot without changing its read cursor or notice - * state. Throws for an unknown or foreign job. - * @param id - job to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ - abstract get(id: JobId, caller?: Agent): JobSnapshot - - /** - * Read the next stream delta, or the idempotent final output after settlement. - * A terminal read marks the job reported. Throws for an unknown or foreign - * job. - * @param id - job to read. - * @param caller - reading agent checked against the owner. - * @returns output text and the post-read snapshot. - */ - abstract read(id: JobId, caller?: Agent): JobRead - - /** - * Request cancellation, then mark the job stopping and reported. A producer - * throw propagates without changing job state. Throws for an unknown or - * foreign job. - * @param id - job to cancel. - * @param caller - killing agent checked against the owner. - * @param reason - logged reason forwarded to the producer. - * @returns `requested` for live work, otherwise `already-finished`. - */ - abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished' - - /** - * Wait for settlement or timeout without cancelling the job. Caller abort - * rejects only while the job is live; after settlement the terminal - * snapshot wins so a notice suppressed for this waiter is still delivered. - * Throws for invalid, unknown, or foreign input. - * @param id - job to wait for. - * @param timeoutMs - positive finite wait bound in milliseconds. - * @param caller - waiting agent checked against the owner. - * @param signal - optional cancellation of the wait itself. - * @returns snapshot at settlement or timeout. - */ - abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise - - /** - * Register an effect-scoped completion listener. It receives the settlements - * of the owners its registering context's scope covers; each listener is - * contained; returned promises are observed but not awaited. No listener runs - * after service disposal. - * @param listener - receives each terminal snapshot and its exact owner. - * @returns disposer that unregisters the listener. - */ - abstract onJobDone(listener: JobDoneListener): () => void - - /** - * Register an effect-scoped observer of visible-set changes. It fires after - * every commit that changes what {@link list} returns for that owner — - * registration, every stopping transition (including the one teardown - * performs before it awaits a slow producer), settlement, owner-disposal - * removal, and the emptying that service disposal commits — so an observer - * re-reads rather than accumulating deltas. - * - * Delivery is owner-relative on the same terms as {@link onJobDone}: an - * observer registered from an unscoped context — a host composition's own - * carrier — sees every owner, while one registered under an agent - * composition's scope sees exactly the agents composed under it. - * - * This is not a superset of {@link onJobDone}: that one delivers the terminal - * record under first-wins semantics a job controller couples to notice - * delivery, while this one carries no delivery meaning and marks nothing - * reported. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned job changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ - abstract onJobsChanged(listener: JobsChangedListener): () => void - - /** - * Read retained record output from an absolute byte offset without consuming - * it. Resume by passing a previous read's `next`; a foreign offset inside a - * retained chunk returns that whole chunk (its `at` may precede `from`). - * Never marks the job reported. Throws for an unknown or foreign job, a job - * without a {@link JobStart.record} declaration, or a negative or - * non-integer offset. - * @param id - job to read. - * @param from - absolute byte offset to read from (0 for the retained head). - * @param caller - reading agent checked against the owner. - * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. - */ - abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead - - /** - * Register an effect-scoped observer of record advancement — one signal per - * committed append and one at settlement, carrying only the job id. A - * consumer schedules a {@link readRecord} from its own cursor; the registry - * never pushes payloads. Delivery is owner-relative on the same terms as - * {@link onJobsChanged}. Listeners are contained and never awaited. - * @param listener - receives the id whose record advanced. - * @returns disposer that unregisters the listener. - */ - abstract onOutput(listener: JobOutputListener): () => void + abstract visibleTo(caller?: SessionId): VisibleJobs /** * Attach an effect-scoped controller that can read and stop jobs. It serves the diff --git a/packages/jobs/jobs/src/invariant.ts b/packages/jobs/jobs/src/invariant.ts index 36aca11fd8..84e3a22fe2 100644 --- a/packages/jobs/jobs/src/invariant.ts +++ b/packages/jobs/jobs/src/invariant.ts @@ -1,9 +1,8 @@ -/** Package-owned background-job snapshot invariants. @module @deepseek-ai/dsh-jobs/invariant */ +/** Package-owned background-job projection invariants. @module @deepseek-ai/dsh-jobs/invariant */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import type { JobSnapshot } from './types.ts' +import type { JobView } from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-jobs' const TERMINAL_STATUSES = new Set(['completed', 'killed', 'failed']) @@ -13,48 +12,44 @@ export const name = 'jobs-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate the cross-field relationships in one registry snapshot. */ -function validateSnapshot(snapshot: JobSnapshot, owner: Agent | undefined, fail: InvariantFailure): void { - const id = String(snapshot.id) - const prefix = `${snapshot.kind}-` +/** Validate the cross-field relationships in one registry projection. */ +function validateView(job: JobView, fail: InvariantFailure): void { + const id = String(job.id) + const prefix = `${job.kind}-` const ordinal = Number(id.slice(prefix.length)) - if (snapshot.kind.length === 0 || !id.startsWith(prefix) + if (job.kind.length === 0 || !id.startsWith(prefix) || !Number.isSafeInteger(ordinal) || ordinal < 1) { - fail(`job snapshot id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`) + fail(`job view id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`) } - if (snapshot.label.length === 0) fail(`job ${JSON.stringify(id)} label must be non-empty`) - if (!Number.isSafeInteger(snapshot.startedAt) || snapshot.startedAt < 0) { + if (job.label.length === 0) fail(`job ${JSON.stringify(id)} label must be non-empty`) + if (!Number.isSafeInteger(job.startedAt) || job.startedAt < 0) { fail(`job ${JSON.stringify(id)} startedAt must be a non-negative epoch integer`) } - const terminal = TERMINAL_STATUSES.has(snapshot.status) - if (terminal !== (snapshot.finishedAt !== undefined)) { + const terminal = TERMINAL_STATUSES.has(job.status) + if (terminal !== (job.finishedAt !== undefined)) { fail(`job ${JSON.stringify(id)} finishedAt must be present exactly for a terminal status`) } - if (snapshot.finishedAt !== undefined - && (!Number.isSafeInteger(snapshot.finishedAt) || snapshot.finishedAt < snapshot.startedAt)) { + if (job.finishedAt !== undefined + && (!Number.isSafeInteger(job.finishedAt) || job.finishedAt < job.startedAt)) { fail(`job ${JSON.stringify(id)} finishedAt must be an epoch integer no earlier than startedAt`) } - - const expectedOwner = owner?.id - if (snapshot.ownerSession !== expectedOwner) { - fail(`job ${JSON.stringify(id)} ownerSession does not match its completion owner`) + if (terminal && job.progress !== undefined) { + fail(`job ${JSON.stringify(id)} progress must be cleared once settled`) } - const { outputTotal, outputEarliest } = snapshot - if ((outputTotal !== undefined) !== (outputEarliest !== undefined)) { - fail(`job ${JSON.stringify(id)} record offsets must be present together`) - } else if (outputTotal !== undefined && outputEarliest !== undefined - && (!Number.isSafeInteger(outputTotal) || !Number.isSafeInteger(outputEarliest) - || outputEarliest < 0 || outputEarliest > outputTotal)) { - fail(`job ${JSON.stringify(id)} record offsets must satisfy 0 <= outputEarliest <= outputTotal`) + const { total, earliest } = job.output + if (!Number.isSafeInteger(total) || !Number.isSafeInteger(earliest) || earliest < 0 || earliest > total) { + fail(`job ${JSON.stringify(id)} output offsets must satisfy 0 <= earliest <= total`) } } -/** Install checks over current unowned records and every terminal snapshot. */ +/** Install checks over the current unowned projections and every settlement. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - for (const snapshot of ctx.jobs.list()) validateSnapshot(snapshot, undefined, fail) - ctx.jobs.onJobDone((snapshot, owner) => { validateSnapshot(snapshot, owner, fail) }) + for (const job of ctx.jobs.visibleTo().list()) validateView(job, fail) + ctx.jobs.events.subscribe({ owners: 'all' }, (event) => { + if (event.type === 'settled') validateView(event.job, fail) + }) }, { inject: ['jobs'] }) /** diff --git a/packages/jobs/jobs/src/pump.ts b/packages/jobs/jobs/src/pump.ts deleted file mode 100644 index a3c1956019..0000000000 --- a/packages/jobs/jobs/src/pump.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Shared poll pump for producers whose output substrate is a non-consuming - * offset reader (the subprocess `readFrom(fromByte)` family). Pure utility — - * no cordis, no timers retained past settlement. - * @module - */ - -import type { JobChannel, RecordingJob } from './types.ts' - -/** One non-consuming offset read from a producer-owned stream. */ -export interface JobPumpRead { - /** Text captured since the requested offset (the whole retained tail when lossy). */ - text: string - /** Whole-stream offset to resume from on the next read. */ - nextOffset: number - /** True when the requested offset slid out of the source's retained window. */ - lossy: boolean -} - -/** - * One producer-owned stream the pump copies into a job record. - * - * The source is a pull-by-offset snapshot reader, not a Node.js `Readable`, - * because the substrate it reads (the subprocess `readFrom` family) is a - * retained ring that several readers share: the model-facing consuming - * cursor, the foreground result collector, and this record pump each hold - * their own absolute offset and read without disturbing the others. A - * `Readable` is single-consumer and consuming, so wrapping the ring in one - * would need a tee per reader and could not express `lossy` — the reader's - * offset having slid out of the retained window — which the record turns - * into a visible `gapBefore` instead of a silent splice. - */ -export interface JobPumpSource { - /** Stream label attached to every chunk this source yields. */ - channel?: JobChannel - /** - * Read everything captured since `fromByte` without consuming it. - * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). - * @returns the delta text, the next offset, and the lossy flag. - */ - read(fromByte: number): JobPumpRead -} - -/** Cadence and settlement for one {@link pumpJobOutput} run. */ -export interface JobPumpOptions { - /** Poll interval in milliseconds; a positive finite number. */ - pollMs: number - /** - * Settles when the producer's work finished and its sources hold their - * final bytes. The pump drains once more after settlement and returns; - * a rejection is treated as settlement. - */ - done: Promise -} - -/** - * Copy producer streams into a job record at a bounded cadence: drain every - * source, sleep `pollMs` or until `done` settles, repeat, then drain one - * final time. A lossy source read appends its surviving tail with - * `gapBefore`, so the discontinuity stays visible to observers. Fold the - * returned promise into the producer's `JobHooks.done` chain so the final - * drain lands before settlement trims and closes the record. - * - * Each round drains the sources in array order, so bytes two sources - * produced inside one poll window land in that order, not in the order they - * were written; the record is a best-effort live view whose cross-source - * reordering is bounded by `pollMs`, not a terminal transcript. - * - * The wait holds constant resources however long the job runs: one - * subscription on `done` for the whole run and one pending timer at a time. - * @param job - the running job's producer face receiving the copied chunks. - * @param sources - producer streams, each pumped at its own offset. - * @param options - poll cadence and the producer's settlement promise. - * @returns resolves after the final post-settlement drain. - */ -export async function pumpJobOutput( - job: RecordingJob, - sources: readonly JobPumpSource[], - options: JobPumpOptions, -): Promise { - if (!Number.isFinite(options.pollMs) || options.pollMs <= 0) { - throw new Error(`invalid pump pollMs: expected a positive finite number of milliseconds, got ${JSON.stringify(options.pollMs)}`) - } - const states = sources.map(source => ({ source, cursor: 0 })) - const drain = (): void => { - for (const state of states) { - const { source } = state - const read = source.read(state.cursor) - state.cursor = read.nextOffset - if (read.text.length === 0) continue - if (source.channel === undefined && !read.lossy) { - job.append(read.text) - } else { - job.append(read.text, { - ...source.channel !== undefined ? { channel: source.channel } : {}, - ...read.lossy ? { gapBefore: true as const } : {}, - }) - } - } - } - let settled = false - let timer: ReturnType | undefined - let wake: (() => void) | undefined - const finish = (): void => { - settled = true - // Clear the pending poll so a finished pump holds no timer for up to one - // interval, and release the current wait so the final drain runs at once. - if (timer !== undefined) clearTimeout(timer) - wake?.() - } - void options.done.then(finish, finish) - while (!settled) { - drain() - await new Promise((resolve) => { - wake = resolve - timer = setTimeout(resolve, options.pollMs) - }) - wake = undefined - timer = undefined - } - drain() -} diff --git a/packages/jobs/jobs/src/types.ts b/packages/jobs/jobs/src/types.ts index 3ec3f87357..0f46ff1715 100644 --- a/packages/jobs/jobs/src/types.ts +++ b/packages/jobs/jobs/src/types.ts @@ -1,154 +1,98 @@ /** - * Types shared by job producers, the registry, and controllers. The - * service implementation lives in `./index.ts`. + * Types shared by job producers, the registry, and its consumers. The + * client-safe projection vocabulary lives in `./view.ts`; the service + * implementation lives in `./index.ts`. * @module @deepseek-ai/dsh-jobs/types */ -import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import type { JobId } from './brand.ts' +import type { JobChannel, JobChunk, JobKind, JobView } from './view.ts' export { JobId } from './brand.ts' - -/** - * Task lifecycle: `running`, optionally `stopping`, then exactly one terminal - * status. Producer-specific facts belong in {@link JobSnapshot.detail}. - */ -export type JobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed' - -/** - * Producer-defined job kinds. Plugins extend this map by declaration merging; - * the registry treats every value as an opaque id namespace. - */ -export interface JobKindMap { - bash: 'bash' - subagent: 'subagent' -} - -/** The merge-extensible union of registered producer kind names. */ -export type JobKind = JobKindMap[keyof JobKindMap] +export type { JobChannel, JobChunk, JobKind, JobKindMap, JobStatus, JobView } from './view.ts' /** Terminal result supplied by a producer through {@link JobHooks.done}. */ export interface JobOutcome { /** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ status: 'completed' | 'killed' | 'failed' - /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */ + /** + * Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`). + * When the job settles `killed` after a {@link VisibleJobs.kill} with a + * reason, the registry appends that reason. + */ detail?: string - /** Final output for jobs without `readOutput`; stream jobs leave it unset. */ - output?: string + /** + * Return value for jobs whose result is a value rather than a stream (a + * workflow's rendered result, a subagent's report). The output ring carries + * the stream; this is handed out once by the model's next {@link VisibleJobs.read}. + */ + result?: string +} + +/** One incremental read from a {@link JobOutputSource}. */ +export interface JobSourceRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean } /** - * Record-stream label. Producers without distinct streams omit it; consumers - * treat an unrecognized label like an absent one. + * A pull source the registry pumps into the job's output ring — the subprocess + * `readFrom` family. The registry owns the cadence and drains every source one + * last time before settlement closes the ring, so a producer folds nothing + * into its `done`. */ -export type JobChannel = 'stdout' | 'stderr' +export interface JobOutputSource { + /** Stream label attached to every chunk this source yields. */ + channel?: JobChannel + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, and the lossy flag. + */ + read(fromByte: number): JobSourceRead +} -/** Options for one {@link RunningJob.append}. */ +/** Options for one {@link JobHandle.append}. */ export interface JobAppendOptions { /** Stream label for the chunk; omitted for producers without distinct streams. */ channel?: JobChannel /** * Marks that producer-side bytes between the previous chunk and this one - * were lost (a source window slid before the producer could copy it), so an - * observer can render the discontinuity instead of a silent splice. + * were lost, so an observer can render the discontinuity instead of a + * silent splice. */ gapBefore?: true } -/** Fields shared by every producer declaration passed to {@link JobRegistry.start}. */ -interface JobStartBase { - /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ - kind: JobKind - /** One-line model-facing label (the command; the delegation description). */ - label: string - /** - * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. Independent of record - * retention: it bounds the consuming model surface, never - * {@link JobRegistry.readRecord}. - */ - outputLimitBytes?: number - /** - * Owning live agent. Access is fenced by its session id, and agent disposal - * cancels and awaits the job. The instance must be the one currently - * registered under its agent id. Omitting the owner creates an unowned job, - * open to any caller until service disposal. - */ - owner?: Agent -} - /** - * A start with the model-facing surfaces only. Its producer face carries no - * `append`, so output the registry would have nowhere to keep is - * unrepresentable rather than dropped. + * Producer face of one registered job, handed to {@link JobSpec.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — writes log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. */ -export interface PlainJobStart extends JobStartBase { - record?: undefined - /** - * Start the work after preflight and synchronously return its hooks. Called - * once with the job's producer face; a throw leaves nothing registered (the - * spent ordinal is skipped), and the producer must clean up any partially - * started resources. - * @param job - the issued id plus the live-detail writer. - */ - run(job: RunningJob): JobHooks -} - -/** - * A start that declares an observable output record beside the model-facing - * surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that - * any number of observers read at absolute byte offsets through - * {@link JobRegistry.readRecord}, and snapshots carry - * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. - */ -export interface RecordingJobStart extends JobStartBase { - record: true - /** - * Start the work after preflight and synchronously return its hooks. Called - * once with the job's producer face; a throw leaves nothing registered (the - * spent ordinal is skipped), and the producer must clean up any partially - * started resources. - * @param job - the issued id plus the record append and live-detail writers. - */ - run(job: RecordingJob): JobHooks -} - -/** - * Producer declaration passed to {@link JobRegistry.start}, discriminated by - * `record`. The runtime preflights access and cleanup before invoking `run`; - * the producer owns execution resources while the runtime owns identity and - * lifecycle state. - */ -export type JobStart = PlainJobStart | RecordingJobStart - -/** - * Producer face of one registered job, handed to `run` and valid for the - * job's whole life. All methods are synchronous. Writes staged inside the - * starter call are retained and become visible with the registration commit; - * after settlement — the producer's own outcome, a kill, or a registry-forced - * teardown end — writes log and drop instead of throwing, so a producer's - * trailing flush cannot break its own teardown path. - */ -export interface RunningJob { +export interface JobHandle { /** The registry-issued id (`-N`). */ readonly id: JobId /** - * Replace the snapshot's status detail with a live progress line (`3/10`). - * @param detail - the new detail line. - */ - updateDetail(detail: string): void -} - -/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */ -export interface RecordingJob extends RunningJob { - /** - * Append one record chunk. Offsets advance by the chunk's UTF-8 byte - * length; an empty chunk is dropped without waking observers. + * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 + * byte length; an empty chunk is dropped without waking observers. * @param text - the chunk text, exactly as produced. * @param options - stream label and gap marker. */ append(text: string, options?: JobAppendOptions): void + /** + * Replace the live progress line (`3/10`, the current phase). Settlement + * clears it; the terminal reason travels in {@link JobOutcome.detail}. + * @param line - the new progress line. + */ + updateProgress(line: string): void } /** Hooks through which the runtime controls and observes producer work. */ @@ -162,104 +106,82 @@ export interface JobHooks { * Resolves after the producer releases its resources, not merely when work * finishes. Must not reject; the runtime converts a rejection to `failed`. * If teardown cancellation throws, the runtime may force-fail only the - * registry record without claiming that the work stopped. Settlement also - * ends the record: fold any record-filling pump into this promise so the - * final drain lands before the registry trims and closes the stream. + * registry record without claiming that the work stopped. */ done: Promise - /** - * Consume output produced since the previous call. The producer formats - * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. Independent of the record: this is the - * model-facing projection, {@link JobRegistry.readRecord} the observer one. - */ - readOutput?(): string } /** - * A read-only projection of one job, safe to hand to listeners and tools — - * a fresh object per call, never live registry state. + * Producer declaration passed to {@link JobRegistry.start}. The runtime + * preflights access and cleanup before invoking {@link run}; the producer owns + * execution resources while the runtime owns identity, lifecycle state, and + * the output ring. */ -export interface JobSnapshot { - /** The registry-issued id (`-N`). */ - id: JobId - /** The producer kind the job was registered with. */ +export interface JobSpec { + /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ kind: JobKind - /** The producer-supplied one-line label. */ + /** One-line model-facing label (the command; the delegation description). */ label: string - /** Producer-owned cap for complete model-facing notices and output reads. */ + /** + * Owning session. Access is fenced by it, and the owner's live Agent must be + * the one currently registered under that id: its disposal cancels and + * awaits the job. Omitting the owner creates an unowned job, open to any + * caller until service disposal. + */ + owner?: SessionId + /** + * Optional UTF-8 byte cap for each complete model-facing completion notice or + * output read, including controller status metadata. Independent of ring + * retention: it bounds the consuming model surface, never observers. + */ outputLimitBytes?: number /** - * Owner session id used for authorization and correlation; absent for - * unowned jobs. Completion listeners receive the exact {@link Agent} - * separately through {@link JobDoneListener}. + * Pull sources the registry pumps into the ring at its own cadence. + * Producers that narrate their own progress use {@link JobHandle.append} + * instead; a job may use both. */ - ownerSession?: SessionId - /** Current lifecycle state. */ - status: JobStatus + output?: readonly JobOutputSource[] /** - * Kind-specific status detail: live progress while the producer updates it - * through {@link RunningJob.updateDetail}, the terminal detail once settled. + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the ring append and progress writers. */ - detail?: string - /** Epoch ms when the job was registered. */ - startedAt: number - /** Epoch ms when the job settled; absent while `running`/`stopping`. */ - finishedAt?: number - /** - * True when a kill, read, wait, or teardown cancel has reported or committed - * to report the terminal state. Completion reporters suppress redundant - * notices when set. Teardown claims it because the owner or service being - * destroyed leaves no reader: a reporter that opens a turn on notice would - * otherwise spend a model request per teardown layer. - */ - reported: boolean - /** - * Total UTF-8 bytes ever appended to the record — the offset the next chunk - * starts at. Present exactly when the job declared {@link JobStart.record}. - */ - outputTotal?: number - /** - * Offset of the oldest retained record byte. Greater than zero exactly when - * retention dropped the head; a reader starting below it gets a lossy read. - * Present exactly when the job declared {@link JobStart.record}. - */ - outputEarliest?: number + run(job: JobHandle): JobHooks } -/** Output and post-read state returned by {@link JobRegistry.read}. */ +/** Options for {@link VisibleJobs.kill}. */ +export interface JobKillOptions { + /** + * Cancellation reason: forwarded verbatim to the producer's cancel hook, and + * merged into the terminal `detail` when the job settles `killed`, so both + * the model and observers can see why the work stopped. + */ + reason?: string +} + +/** Output and post-read state returned by the consuming {@link VisibleJobs.read}. */ export interface JobRead { - /** - * Stream kinds: the consuming delta since the previous read. Final-output - * kinds: empty while live, the terminal {@link JobOutcome.output} (or - * empty) once settled — idempotent, never consumed. - */ - text: string + /** Ring chunks appended since the model cursor, in offset order; every channel included. */ + chunks: readonly JobChunk[] + /** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean + /** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */ + result?: string /** The job's state at read time. */ - snapshot: JobSnapshot + job: JobView } -/** One retained record chunk returned by {@link JobRegistry.readRecord}. */ -export interface JobRecordChunk { - /** Absolute offset of the chunk's first byte. */ - at: number - /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ - text: string - /** Stream label, when the producer supplied one. */ - channel?: JobChannel - /** Producer-reported loss immediately before this chunk. */ - gapBefore?: true -} - -/** Result of one non-consuming {@link JobRegistry.readRecord}. */ -export interface JobRecordRead { +/** Result of one non-consuming {@link VisibleJobs.readAt}. */ +export interface JobOutputRead { /** Retained chunks overlapping `[from, total)`, in offset order. */ - chunks: readonly JobRecordChunk[] + chunks: readonly JobChunk[] /** - * Offset to resume from — the record's current `outputTotal`. Always a - * chunk boundary: appends land whole and trimming only advances chunk - * starts, and consumers concatenate `chunks` under that assumption, so a - * provider serving partial chunks would silently duplicate text. + * Offset to resume from — the ring's current `total`. Always a chunk + * boundary: appends land whole and trimming only advances chunk starts, and + * consumers concatenate `chunks` under that assumption, so a provider + * serving partial chunks would silently duplicate text. */ next: number /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ @@ -267,28 +189,118 @@ export interface JobRecordRead { } /** - * Completion callback with the exact owner supplied at start, or `undefined` - * for an unowned job. Returned promises are observed but not awaited. + * The operations one caller may perform, bound by {@link JobRegistry.visibleTo}. + * A caller sees its own jobs and every unowned job; every method throws for + * an id outside that set, without distinguishing unknown from foreign. */ -export type JobDoneListener = ( - snapshot: JobSnapshot, - owner: Agent | undefined, -) => void | PromiseLike +export interface VisibleJobs { + /** + * List the visible jobs in registration order. + * @returns fresh projections. + */ + list(): JobView[] + /** + * Project one job without touching its cursor. + * @param id - job to look up. + * @returns a fresh projection. + */ + get(id: JobId): JobView + /** + * Consume the ring from the model cursor and advance it to the current + * total. After settlement the first read also carries the producer's + * result; later reads return only what was appended since. + * @param id - job to read. + * @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection. + */ + read(id: JobId): JobRead + /** + * Read retained ring output from an absolute byte offset without consuming + * it. Resume by passing a previous read's `next`; an offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Never moves the model cursor. Throws for a negative or non-integer offset. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ + readAt(id: JobId, from: number): JobOutputRead + /** + * Request cancellation, then mark the job stopping. A recorded + * {@link JobKillOptions.reason} merges into the terminal `detail` when the + * job settles `killed`. A producer throw propagates without changing job + * state. + * @param id - job to cancel. + * @param options - cancellation reason. + * @returns `requested` for live work, otherwise `already-finished`. + */ + kill(id: JobId, options?: JobKillOptions): 'requested' | 'already-finished' + /** + * Wait for settlement or timeout without cancelling the job. Caller abort + * rejects only while the job is live; after settlement the terminal + * projection wins. Throws for an invalid timeout. + * @param id - job to wait for. + * @param timeoutMs - positive finite wait bound in milliseconds. + * @param signal - optional cancellation of the wait itself. + * @returns projection at settlement or timeout. + */ + wait(id: JobId, timeoutMs: number, signal?: AbortSignal): Promise +} /** - * Observation callback for a change to what one owner's {@link JobRegistry.list} - * would return. It is owner-granular rather than job-granular because the - * change may be a removal, which no per-job record can express, and because - * its consumers re-read the whole visible set anyway. - * - * An `undefined` owner means an unowned job changed, so every caller's visible - * set changed with it. + * Why a job settled: its producer finished (`producer`), a + * {@link VisibleJobs.kill} ran first (`kill`), or owner or service teardown + * cancelled it (`teardown`) — a settlement whose owner has no reader left. */ -export type JobsChangedListener = (owner: Agent | undefined) => void +export type JobSettleCause = 'producer' | 'kill' | 'teardown' /** - * Observer of record advancement for one job: new appended output, or the - * settlement that ends the stream. Carries no payload — a consumer schedules a - * {@link JobRegistry.readRecord} from its own cursor. + * One lifecycle or output event. Lifecycle events carry the job's projection + * after the commit they announce; `output` carries only the id and the new + * total, so an observer schedules a {@link VisibleJobs.readAt} from its own + * cursor and the registry never pushes payloads. */ -export type JobOutputListener = (id: JobId) => void +export type JobEvent = + | { + /** Registration commit, progress line change, stopping transition, or removal from the visible set. */ + readonly type: 'registered' | 'progress' | 'stopping' | 'removed' + readonly job: JobView + } + | { + readonly type: 'settled' + readonly job: JobView + readonly cause: JobSettleCause + } + | { + readonly type: 'output' + readonly id: JobId + /** Owning session, absent for an unowned job. */ + readonly owner?: SessionId + /** The ring's total after the append (or at settlement, which ends the stream). */ + readonly total: number + } + +/** + * Who a subscription hears about. `{ owner }` delivers that session's jobs + * plus every unowned job (the set that session can see). `{ owners: 'scope' }` + * delivers the owners composed under the subscribing context — one registry + * serves every composition in the process, and a mount under one preset must + * not hear another preset's agents. `{ owners: 'all' }` delivers everything. + */ +export type JobEventFilter = + | { readonly owner: SessionId } + | { readonly owners: 'all' | 'scope' } + +/** Event callback; contained by the registry, never awaited. */ +export type JobEventListener = (event: JobEvent) => void + +/** The registry's event stream. */ +export interface JobEvents { + /** + * Register an effect-scoped listener. Events are dispatched synchronously + * after the commit they announce; a `settled` event follows the release of + * every waiter. No listener runs after service disposal. + * @param filter - which owners' events to deliver. + * @param listener - receives each matching event. + * @returns disposer that unregisters the listener. + */ + subscribe(filter: JobEventFilter, listener: JobEventListener): () => void +} diff --git a/packages/jobs/jobs/src/view.ts b/packages/jobs/jobs/src/view.ts new file mode 100644 index 0000000000..033fcfbe76 --- /dev/null +++ b/packages/jobs/jobs/src/view.ts @@ -0,0 +1,81 @@ +/** + * Client-safe job vocabulary: the read-only projection of one job and the + * chunks its output ring hands out. This leaf reaches no Host package, so + * browser programs and Remote wire types import it without pulling the + * registry's Host declaration merges. + * @module @deepseek-ai/dsh-jobs/view + */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { JobId } from './brand.ts' + +/** + * Job lifecycle: `running`, optionally `stopping`, then exactly one terminal + * status. Producer-specific facts belong in {@link JobView.progress} and + * {@link JobView.detail}. + */ +export type JobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + +/** + * Producer-defined job kinds. Plugins extend this map by declaration merging; + * the registry treats every value as an opaque id namespace. + */ +export interface JobKindMap { + bash: 'bash' + subagent: 'subagent' +} + +/** The merge-extensible union of registered producer kind names. */ +export type JobKind = JobKindMap[keyof JobKindMap] + +/** + * Stream label of one output chunk. `stdout` and `stderr` reach the model's + * consuming read; `log` marks producer narration (a workflow's phase lines) + * that only observers see. Consumers treat an unrecognized label like an + * absent one. + */ +export type JobChannel = 'stdout' | 'stderr' | 'log' + +/** One chunk of a job's output ring: absolute offset, text, and its provenance. */ +export interface JobChunk { + /** Absolute offset of the chunk's first byte; offsets never move once assigned. */ + readonly at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + readonly text: string + /** Stream label, when the producer supplied one. */ + readonly channel?: JobChannel + /** Bytes immediately before this chunk were lost, at the producer or to retention. */ + readonly gapBefore?: true +} + +/** + * Read-only projection of one job — a fresh object per call, never live + * registry state. The model tools, the browser roster, and the observation + * stream all consume this one shape. + */ +export interface JobView { + /** The registry-issued id (`-N`). */ + readonly id: JobId + /** The producer kind the job was registered with. */ + readonly kind: JobKind + /** The producer-supplied one-line label. */ + readonly label: string + /** Owning session; absent for an unowned job, which every caller can see. */ + readonly owner?: SessionId + /** Current lifecycle state. */ + readonly status: JobStatus + /** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */ + readonly progress?: string + /** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */ + readonly detail?: string + /** Epoch ms when the job was registered. */ + readonly startedAt: number + /** Epoch ms when the job settled; absent while live. */ + readonly finishedAt?: number + /** + * The output ring's absolute coordinates. `total` is the offset the next + * chunk starts at (0 while nothing was written); `earliest` is the oldest + * retained byte, greater than zero exactly when retention dropped the head. + */ + readonly output: { readonly total: number; readonly earliest: number } +} diff --git a/packages/jobs/jobs/tests/invariant.spec.ts b/packages/jobs/jobs/tests/invariant.spec.ts index fa42eb6c74..12a6d48bee 100644 --- a/packages/jobs/jobs/tests/invariant.spec.ts +++ b/packages/jobs/jobs/tests/invariant.spec.ts @@ -1,48 +1,49 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import JobRegistry, { JobId } from '@deepseek-ai/dsh-jobs' -import type { JobDoneListener, JobSnapshot } from '@deepseek-ai/dsh-jobs' +import type { JobEventListener, JobView } from '@deepseek-ai/dsh-jobs' import * as JobsInvariant from '@deepseek-ai/dsh-jobs/invariant' import InvariantRegistry from '@deepseek-ai/dsh-invariants' -const BASE: JobSnapshot = { +const BASE: JobView = { id: JobId('bash-1'), kind: 'bash', label: 'compile', status: 'completed', startedAt: 10, finishedAt: 20, - reported: false, + output: { total: 0, earliest: 0 }, } -const RUNNING: JobSnapshot = { +const RUNNING: JobView = { id: JobId('bash-1'), kind: 'bash', label: 'compile', status: 'running', startedAt: 10, - reported: false, + output: { total: 0, earliest: 0 }, } -const TERMINAL_WITHOUT_FINISH: JobSnapshot = { +const TERMINAL_WITHOUT_FINISH: JobView = { id: JobId('bash-1'), kind: 'bash', label: 'compile', status: 'completed', startedAt: 10, - reported: false, + output: { total: 0, earliest: 0 }, } -async function setup(seed: JobSnapshot[] = []): Promise<(snapshot: unknown, owner?: Agent) => void> { +async function setup(seed: JobView[] = []): Promise<(job: unknown) => void> { const ctx = new Context() - let listener: JobDoneListener | undefined + let listener: JobEventListener | undefined const probe = { - list: () => seed, - onJobDone(value: JobDoneListener) { - listener = value - return () => { listener = undefined } + visibleTo: () => ({ list: () => seed }), + events: { + subscribe(_filter: unknown, value: JobEventListener) { + listener = value + return () => { listener = undefined } + }, }, } as unknown as JobRegistry await ctx.plugin(InvariantRegistry) @@ -51,44 +52,54 @@ async function setup(seed: JobSnapshot[] = []): Promise<(snapshot: unknown, owne apply(child: Context) { child.provide('jobs', probe) }, }) await ctx.plugin(JobsInvariant) - if (listener === undefined) throw new Error('job invariant did not subscribe to terminal snapshots') - return (snapshot, owner) => { listener!(snapshot as JobSnapshot, owner) } + if (listener === undefined) throw new Error('job invariant did not subscribe to settlements') + return (job) => { listener!({ type: 'settled', job: job as JobView, cause: 'producer' }) } } describe('job-registry invariants', () => { - it('accepts coherent current and terminal snapshots', async () => { + it('accepts coherent current and terminal projections', async () => { const notify = await setup([RUNNING]) expect(() => { notify(BASE) }).not.toThrow() - const owner = { id: SessionId('owner') } as Agent - expect(() => { notify({ ...BASE, id: JobId('subagent-2'), kind: 'subagent', ownerSession: owner.id }, owner) }) + expect(() => { notify({ ...BASE, id: JobId('subagent-2'), kind: 'subagent', owner: SessionId('owner') }) }) .not.toThrow() - expect(() => { notify({ ...BASE, outputTotal: 8, outputEarliest: 4 }) }).not.toThrow() + expect(() => { notify({ ...BASE, output: { total: 8, earliest: 4 } }) }).not.toThrow() + }) + + it('ignores non-settlement events', async () => { + const ctx = new Context() + let listener: JobEventListener | undefined + const probe = { + visibleTo: () => ({ list: () => [] }), + events: { subscribe(_filter: unknown, value: JobEventListener) { listener = value; return () => {} } }, + } as unknown as JobRegistry + await ctx.plugin(InvariantRegistry) + await ctx.plugin({ name: 'job-invariant-probe', apply(child: Context) { child.provide('jobs', probe) } }) + await ctx.plugin(JobsInvariant) + expect(() => { listener!({ type: 'registered', job: { ...BASE, label: '' } }) }).not.toThrow() }) it.each([ - [{ ...BASE, id: JobId('-1'), kind: '' }, undefined, /positive ordinal/], - [{ ...BASE, id: JobId('other-1') }, undefined, /must be "bash-" followed by a positive ordinal/], - [{ ...BASE, id: JobId('bash-x') }, undefined, /positive ordinal/], - [{ ...BASE, id: JobId('bash-0') }, undefined, /positive ordinal/], - [{ ...BASE, startedAt: -1 }, undefined, /startedAt must be a non-negative epoch integer/], - [{ ...BASE, startedAt: 0.5 }, undefined, /startedAt must be a non-negative epoch integer/], - [{ ...BASE, status: 'running' }, undefined, /finishedAt must be present exactly for a terminal status/], - [TERMINAL_WITHOUT_FINISH, undefined, /finishedAt must be present exactly for a terminal status/], - [{ ...BASE, finishedAt: 9 }, undefined, /no earlier than startedAt/], - [{ ...BASE, finishedAt: 20.5 }, undefined, /no earlier than startedAt/], - [{ ...BASE, ownerSession: SessionId('recorded') }, { id: SessionId('actual') } as Agent, /does not match its completion owner/], - [{ ...BASE, outputTotal: 8 }, undefined, /record offsets must be present together/], - [{ ...BASE, outputEarliest: 0 }, undefined, /record offsets must be present together/], - [{ ...BASE, outputTotal: 8, outputEarliest: 9 }, undefined, /0 <= outputEarliest <= outputTotal/], - [{ ...BASE, outputTotal: 8.5, outputEarliest: 0 }, undefined, /0 <= outputEarliest <= outputTotal/], - [{ ...BASE, outputTotal: 8, outputEarliest: 0.5 }, undefined, /0 <= outputEarliest <= outputTotal/], - [{ ...BASE, outputTotal: 8, outputEarliest: -1 }, undefined, /0 <= outputEarliest <= outputTotal/], - ] as const)('rejects an incoherent registry snapshot', async (snapshot, owner, message) => { + [{ ...BASE, id: JobId('-1'), kind: '' }, /positive ordinal/], + [{ ...BASE, id: JobId('other-1') }, /must be "bash-" followed by a positive ordinal/], + [{ ...BASE, id: JobId('bash-x') }, /positive ordinal/], + [{ ...BASE, id: JobId('bash-0') }, /positive ordinal/], + [{ ...BASE, startedAt: -1 }, /startedAt must be a non-negative epoch integer/], + [{ ...BASE, startedAt: 0.5 }, /startedAt must be a non-negative epoch integer/], + [{ ...BASE, status: 'running' }, /finishedAt must be present exactly for a terminal status/], + [TERMINAL_WITHOUT_FINISH, /finishedAt must be present exactly for a terminal status/], + [{ ...BASE, finishedAt: 9 }, /no earlier than startedAt/], + [{ ...BASE, finishedAt: 20.5 }, /no earlier than startedAt/], + [{ ...BASE, progress: '3/10' }, /progress must be cleared once settled/], + [{ ...BASE, output: { total: 8, earliest: 9 } }, /0 <= earliest <= total/], + [{ ...BASE, output: { total: 8.5, earliest: 0 } }, /0 <= earliest <= total/], + [{ ...BASE, output: { total: 8, earliest: 0.5 } }, /0 <= earliest <= total/], + [{ ...BASE, output: { total: 8, earliest: -1 } }, /0 <= earliest <= total/], + ] as const)('rejects an incoherent registry projection', async (job, message) => { const notify = await setup() - expect(() => { notify(snapshot, owner) }).toThrow(message) + expect(() => { notify(job) }).toThrow(message) }) - it('rejects an incoherent record already present at installation', async () => { + it('rejects an incoherent projection already present at installation', async () => { await expect(setup([{ ...BASE, label: '' }])).rejects.toThrow(/label must be non-empty/) }) }) diff --git a/packages/jobs/jobs/tests/service.spec.ts b/packages/jobs/jobs/tests/service.spec.ts index 331bf6596b..f3b456acbc 100644 --- a/packages/jobs/jobs/tests/service.spec.ts +++ b/packages/jobs/jobs/tests/service.spec.ts @@ -1,69 +1,60 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { JobId, JobRegistry, pumpJobOutput } from '@deepseek-ai/dsh-jobs' +import { SessionId } from '@deepseek-ai/dsh-session' +import { JobId, JobRegistry } from '@deepseek-ai/dsh-jobs' import type { - JobAppendOptions, JobDoneListener, JobHooks, JobOutputListener, JobPumpSource, JobRead, JobRecordRead, - JobSnapshot, JobStart, JobsChangedListener, PlainJobStart, RecordingJob, RecordingJobStart, + JobEvent, JobEventFilter, JobEventListener, JobHooks, JobOutputSource, JobRead, JobSpec, JobView, VisibleJobs, } from '@deepseek-ai/dsh-jobs' +/** Compile-time probe: does `Shape` carry a `Key` member? */ +type HasKey = Key extends keyof Shape ? true : false + /** - * Minimal concrete registry: one canned record. The Service Definition owns the contract - * only (ids, snapshots, authorization-shaped signatures); the registry + * Minimal concrete registry: one canned row. The Service Definition owns the + * contract only (ids, the caller-bound view, the event stream); the registry * behavior suite lives with `@deepseek-ai/dsh-jobs-local`. */ class StubJobRegistry extends JobRegistry { - snapshotOf(id: JobId): JobSnapshot { + readonly subscriptions: { filter: JobEventFilter; listener: JobEventListener }[] = [] + readonly started: JobSpec[] = [] + + readonly events = { + subscribe: (filter: JobEventFilter, listener: JobEventListener): (() => void) => { + const entry = { filter, listener } + this.subscriptions.push(entry) + return () => { this.subscriptions.splice(this.subscriptions.indexOf(entry), 1) } + }, + } + + private view(id: JobId, owner: SessionId | undefined): JobView { return { id, kind: 'bash', label: 'sleep 60', + ...owner !== undefined ? { owner } : {}, status: 'running', startedAt: 0, - reported: false, + output: { total: 0, earliest: 0 }, } } - start(spec: JobStart): JobId { + start(spec: JobSpec): JobId { + this.started.push(spec) const id = JobId(`${spec.kind}-1`) - spec.run({ id, append() {}, updateDetail() {} }) + spec.run({ id, append() {}, updateProgress() {} }) return id } - list(): JobSnapshot[] { - return [this.snapshotOf(JobId('bash-1'))] - } - - get(id: JobId): JobSnapshot { - return this.snapshotOf(id) - } - - read(id: JobId): JobRead { - return { text: '', snapshot: this.snapshotOf(id) } - } - - kill(): 'requested' | 'already-finished' { - return 'requested' - } - - wait(id: JobId, _timeoutMs: number, _caller?: Agent, _signal?: AbortSignal): Promise { - return Promise.resolve(this.snapshotOf(id)) - } - - onJobDone(_listener: JobDoneListener): () => void { - return () => {} - } - - onJobsChanged(_listener: JobsChangedListener): () => void { - return () => {} - } - - readRecord(_id: JobId, from: number): JobRecordRead { - return { chunks: [], next: from, lossy: false } - } - - onOutput(_listener: JobOutputListener): () => void { - return () => {} + visibleTo(caller?: SessionId): VisibleJobs { + const view = (id: JobId): JobView => this.view(id, caller) + return { + list: () => [view(JobId('bash-1'))], + get: id => view(id), + read: (id): JobRead => ({ chunks: [], lossy: false, job: view(id) }), + readAt: (_id, from) => ({ chunks: [], next: from, lossy: false }), + kill: () => 'requested', + wait: id => Promise.resolve(view(id)), + } } attachController(_name: string): () => void { @@ -71,63 +62,58 @@ class StubJobRegistry extends JobRegistry { } } -/** Compile-time probe: does the producer face `Face` carry `append`? */ -type HasAppend = 'append' extends keyof Face ? true : false - -describe('JobStart producer faces', () => { - it('hands append only to a start that declares a record', () => { - const hooks: JobHooks = { cancel() {}, done: new Promise(() => {}) } - // The literal's `record` discriminant selects the face `run` receives. - const plain: JobStart = { - kind: 'bash', - label: 'no record', - run: (job) => { - job.updateDetail('works for every job') - return hooks - }, - } - const recording: JobStart = { - kind: 'bash', - label: 'record', - record: true, - run: (job) => { - job.append('lands in the record') - return hooks - }, - } - // Negative proof: a plain start's face has no `append` at the type level. - const plainFace: HasAppend[0]> = false - const recordingFace: HasAppend[0]> = true - expect(plainFace).toBe(false) - expect(recordingFace).toBe(true) - expect(plain.record).toBeUndefined() - expect(recording.record).toBe(true) - }) -}) - describe('JobRegistry seam', () => { it('a concrete subclass registers as ctx.jobs and serves the abstract API', async () => { const ctx = new Context() await ctx.plugin(StubJobRegistry) + const caller = SessionId('session-1') + const hooks: JobHooks = { cancel() {}, done: new Promise(() => {}) } const detachController = ctx.jobs.attachController('seam-test') - const id = ctx.jobs.start({ kind: 'bash', label: 'sleep 60', run: () => ({ cancel() {}, done: new Promise(() => {}) }) }) + const id = ctx.jobs.start({ kind: 'bash', label: 'sleep 60', owner: caller, run: () => hooks }) expect(id).toBe('bash-1') - expect(ctx.jobs.list()).toHaveLength(1) - expect(ctx.jobs.get(id).status).toBe('running') - expect(ctx.jobs.read(id).text).toBe('') - expect(ctx.jobs.kill(id)).toBe('requested') - await expect(ctx.jobs.wait(id, 5)).resolves.toMatchObject({ id }) - expect(ctx.jobs.readRecord(id, 0)).toEqual({ chunks: [], next: 0, lossy: false }) - const detachListener = ctx.jobs.onJobDone(() => {}) - detachListener() - const detachChanges = ctx.jobs.onJobsChanged(() => {}) - detachChanges() - const detachOutput = ctx.jobs.onOutput(() => {}) - detachOutput() + + const jobs = ctx.jobs.visibleTo(caller) + expect(jobs.list()).toHaveLength(1) + expect(jobs.get(id).status).toBe('running') + expect(jobs.get(id).owner).toBe(caller) + expect(jobs.read(id)).toEqual({ chunks: [], lossy: false, job: expect.objectContaining({ id }) }) + expect(jobs.readAt(id, 7)).toEqual({ chunks: [], next: 7, lossy: false }) + expect(jobs.kill(id, { reason: 'seam test' })).toBe('requested') + await expect(jobs.wait(id, 5)).resolves.toMatchObject({ id }) + // A caller-less view is the unowned-only view. + expect(ctx.jobs.visibleTo().get(id).owner).toBeUndefined() + + const events: JobEvent[] = [] + const unsubscribe = ctx.jobs.events.subscribe({ owners: 'all' }, (event) => { events.push(event) }) + unsubscribe() detachController() }) + it('types every start the same way: one handle, pull sources optional', () => { + const hooks: JobHooks = { cancel() {}, done: new Promise(() => {}) } + const source: JobOutputSource = { + channel: 'stdout', + read: fromByte => ({ text: '', nextOffset: fromByte, lossy: false }), + } + const spec: JobSpec = { + kind: 'bash', + label: 'x', + output: [source], + run: (job) => { + job.append('lands in the ring', { channel: 'log' }) + job.updateProgress('1/2') + return hooks + }, + } + expect(spec.output).toHaveLength(1) + // Bookkeeping never reaches the public view, and the record declaration is gone. + const viewHasReported: HasKey = false + const specHasRecord: HasKey = false + const hooksHaveReadOutput: HasKey = false + expect([viewHasReported, specHasRecord, hooksHaveReadOutput]).toEqual([false, false, false]) + }) + it('loading a second implementation throws (one jobs service per context — cordis standard)', async () => { const ctx = new Context() await ctx.plugin(StubJobRegistry) @@ -141,119 +127,3 @@ describe('JobRegistry seam', () => { .rejects.toThrow(/abstract job registry seam; load an implementation such as @deepseek-ai\/dsh-jobs-local/) }) }) - -/** Record every append so pump behavior is observable without a registry. */ -function recordingJob(): { job: RecordingJob; appends: { text: string; options?: JobAppendOptions }[] } { - const appends: { text: string; options?: JobAppendOptions }[] = [] - const job: RecordingJob = { - id: JobId('bash-1'), - append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) }, - updateDetail() {}, - } - return { job, appends } -} - -/** A scripted source: each read() shifts the next scripted result. */ -function scriptedSource( - reads: { text: string; lossy?: boolean }[], - channel?: 'stdout' | 'stderr', -): { source: JobPumpSource; offsets: number[] } { - const offsets: number[] = [] - let offset = 0 - const source: JobPumpSource = { - ...channel !== undefined ? { channel } : {}, - read(fromByte) { - offsets.push(fromByte) - const next = reads.shift() ?? { text: '' } - offset += Buffer.byteLength(next.text, 'utf8') - return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false } - }, - } - return { source, offsets } -} - -describe('pumpJobOutput', () => { - it('rejects a non-positive poll interval before touching any source', async () => { - const { job } = recordingJob() - await expect(pumpJobOutput(job, [], { pollMs: 0, done: Promise.resolve() })) - .rejects.toThrow(/invalid pump pollMs/) - }) - - it('copies labeled deltas at the poll cadence and resumes each source at its own offset', async () => { - vi.useFakeTimers() - try { - const { job, appends } = recordingJob() - let settle!: () => void - const done = new Promise((resolve) => { settle = resolve }) - const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout') - const err = scriptedSource([{ text: '' }, { text: 'E' }], 'stderr') - const pump = pumpJobOutput(job, [out.source, err.source], { pollMs: 50, done }) - - await vi.advanceTimersByTimeAsync(50) - settle() - await pump - expect(appends).toEqual([ - { text: 'a', options: { channel: 'stdout' } }, - { text: 'bc', options: { channel: 'stdout' } }, - { text: 'E', options: { channel: 'stderr' } }, - ]) - // Every read resumed from the previous nextOffset, never from a shared cursor. - expect(out.offsets).toEqual([0, 1, 3]) - expect(err.offsets).toEqual([0, 0, 1]) - // Settlement cleared the raced poll timer instead of leaving it pending. - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('subscribes to done once and holds one poll timer however long the job runs', async () => { - vi.useFakeTimers() - try { - const { job } = recordingJob() - // A thenable stands in for the producer's done promise, and whatever the - // pump derives from it counts its own reactions too: a pending promise - // retains every reaction until it settles, so a day-long job must not - // gain one per poll round. - let subscriptions = 0 - let settle!: () => void - const settled = new Promise((resolve) => { settle = resolve }) - const counting = (promise: Promise): Promise => ({ - then(onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown) { - subscriptions += 1 - return counting(promise.then(onFulfilled, onRejected)) - }, - }) as unknown as Promise - const done = counting(settled) - const { source, offsets } = scriptedSource([]) - const pump = pumpJobOutput(job, [source], { pollMs: 50, done }) - - const rounds = 10_000 - await vi.advanceTimersByTimeAsync(50 * rounds) - expect(offsets).toHaveLength(rounds + 1) - expect(subscriptions).toBeLessThanOrEqual(2) - expect(vi.getTimerCount()).toBe(1) - - settle() - await pump - expect(offsets).toHaveLength(rounds + 2) - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('marks a lossy source read as a gap so observers see the discontinuity', async () => { - const { job, appends } = recordingJob() - const { source } = scriptedSource([{ text: 'tail', lossy: true }]) - await pumpJobOutput(job, [source], { pollMs: 1, done: Promise.resolve() }) - expect(appends).toEqual([{ text: 'tail', options: { gapBefore: true } }]) - }) - - it('treats a rejected done as settlement and still drains the final bytes', async () => { - const { job, appends } = recordingJob() - const { source } = scriptedSource([{ text: 'last' }]) - await pumpJobOutput(job, [source], { pollMs: 1, done: Promise.reject(new Error('producer broke')) }) - expect(appends).toEqual([{ text: 'last' }]) - }) -}) diff --git a/tsconfig.base.json b/tsconfig.base.json index bfdee3acfb..97ea6bcf0c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -102,6 +102,7 @@ "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], "@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"], + "@deepseek-ai/dsh-jobs/view": ["./packages/jobs/jobs/src/view.ts"], "@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"], "@deepseek-ai/dsh-authorization/types": ["./packages/credentials/authorization/src/types.ts"], "@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"], From 1b97207150ca8dc36cf6814aba8edeaec4f9f7ca Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 15:53:59 +0800 Subject: [PATCH 028/284] refactor(jobs-local): extract the output ring and the registry-owned pump --- packages/jobs/jobs-local/src/pump.ts | 85 +++++++++++++++ packages/jobs/jobs-local/src/ring.ts | 113 ++++++++++++++++++++ packages/jobs/jobs-local/tests/pump.spec.ts | 111 +++++++++++++++++++ packages/jobs/jobs-local/tests/ring.spec.ts | 65 +++++++++++ 4 files changed, 374 insertions(+) create mode 100644 packages/jobs/jobs-local/src/pump.ts create mode 100644 packages/jobs/jobs-local/src/ring.ts create mode 100644 packages/jobs/jobs-local/tests/pump.spec.ts create mode 100644 packages/jobs/jobs-local/tests/ring.spec.ts diff --git a/packages/jobs/jobs-local/src/pump.ts b/packages/jobs/jobs-local/src/pump.ts new file mode 100644 index 0000000000..e57cb02a09 --- /dev/null +++ b/packages/jobs/jobs-local/src/pump.ts @@ -0,0 +1,85 @@ +/** + * The registry-owned pull pump: copies a job's {@link JobOutputSource}s into + * its ring at a bounded cadence and drains them once more after the + * producer settles, so the ring holds every byte before settlement trims and + * closes it. Pure utility — no cordis, no timers retained past settlement. + * @module @deepseek-ai/dsh-jobs-local/pump + */ + +import type { JobAppendOptions, JobOutputSource } from '@deepseek-ai/dsh-jobs' + +/** One pump run; `done` resolves after the final post-settlement drain. */ +export interface PumpHandle { + done: Promise +} + +/** + * Drain every source in array order, sleep `pollMs` or until `until` + * settles, repeat, then drain one final time. A lossy source read appends + * its surviving tail with `gapBefore`, so the discontinuity stays visible to + * observers. Sources drain in array order each round, so bytes two sources + * produced inside one poll window land in that order, not in the order they + * were written: the ring is a best-effort live view whose cross-source + * reordering is bounded by `pollMs`. + * + * The wait holds constant resources however long the job runs: one + * subscription on `until` for the whole run and one pending timer at a time. + * @param sources - producer streams, each pumped at its own offset. + * @param append - the ring append; receives each copied chunk. + * @param pollMs - poll interval in milliseconds; a positive finite number. + * @param until - settles (or rejects, which counts as settlement) when the producer finished. + * @returns the handle whose `done` resolves after the final drain. + */ +export function startPump( + sources: readonly JobOutputSource[], + append: (text: string, options?: JobAppendOptions) => void, + pollMs: number, + until: Promise, +): PumpHandle { + if (!Number.isFinite(pollMs) || pollMs <= 0) { + throw new Error(`invalid pump pollMs: expected a positive finite number of milliseconds, got ${JSON.stringify(pollMs)}`) + } + const states = sources.map(source => ({ source, cursor: 0 })) + const drain = (): void => { + for (const state of states) { + const { source } = state + const read = source.read(state.cursor) + state.cursor = read.nextOffset + if (read.text.length === 0) continue + if (source.channel === undefined && !read.lossy) { + append(read.text) + } else { + append(read.text, { + ...source.channel !== undefined ? { channel: source.channel } : {}, + ...read.lossy ? { gapBefore: true as const } : {}, + }) + } + } + } + const done = (async (): Promise => { + let settled = false + let timer: ReturnType | undefined + let wake: (() => void) | undefined + const finish = (): void => { + settled = true + // Clear the pending poll so a finished pump holds no timer for up to one + // interval, and release the current wait so the final drain runs at once. + // `finish` always runs from a microtask, after the loop parked on its + // first timer, so the timer is never absent here. + clearTimeout(timer) + wake?.() + } + void until.then(finish, finish) + while (!settled) { + drain() + await new Promise((resolve) => { + wake = resolve + timer = setTimeout(resolve, pollMs) + }) + wake = undefined + timer = undefined + } + drain() + })() + return { done } +} diff --git a/packages/jobs/jobs-local/src/ring.ts b/packages/jobs/jobs-local/src/ring.ts new file mode 100644 index 0000000000..fb4bcb3c5b --- /dev/null +++ b/packages/jobs/jobs-local/src/ring.ts @@ -0,0 +1,113 @@ +/** + * The bounded output ring behind one job: chunks at absolute byte offsets, + * head eviction that never moves an assigned offset, and non-consuming reads + * from any offset. + * @module @deepseek-ai/dsh-jobs-local/ring + */ + +import type { JobAppendOptions, JobChannel, JobOutputRead } from '@deepseek-ai/dsh-jobs' + +/** One retained ring entry; `bytes` caches the chunk's UTF-8 length. */ +interface RingChunk { + at: number + text: string + bytes: number + channel?: JobChannel + gapBefore?: true +} + +/** + * The UTF-8-safe tail of `text` no longer than `maxBytes`: the byte cut + * advances past continuation bytes so the surviving text never starts inside + * a code point. + * @param text - the oversized chunk text. + * @param maxBytes - positive byte budget for the surviving tail. + * @returns the surviving tail and its exact byte length. + */ +function utf8Tail(text: string, maxBytes: number): { text: string; bytes: number } { + const raw = Buffer.from(text, 'utf8') + let start = raw.length - maxBytes + // The loop bound proves the index is in range; the assertion only + // discharges noUncheckedIndexedAccess. + while (start < raw.length && ((raw[start] as number) & 0xC0) === 0x80) start += 1 + const tail = raw.subarray(start) + return { text: tail.toString('utf8'), bytes: tail.length } +} + +/** Offsets stay absolute across eviction: `earliest` only ever advances. */ +export class OutputRing { + /** Retained chunks in offset order. */ + private readonly chunks: RingChunk[] = [] + /** Sum of the retained chunks' byte lengths. */ + retainedBytes = 0 + /** Total UTF-8 bytes ever appended — the offset the next chunk starts at. */ + total = 0 + /** Offset of the oldest retained byte (equals {@link total} when nothing is retained). */ + earliest = 0 + + /** + * Append one chunk and trim the head to `cap`. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + * @param cap - retention cap in UTF-8 bytes after this append. + * @returns false when the chunk was empty and nothing changed. + */ + append(text: string, options: JobAppendOptions | undefined, cap: number): boolean { + if (text.length === 0) return false + const bytes = Buffer.byteLength(text, 'utf8') + this.chunks.push({ + at: this.total, + text, + bytes, + ...options?.channel !== undefined ? { channel: options.channel } : {}, + ...options?.gapBefore !== undefined ? { gapBefore: options.gapBefore } : {}, + }) + this.total += bytes + this.retainedBytes += bytes + this.trim(cap) + return true + } + + /** + * Drop retained head chunks until the ring fits `cap`; a single oversized + * chunk keeps only its UTF-8-safe tail with a `gapBefore` marker. + * @param cap - retention cap in UTF-8 bytes. + */ + trim(cap: number): void { + while (this.retainedBytes > cap && this.chunks.length > 1) { + const dropped = this.chunks.shift() + /* v8 ignore next -- the length guard proves shift() returns a chunk; the check only satisfies noUncheckedIndexedAccess. */ + if (dropped === undefined) break + this.retainedBytes -= dropped.bytes + } + const single = this.chunks.length === 1 ? this.chunks[0] : undefined + if (single !== undefined && single.bytes > cap) { + const tail = utf8Tail(single.text, cap) + single.at += single.bytes - tail.bytes + single.text = tail.text + single.bytes = tail.bytes + single.gapBefore = true + this.retainedBytes = tail.bytes + } + this.earliest = this.chunks[0]?.at ?? this.total + } + + /** + * Retained chunks overlapping `[from, total)` as fresh wire chunks. + * @param from - absolute byte offset to read from. + * @returns the chunks, the resume offset, and whether bytes before them were evicted. + */ + readFrom(from: number): JobOutputRead { + const chunks = [] + for (const chunk of this.chunks) { + if (chunk.at + chunk.bytes <= from) continue + chunks.push({ + at: chunk.at, + text: chunk.text, + ...chunk.channel !== undefined ? { channel: chunk.channel } : {}, + ...chunk.gapBefore !== undefined ? { gapBefore: chunk.gapBefore } : {}, + }) + } + return { chunks, next: this.total, lossy: from < this.earliest } + } +} diff --git a/packages/jobs/jobs-local/tests/pump.spec.ts b/packages/jobs/jobs-local/tests/pump.spec.ts new file mode 100644 index 0000000000..31ee5d4d3f --- /dev/null +++ b/packages/jobs/jobs-local/tests/pump.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest' +import type { JobAppendOptions, JobOutputSource } from '@deepseek-ai/dsh-jobs' +import { startPump } from '../src/pump.ts' + +/** Record every append so pump behavior is observable without a registry. */ +function sink() { + const appends: { text: string; options?: JobAppendOptions }[] = [] + return { + appends, + append: (text: string, options?: JobAppendOptions) => { appends.push({ text, ...options !== undefined ? { options } : {} }) }, + } +} + +/** A scripted source: each read() shifts the next scripted result. */ +function scriptedSource( + reads: { text: string; lossy?: boolean }[], + channel?: 'stdout' | 'stderr', +): { source: JobOutputSource; offsets: number[] } { + const offsets: number[] = [] + let offset = 0 + const source: JobOutputSource = { + ...channel !== undefined ? { channel } : {}, + read(fromByte) { + offsets.push(fromByte) + const next = reads.shift() ?? { text: '' } + offset += Buffer.byteLength(next.text, 'utf8') + return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false } + }, + } + return { source, offsets } +} + +describe('startPump', () => { + it('copies labeled deltas at the poll cadence and resumes each source at its own offset', async () => { + vi.useFakeTimers() + try { + const { appends, append } = sink() + let settle!: () => void + const until = new Promise((resolve) => { settle = resolve }) + const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout') + const err = scriptedSource([{ text: '' }, { text: 'E' }], 'stderr') + const pump = startPump([out.source, err.source], append, 50, until) + + await vi.advanceTimersByTimeAsync(50) + settle() + await pump.done + expect(appends).toEqual([ + { text: 'a', options: { channel: 'stdout' } }, + { text: 'bc', options: { channel: 'stdout' } }, + { text: 'E', options: { channel: 'stderr' } }, + ]) + // Every read resumed from the previous nextOffset, never from a shared cursor. + expect(out.offsets).toEqual([0, 1, 3]) + expect(err.offsets).toEqual([0, 0, 1]) + // Settlement cleared the pending poll timer instead of leaving it pending. + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('marks a lossy source read as a gap so observers see the discontinuity', async () => { + const { appends, append } = sink() + const { source } = scriptedSource([{ text: 'tail', lossy: true }]) + await startPump([source], append, 1, Promise.resolve()).done + expect(appends).toEqual([{ text: 'tail', options: { gapBefore: true } }]) + }) + + it('treats a rejected settlement as settlement and still drains the final bytes', async () => { + const { appends, append } = sink() + const { source } = scriptedSource([{ text: 'last' }]) + await startPump([source], append, 1, Promise.reject(new Error('producer broke'))).done + expect(appends).toEqual([{ text: 'last' }]) + }) + + it('subscribes to the settlement once and holds one poll timer however long the job runs', async () => { + vi.useFakeTimers() + try { + const { append } = sink() + let subscriptions = 0 + let settle!: () => void + const settled = new Promise((resolve) => { settle = resolve }) + const counting = (promise: Promise): Promise => ({ + then(onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown) { + subscriptions += 1 + return counting(promise.then(onFulfilled, onRejected)) + }, + }) as unknown as Promise + const { source, offsets } = scriptedSource([]) + const pump = startPump([source], append, 50, counting(settled)) + + const rounds = 10_000 + await vi.advanceTimersByTimeAsync(50 * rounds) + expect(offsets).toHaveLength(rounds + 1) + expect(subscriptions).toBeLessThanOrEqual(2) + expect(vi.getTimerCount()).toBe(1) + + settle() + await pump.done + expect(offsets).toHaveLength(rounds + 2) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('rejects a non-positive poll interval before touching any source', () => { + const { append } = sink() + expect(() => startPump([], append, 0, Promise.resolve())).toThrow(/invalid pump pollMs/) + }) +}) diff --git a/packages/jobs/jobs-local/tests/ring.spec.ts b/packages/jobs/jobs-local/tests/ring.spec.ts new file mode 100644 index 0000000000..670e121d0b --- /dev/null +++ b/packages/jobs/jobs-local/tests/ring.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { OutputRing } from '../src/ring.ts' + +describe('OutputRing', () => { + it('assigns absolute offsets by UTF-8 byte length and drops empty chunks silently', () => { + const ring = new OutputRing() + expect(ring.append('', undefined, 1024)).toBe(false) + expect(ring.append('héllo', { channel: 'stdout' }, 1024)).toBe(true) + expect(ring.append('wörld', { channel: 'stderr', gapBefore: true }, 1024)).toBe(true) + expect(ring.total).toBe(12) + expect(ring.earliest).toBe(0) + expect(ring.retainedBytes).toBe(12) + expect(ring.readFrom(0)).toEqual({ + chunks: [ + { at: 0, text: 'héllo', channel: 'stdout' }, + { at: 6, text: 'wörld', channel: 'stderr', gapBefore: true }, + ], + next: 12, + lossy: false, + }) + }) + + it('reads from a foreign offset by returning the whole overlapping chunk', () => { + const ring = new OutputRing() + ring.append('abc', undefined, 1024) + ring.append('def', undefined, 1024) + expect(ring.readFrom(4).chunks).toEqual([{ at: 3, text: 'def' }]) + expect(ring.readFrom(6)).toEqual({ chunks: [], next: 6, lossy: false }) + }) + + it('trims whole head chunks past the cap and reports the evicted span as lossy', () => { + const ring = new OutputRing() + ring.append('aaaa', undefined, 8) + ring.append('bbbb', undefined, 8) + ring.append('cc', undefined, 8) + // 10 bytes retained > cap 8: the oldest whole chunk goes. + expect(ring.earliest).toBe(4) + expect(ring.retainedBytes).toBe(6) + expect(ring.readFrom(0)).toEqual({ chunks: [{ at: 4, text: 'bbbb' }, { at: 8, text: 'cc' }], next: 10, lossy: true }) + expect(ring.readFrom(4).lossy).toBe(false) + }) + + it('keeps a lone oversized chunk as a UTF-8-safe tail marked gapBefore', () => { + const ring = new OutputRing() + // 3 two-byte code points = 6 bytes; a 3-byte cap must not start inside a code point. + ring.append('ééé', undefined, 3) + expect(ring.retainedBytes).toBe(2) + expect(ring.earliest).toBe(4) + expect(ring.readFrom(0)).toEqual({ chunks: [{ at: 4, text: 'é', gapBefore: true }], next: 6, lossy: true }) + }) + + it('re-trims to a smaller cap on demand and leaves total untouched', () => { + const ring = new OutputRing() + ring.append('12345678', undefined, 64) + ring.append('abcdefgh', undefined, 64) + ring.trim(8) + expect(ring.total).toBe(16) + expect(ring.earliest).toBe(8) + expect(ring.readFrom(8)).toEqual({ chunks: [{ at: 8, text: 'abcdefgh' }], next: 16, lossy: false }) + // Trimming an empty ring is a no-op that keeps earliest at total. + const empty = new OutputRing() + empty.trim(1) + expect(empty.earliest).toBe(0) + }) +}) From f55e9805c753af225dc80fcc9b0d4159dc860d36 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 16:03:16 +0800 Subject: [PATCH 029/284] refactor(jobs-local): one ring, two cursors, and event routing Every job owns an `OutputRing`; the model's `read` consumes from a registry-kept cursor while `readAt` reads at absolute offsets. Pull sources named by the spec are pumped by the registry and drained once more before settlement. `visibleTo(caller)` binds the session fence once; the three listener families become one event hub with explicit filters; `reported` is gone and settlements announce their cause. --- packages/jobs/jobs-local/src/events.ts | 98 ++ packages/jobs/jobs-local/src/index.ts | 741 ++++++------- packages/jobs/jobs-local/tests/events.spec.ts | 138 +++ packages/jobs/jobs-local/tests/jobs.spec.ts | 983 ++++++++---------- 4 files changed, 967 insertions(+), 993 deletions(-) create mode 100644 packages/jobs/jobs-local/src/events.ts create mode 100644 packages/jobs/jobs-local/tests/events.spec.ts diff --git a/packages/jobs/jobs-local/src/events.ts b/packages/jobs/jobs-local/src/events.ts new file mode 100644 index 0000000000..2e39f7089f --- /dev/null +++ b/packages/jobs/jobs-local/src/events.ts @@ -0,0 +1,98 @@ +/** + * Event routing for the local registry: subscriptions file by filter, and + * every commit dispatches once to each matching listener with containment. + * @module @deepseek-ai/dsh-jobs-local/events + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeLayer } from '@deepseek-ai/dsh-scope' +import type { JobEvent, JobEventFilter, JobEventListener } from '@deepseek-ai/dsh-jobs' + +/** One registered listener and the filter it declared. */ +interface Subscription { + filter: JobEventFilter + listener: JobEventListener +} + +/** + * One scope's contributions: the job controllers attached from it and the + * `{ owners: 'scope' }` subscriptions registered there. Both tables are + * anonymous because a contribution is identified by its own disposer, never + * by a name a second registrant could shadow. + */ +export class JobLayer implements ScopeLayer { + readonly controllers = new AnonymousEntries() + readonly scoped = new AnonymousEntries() + + isEmpty(): boolean { + return this.controllers.isEmpty() && this.scoped.isEmpty() + } +} + +/** + * Routes events to subscriptions. `{ owner }` and `{ owners: 'all' }` + * subscriptions are evaluated against every event regardless of where they + * were registered; `{ owners: 'scope' }` subscriptions file into the + * registering context's scope layer and receive the owners composed under it + * — the global layer, reached from an unscoped context, receives every owner. + */ +export class JobEventHub { + private readonly unscoped = new Set() + + /** + * @param layers - the scope-layer table shared with the controller handshake. + * @param warn - sink for a listener's contained failure. + */ + constructor( + private readonly layers: ScopedLayers, + private readonly warn: (message: string) => void, + ) {} + + /** + * Register one listener as an effect of `ctx`. + * @param ctx - the subscribing context; owns the effect and, for `'scope'`, names the scope. + * @param filter - which owners' events to deliver. + * @param listener - receives each matching event. + * @returns disposer that unregisters the listener. + */ + subscribe(ctx: Context, filter: JobEventFilter, listener: JobEventListener): () => void { + const subscription: Subscription = { filter, listener } + if ('owners' in filter && filter.owners === 'scope') { + return this.layers.effect(ctx, layer => layer.scoped.append(subscription), { label: 'jobs.events.subscribe()' }) + } + return ctx.effect(() => { + this.unscoped.add(subscription) + return () => { this.unscoped.delete(subscription) } + }, 'jobs.events.subscribe()') + } + + /** + * Deliver one event to every matching subscription, containing each + * listener so an observer cannot break the commit already made. + * @param event - the event to deliver. + * @param owner - the job's exact owner, or undefined for unowned work. + */ + emit(event: JobEvent, owner: Agent | undefined): void { + const ownerId = owner?.id + for (const subscription of this.unscoped) { + const { filter } = subscription + if ('owner' in filter && ownerId !== undefined && ownerId !== filter.owner) continue + this.deliver(subscription, event) + } + for (const subscription of this.layers.global.scoped.values()) this.deliver(subscription, event) + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) { + for (const subscription of layer.scoped.values()) this.deliver(subscription, event) + } + } + + private deliver(subscription: Subscription, event: JobEvent): void { + try { + subscription.listener(event) + } catch (error: unknown) { + this.warn(`jobs: event listener threw on ${event.type}: ${String(error)}`) + } + } +} diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index e5415aee15..965de7a124 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -1,8 +1,8 @@ /** * Process-local provider for the background-job capability seam - * (`ctx.jobs`). It keeps every record — lifecycle state plus the optional - * bounded output ring — in memory and hands out fresh snapshots and chunk - * copies, never live state. + * (`ctx.jobs`). It keeps every job — lifecycle state, the bounded output + * ring, and the model cursor — in memory and hands out fresh projections and + * chunk copies, never live state. * * Registrations outlive producer and controller fibers. Agent or service * disposal cancels live work and awaits compliant producers; a throwing @@ -13,14 +13,18 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' -import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' -import type { ScopeLayer } from '@deepseek-ai/dsh-scope' +import { ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' +import type { SessionId } from '@deepseek-ai/dsh-session' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' import type { - JobAppendOptions, JobChannel, JobDoneListener, JobKind, JobOutcome, JobOutputListener, JobRead, - JobRecordChunk, JobRecordRead, JobSnapshot, JobStart, JobStatus, JobsChangedListener, RecordingJob, RunningJob, + JobAppendOptions, JobEvent, JobEvents, JobHandle, JobKillOptions, JobKind, JobOutcome, JobOutputRead, JobRead, + JobSettleCause, JobSpec, JobStatus, JobView, VisibleJobs, } from '@deepseek-ai/dsh-jobs' +import { JobEventHub, JobLayer } from './events.ts' +import { startPump } from './pump.ts' +import type { PumpHandle } from './pump.ts' +import { OutputRing } from './ring.ts' /** Timeout code that distinguishes a bounded wait from caller cancellation. */ export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT' @@ -28,12 +32,15 @@ export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT' /** Default maximum number of active jobs in one exact-owner bucket. */ const DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER = 10 -/** Default live record retention per job, in UTF-8 bytes. */ +/** Default live ring retention per job, in UTF-8 bytes. */ const DEFAULT_RETAIN_BYTES = 256 * 1024 -/** Default record retention kept after settlement, in UTF-8 bytes. */ +/** Default ring retention kept after settlement, in UTF-8 bytes. */ const DEFAULT_SETTLED_RETAIN_BYTES = 16 * 1024 +/** Default poll interval for pull sources, in milliseconds. */ +const DEFAULT_PUMP_POLL_MS = 150 + /** Configuration for the process-local job registry. */ export interface Config { /** @@ -41,49 +48,28 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number - /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */ + /** Live ring retention per job in UTF-8 bytes; omission defaults to 262144. */ retainBytes?: number - /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ + /** Ring retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ settledRetainBytes?: number -} - -/** One retained record ring entry; `bytes` caches the chunk's UTF-8 length. */ -interface RingChunk { - at: number - text: string - bytes: number - channel?: JobChannel - gapBefore?: true -} - -/** The bounded output ring behind one job's declared record. */ -interface RecordState { - /** Retained chunks in offset order; offsets stay absolute across eviction. */ - chunks: RingChunk[] - /** Sum of the retained chunks' byte lengths. */ - retainedBytes: number - /** Total UTF-8 bytes ever appended. */ - total: number - /** Offset of the oldest retained byte (equals {@link total} when nothing is retained). */ - earliest: number + /** Poll interval for a job's pull sources, in milliseconds; omission defaults to 150. */ + pumpPollMs?: number } /** - * Producer-written state shared between the {@link RunningJob} face and the + * Producer-written state shared between the {@link JobHandle} and the * registered record: the starter call writes through it before the commit, * the same object serves the job for its whole life afterwards. */ interface ProducerState { - /** Bounded output ring; undefined when the spec declared no record. */ - record: RecordState | undefined - /** Live progress detail until settlement replaces it with the terminal one. */ - detail: string | undefined + /** Live progress line until settlement clears it. */ + progress: string | undefined /** The committed registry record; undefined exactly during the starter call. */ - job: TrackedTask | undefined + job: TrackedJob | undefined } -/** The registry's mutable per-job record (never handed out — see {@link LocalJobRegistry.snapshot}). */ -interface TrackedTask { +/** The registry's mutable per-job record (never handed out — see {@link LocalJobRegistry.view}). */ +interface TrackedJob { id: JobId kind: JobKind label: string @@ -91,22 +77,31 @@ interface TrackedTask { /** Exact lifecycle owner; session-id authorization is derived from it. */ owner: Agent | undefined cancel: (reason?: string) => void - readOutput: (() => string) | undefined status: JobStatus - /** Producer-shared record ring and detail line. */ + ring: OutputRing + /** The model's consuming cursor; {@link VisibleJobs.readAt} never moves it. */ + modelCursor: number + /** Whether the first post-settlement read already handed out `result`. */ + resultDelivered: boolean + /** Producer-shared progress line and commit binding. */ state: ProducerState - output: string | undefined + /** Terminal reason; a recorded kill reason is merged in at settlement. */ + detail: string | undefined + result: string | undefined startedAt: number finishedAt: number | undefined - reported: boolean - /** Resolves once the terminal snapshot is recorded and listeners notified. */ + /** Reason recorded by {@link VisibleJobs.kill}, merged into a `killed` settlement's detail. */ + killReason: string | undefined + /** Set once a kill or teardown cancel ran; settlement reports it as the cause. */ + settleCause: JobSettleCause | undefined + /** Resolves once the terminal record is committed and announced. */ settled: Promise /** Resolver for {@link settled}, called by the first effective settlement. */ markSettled: () => void - /** Live waits; settlement with a waiter marks the job reported. */ - waiters: number /** Removable resolvers for live waits; timeout/abort unregister before the job settles. */ waitResolvers: Set<() => void> + /** The registry-owned pump over the spec's pull sources, when it named any. */ + pump: PumpHandle | undefined } /** True for the three terminal {@link JobStatus} values. */ @@ -114,42 +109,6 @@ function isTerminal(status: JobStatus): boolean { return status === 'completed' || status === 'killed' || status === 'failed' } -/** - * The UTF-8-safe tail of `text` no longer than `maxBytes`: the byte cut - * advances past continuation bytes so the surviving text never starts inside - * a code point. - * @param text - the oversized chunk text. - * @param maxBytes - positive byte budget for the surviving tail. - * @returns the surviving tail and its exact byte length. - */ -function utf8Tail(text: string, maxBytes: number): { text: string; bytes: number } { - const raw = Buffer.from(text, 'utf8') - let start = raw.length - maxBytes - // The loop bound proves the index is in range; the assertion only - // discharges noUncheckedIndexedAccess. - while (start < raw.length && ((raw[start] as number) & 0xC0) === 0x80) start += 1 - const tail = raw.subarray(start) - return { text: tail.toString('utf8'), bytes: tail.length } -} - -/** - * One scope's contributions: the job controllers attached from it and the - * completion, change, and record-output listeners registered there. All - * tables are anonymous because a contribution is identified by its own - * disposer, never by a name a second registrant could shadow. - */ -class JobLayer implements ScopeLayer { - readonly controllers = new AnonymousEntries() - readonly listeners = new AnonymousEntries() - readonly changed = new AnonymousEntries() - readonly output = new AnonymousEntries() - - isEmpty(): boolean { - return this.controllers.isEmpty() && this.listeners.isEmpty() && this.changed.isEmpty() - && this.output.isEmpty() - } -} - /** * The in-memory `jobs` registry. See the Service Definition contract in * `@deepseek-ai/dsh-jobs` for the ownership, isolation, and lifecycle @@ -172,20 +131,28 @@ export class LocalJobRegistry extends JobRegistry { .min(1) .max(Number.MAX_SAFE_INTEGER) .default(DEFAULT_SETTLED_RETAIN_BYTES), + pumpPollMs: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_PUMP_POLL_MS), }) /** Schemastery-defaulted active-job limit. */ private readonly maxConcurrentJobsPerOwner: number - /** Schemastery-defaulted live record retention cap. */ + /** Schemastery-defaulted live ring retention cap. */ private readonly retainBytes: number - /** Schemastery-defaulted settled record retention cap. */ + /** Schemastery-defaulted settled ring retention cap. */ private readonly settledRetainBytes: number - private store = new Map() + /** Schemastery-defaulted pull-source poll interval. */ + private readonly pumpPollMs: number + private store = new Map() private counters = new Map() /** - * Surfaces and listeners layered by the scope that registered them, in the - * tools-registry shape: a contribution files into its registering context's - * scope, and a read unions the global layer with the reader's scope chain. + * Controllers and scoped subscriptions layered by the scope that registered + * them, in the tools-registry shape: a contribution files into its + * registering context's scope, and a read unions the global layer with the + * owner's scope chain. * * The registry is one process-wide instance serving every composition, so a * flat table would answer a per-owner question process-wide: one preset's @@ -195,7 +162,7 @@ export class LocalJobRegistry extends JobRegistry { * layer, so change notification is a no-op. */ private readonly layers = new ScopedLayers(() => new JobLayer(), () => {}) - private listenersClosed = false + private readonly hub: JobEventHub /** Owner agents with attached scope cleanup, mapped to the exact disposer. */ private ownerCleanups = new Map Promise | void>() /** Service context used by detached settlement continuations and teardown. */ @@ -204,15 +171,30 @@ export class LocalJobRegistry extends JobRegistry { constructor(ctx: Context, config: Config) { super(ctx) // Schemastery validates and fills the defaults before constructing the service. - this.maxConcurrentJobsPerOwner = (config as Required).maxConcurrentJobsPerOwner - this.retainBytes = (config as Required).retainBytes - this.settledRetainBytes = (config as Required).settledRetainBytes + const resolved = config as Required + this.maxConcurrentJobsPerOwner = resolved.maxConcurrentJobsPerOwner + this.retainBytes = resolved.retainBytes + this.settledRetainBytes = resolved.settledRetainBytes + this.pumpPollMs = resolved.pumpPollMs this.selfCtx = ctx + this.hub = new JobEventHub(this.layers, message => ctx.logger.warn(message)) ctx.effect(() => () => this.disposeAll(), 'jobs teardown') } - start(spec: JobStart): JobId { - if (!this.servesOwner(spec.owner)) { + /** + * The event stream bound to the accessing context: a subscription is an + * effect of that context, and `{ owners: 'scope' }` names its scope. + */ + get events(): JobEvents { + const registrar = this.ctx + return { + subscribe: (filter, listener) => this.hub.subscribe(registrar, filter, listener), + } + } + + start(spec: JobSpec): JobId { + const owner = this.resolveOwner(spec.owner) + if (!this.servesOwner(owner)) { throw new Error('background jobs unavailable: no job controller serves this agent (load @deepseek-ai/dsh-tool-jobs in its composition)') } if (spec.kind.length === 0) throw new Error('invalid job kind: expected a non-empty string') @@ -221,9 +203,9 @@ export class LocalJobRegistry extends JobRegistry { && (!Number.isSafeInteger(spec.outputLimitBytes) || spec.outputLimitBytes <= 0)) { throw new Error(`invalid outputLimitBytes: expected a positive safe integer, got ${JSON.stringify(spec.outputLimitBytes)}`) } - if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) + if (owner !== undefined) this.ensureOwnerCleanup(owner) - const active = this.activeTaskCount(spec.owner) + const active = this.activeTaskCount(owner) if (active >= this.maxConcurrentJobsPerOwner) { throw new Error( `background job limit reached for this owner (limit: ${this.maxConcurrentJobsPerOwner}); use job_kill to stop an unneeded job, wait for it to finish, then retry`, @@ -236,189 +218,90 @@ export class LocalJobRegistry extends JobRegistry { const count = (this.counters.get(spec.kind) ?? 0) + 1 this.counters.set(spec.kind, count) const id = JobId(`${spec.kind}-${count}`) - const state: ProducerState = { record: undefined, detail: undefined, job: undefined } - const face: RunningJob = { + const ring = new OutputRing() + const state: ProducerState = { progress: undefined, job: undefined } + const handle: JobHandle = { id, - updateDetail: (detail) => { this.updateDetail(state, detail) }, + append: (text, options) => { this.appendRing(state, ring, text, options, 'producer') }, + updateProgress: (line) => { this.updateProgress(state, line) }, } - // The declaration decides the face: a plain start never receives `append`. - const hooks = spec.record === true ? spec.run(this.recordingFace(face, state)) : spec.run(face) + const hooks = spec.run(handle) let markSettled!: () => void const settled = new Promise((resolve) => { markSettled = resolve }) - const job: TrackedTask = { + const job: TrackedJob = { id, kind: spec.kind, label: spec.label, outputLimitBytes: spec.outputLimitBytes, - owner: spec.owner, + owner, cancel: hooks.cancel.bind(hooks), - readOutput: hooks.readOutput?.bind(hooks), status: 'running', + ring, + modelCursor: 0, + resultDelivered: false, state, - output: undefined, + detail: undefined, + result: undefined, startedAt: Date.now(), finishedAt: undefined, - reported: false, + killReason: undefined, + settleCause: undefined, settled, markSettled, - waiters: 0, waitResolvers: new Set(), + pump: undefined, } // Binding the shared producer state is the commit: writes staged inside - // the starter are already in `state`, and every later handle call reaches - // the registered record for its terminal checks and observer signals. + // the starter are already in the ring and `state`, and every later handle + // call reaches the registered record for its terminal checks and signals. state.job = job this.store.set(id, job) - void hooks.done.then( - (outcome) => { this.settle(job, outcome) }, - (error: unknown) => { + // The producer's settlement or a registry-forced one ends the pump; the + // pump's final drain then lands before this registry trims the ring. + const producerDone = hooks.done.then( + outcome => outcome, + (error: unknown): JobOutcome => { // Contain a producer contract violation (`done` rejected) so cleanup and waiters cannot hang. this.selfCtx.logger.warn(`jobs: job ${job.id} producer done promise rejected (producer contract violation): ${String(error)}`) - this.settle(job, { status: 'failed', detail: String(error) }) + return { status: 'failed', detail: String(error) } }, ) + if (spec.output !== undefined && spec.output.length > 0) { + job.pump = startPump( + spec.output, + (text, options) => { this.appendRing(state, ring, text, options, 'pump') }, + this.pumpPollMs, + Promise.race([producerDone, settled]), + ) + } + void producerDone.then(async (outcome) => { + if (job.pump !== undefined) await job.pump.done + this.settle(job, outcome, job.settleCause ?? 'producer') + }) // Registration is complete and cannot fail from here, so the visible set // has genuinely changed. - this.notifyChanged(job.owner) + this.emit({ type: 'registered', job: this.view(job) }, owner) return id } - list(caller?: Agent): JobSnapshot[] { - const session = caller?.id - return [...this.store.values()] - .filter(job => job.owner === undefined || job.owner.id === session) - .map(job => this.snapshot(job)) - } - - get(id: JobId, caller?: Agent): JobSnapshot { - const job = this.expect(id) - this.assertAccess(job, caller) - return this.snapshot(job) - } - - read(id: JobId, caller?: Agent): JobRead { - const job = this.expect(id) - this.assertAccess(job, caller) - const text = job.readOutput !== undefined - ? job.readOutput() - : isTerminal(job.status) ? job.output ?? '' : '' - if (isTerminal(job.status)) job.reported = true - return { text, snapshot: this.snapshot(job) } - } - - kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished' { - const job = this.expect(id) - this.assertAccess(job, caller) - if (isTerminal(job.status)) { - job.reported = true - return 'already-finished' + visibleTo(caller?: SessionId): VisibleJobs { + const reach = (id: JobId): TrackedJob => { + const job = this.expect(id) + this.assertAccess(job, caller) + return job } - // Cancel first so a throw leaves both lifecycle and notice state unchanged. - job.cancel(reason) - job.status = 'stopping' - job.reported = true - this.notifyChanged(job.owner) - return 'requested' - } - - async wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise { - const job = this.expect(id) - this.assertAccess(job, caller) - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`) + return { + list: () => [...this.store.values()] + .filter(job => job.owner === undefined || job.owner.id === caller) + .map(job => this.view(job)), + get: id => this.view(reach(id)), + read: id => this.readJob(reach(id)), + readAt: (id, from) => this.readAt(reach(id), from), + kill: (id, options) => this.killJob(reach(id), options), + wait: async (id, timeoutMs, signal) => this.waitJob(reach(id), timeoutMs, signal), } - if (!isTerminal(job.status)) { - if (signal?.aborted) throw new Error('wait aborted') - // Abort removes the waiter synchronously so same-tick settlement cannot - // suppress a notice for a wait that will reject. - job.waiters += 1 - let counted = true - const uncount = (): void => { - if (!counted) return - counted = false - job.waiters -= 1 - } - try { - // The scoped deadline distinguishes a successful wait timeout from - // caller cancellation and clears its timer on every exit. - using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT) - await new Promise((resolve, reject) => { - const onSettled = (): void => { - job.waitResolvers.delete(onSettled) - d.signal.removeEventListener('abort', onAbort) - resolve() - } - const onAbort = (): void => { - job.waitResolvers.delete(onSettled) - // A settled job cannot reach here: settlement releases every waiter - // before it announces completion, and each released waiter detaches - // this listener in the same synchronous span, so nothing that reacts - // to a settlement can abort a wait the settlement already owed. - if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) { - resolve() - } else { - uncount() - reject(new Error('wait aborted')) - } - } - job.waitResolvers.add(onSettled) - d.signal.addEventListener('abort', onAbort, { once: true }) - }) - } finally { - uncount() - } - } - if (isTerminal(job.status)) job.reported = true - return this.snapshot(job) - } - - onJobDone(listener: JobDoneListener): () => void { - return this.layers.effect( - this.ctx, - layer => layer.listeners.append(listener), - { label: 'jobs.onJobDone()' }, - ) - } - - onJobsChanged(listener: JobsChangedListener): () => void { - return this.layers.effect( - this.ctx, - layer => layer.changed.append(listener), - { label: 'jobs.onJobsChanged()' }, - ) - } - - readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead { - const job = this.expect(id) - this.assertAccess(job, caller) - if (!Number.isSafeInteger(from) || from < 0) { - throw new Error(`invalid record read offset: expected a non-negative safe integer, got ${JSON.stringify(from)}`) - } - const record = job.state.record - if (record === undefined) { - throw new Error(`job ${job.id} declared no output record`) - } - const chunks: JobRecordChunk[] = [] - for (const chunk of record.chunks) { - if (chunk.at + chunk.bytes <= from) continue - chunks.push({ - at: chunk.at, - text: chunk.text, - ...chunk.channel !== undefined ? { channel: chunk.channel } : {}, - ...chunk.gapBefore !== undefined ? { gapBefore: chunk.gapBefore } : {}, - }) - } - return { chunks, next: record.total, lossy: from < record.earliest } - } - - onOutput(listener: JobOutputListener): () => void { - return this.layers.effect( - this.ctx, - layer => layer.output.append(listener), - { label: 'jobs.onOutput()' }, - ) } attachController(name: string): () => void { @@ -431,6 +314,24 @@ export class LocalJobRegistry extends JobRegistry { ) } + /** + * Resolve a spec's owner session to its live Agent. An owned registration + * needs the agent registry, and the session must currently have a live + * instance: that instance's disposal is what cancels and drops the job. + */ + private resolveOwner(session: SessionId | undefined): Agent | undefined { + if (session === undefined) return undefined + const agents = this.selfCtx.get('agents') + if (agents === undefined) { + throw new Error('background job ownership requires the agent registry (load @deepseek-ai/dsh-agent)') + } + const owner = agents.get(session) + if (owner === undefined) { + throw new Error(`session "${session}" has no live agent (background job owner must be live)`) + } + return owner + } + /** * Whether an attached job controller can collect and stop work owned by * `owner`. The global layer holds every controller attached from an unscoped @@ -454,22 +355,8 @@ export class LocalJobRegistry extends JobRegistry { return count } - /** - * The completion listeners that own `owner`'s notices: the global layer's - * first, then each scoped layer along the owner's chain. A listener outside - * that chain belongs to another composition and must not deliver, or the - * owner reads one notice per mounted preset. - * @param owner - the settled job's owner, or undefined for unowned work. - * @returns the listeners to notify, in registration order per layer. - */ - private *listenersFor(owner?: Agent): IterableIterator { - yield* this.layers.global.listeners.values() - const scope = owner === undefined ? undefined : scopeOf(owner.ctx) - for (const layer of this.layers.chainLayers(scope)) yield* layer.listeners.values() - } - /** Look up a job or fail loud. */ - private expect(id: JobId): TrackedTask { + private expect(id: JobId): TrackedJob { const job = this.store.get(id) if (job === undefined) throw new Error(`unknown job ${id}`) return job @@ -478,214 +365,189 @@ export class LocalJobRegistry extends JobRegistry { /** * The isolation fence: a job with an owner is reachable only by callers * whose session id matches (`!== undefined` semantics — an unowned job is - * open, and a no-agent caller can never match an owned one). + * open, and a caller-less view can never match an owned one). */ - private assertAccess(job: TrackedTask, caller?: Agent): void { - if (job.owner !== undefined && job.owner.id !== caller?.id) { + private assertAccess(job: TrackedJob, caller: SessionId | undefined): void { + if (job.owner !== undefined && job.owner.id !== caller) { throw new Error(`job ${job.id} belongs to another session`) } } - /** Project a fresh read-only snapshot from the mutable record. */ - private snapshot(job: TrackedTask): JobSnapshot { - const ownerSession = job.owner?.id - const record = job.state.record + /** Project a fresh read-only view from the mutable record. */ + private view(job: TrackedJob): JobView { + const owner = job.owner?.id return { id: job.id, kind: job.kind, label: job.label, + ...owner !== undefined ? { owner } : {}, ...job.outputLimitBytes !== undefined ? { outputLimitBytes: job.outputLimitBytes } : {}, - ...ownerSession !== undefined ? { ownerSession } : {}, status: job.status, - ...job.state.detail !== undefined ? { detail: job.state.detail } : {}, + ...job.state.progress !== undefined ? { progress: job.state.progress } : {}, + ...job.detail !== undefined ? { detail: job.detail } : {}, startedAt: job.startedAt, ...job.finishedAt !== undefined ? { finishedAt: job.finishedAt } : {}, - reported: job.reported, - ...record !== undefined ? { outputTotal: record.total, outputEarliest: record.earliest } : {}, + output: { total: job.ring.total, earliest: job.ring.earliest }, } } - /** - * The change observers that own `owner`'s updates, resolved exactly like - * {@link listenersFor}: the global layer — a host composition's own carrier, - * which serves every owner — then each scoped layer along the owner's chain. - * An observer outside that chain belongs to another composition and would - * otherwise be told about agents it does not compose. - * @param owner - the owner whose visible set moved, or undefined for unowned work. - * @returns the observers to notify, in registration order per layer. - */ - private *changedFor(owner?: Agent): IterableIterator { - yield* this.layers.global.changed.values() - const scope = owner === undefined ? undefined : scopeOf(owner.ctx) - for (const layer of this.layers.chainLayers(scope)) yield* layer.changed.values() + private emit(event: JobEvent, owner: Agent | undefined): void { + this.hub.emit(event, owner) } - /** - * Announce that one owner's visible set changed. Each listener is contained - * so an observer cannot break a lifecycle commit that already happened. - */ - private notifyChanged(owner: Agent | undefined): void { - for (const listener of this.changedFor(owner)) { - try { - listener(owner) - } catch (error: unknown) { - this.selfCtx.logger.warn(`jobs: onJobsChanged listener threw: ${String(error)}`) - } - } - } - - /** The record-output observers that own `owner`'s stream signals, resolved like {@link changedFor}. */ - private *outputFor(owner?: Agent): IterableIterator { - yield* this.layers.global.output.values() - const scope = owner === undefined ? undefined : scopeOf(owner.ctx) - for (const layer of this.layers.chainLayers(scope)) yield* layer.output.values() - } - - /** Announce that one job's record advanced (append or settlement), with per-listener containment. */ - private notifyOutput(job: TrackedTask): void { - for (const listener of this.outputFor(job.owner)) { - try { - listener(job.id) - } catch (error: unknown) { - this.selfCtx.logger.warn(`jobs: onOutput listener threw: ${String(error)}`) - } - } - } - - /** Install the job's record and extend the producer face with its append. */ - private recordingFace(face: RunningJob, state: ProducerState): RecordingJob { - const record: RecordState = { chunks: [], retainedBytes: 0, total: 0, earliest: 0 } - state.record = record + /** Consume the ring from the model cursor; the result rides the first read after settlement. */ + private readJob(job: TrackedJob): JobRead { + const read = job.ring.readFrom(job.modelCursor) + job.modelCursor = job.ring.total + const result = isTerminal(job.status) && !job.resultDelivered ? job.result : undefined + if (result !== undefined) job.resultDelivered = true return { - ...face, - append: (text, options) => { this.appendRecord(state, record, text, options) }, + chunks: read.chunks, + lossy: read.lossy, + ...result !== undefined ? { result } : {}, + job: this.view(job), } } + private readAt(job: TrackedJob, from: number): JobOutputRead { + if (!Number.isSafeInteger(from) || from < 0) { + throw new Error(`invalid output read offset: expected a non-negative safe integer, got ${JSON.stringify(from)}`) + } + return job.ring.readFrom(from) + } + + private killJob(job: TrackedJob, options?: JobKillOptions): 'requested' | 'already-finished' { + if (isTerminal(job.status)) return 'already-finished' + // Cancel first so a throw leaves lifecycle state unchanged. + job.cancel(options?.reason) + job.status = 'stopping' + // Last writer wins on purpose: the detail reports the latest kill intent. + if (options?.reason !== undefined) job.killReason = options.reason + job.settleCause = 'kill' + this.emit({ type: 'stopping', job: this.view(job) }, job.owner) + return 'requested' + } + + private async waitJob(job: TrackedJob, timeoutMs: number, signal?: AbortSignal): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`) + } + if (!isTerminal(job.status)) { + if (signal?.aborted) throw new Error('wait aborted') + // The scoped deadline distinguishes a successful wait timeout from + // caller cancellation and clears its timer on every exit. + using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT) + await new Promise((resolve, reject) => { + const onSettled = (): void => { + job.waitResolvers.delete(onSettled) + d.signal.removeEventListener('abort', onAbort) + resolve() + } + const onAbort = (): void => { + job.waitResolvers.delete(onSettled) + // A settled job cannot reach here: settlement releases every waiter + // before it announces, and each released waiter detaches this + // listener in the same synchronous span, so nothing that reacts to a + // settlement can abort a wait the settlement already owed. + if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) { + resolve() + } else { + reject(new Error('wait aborted')) + } + } + job.waitResolvers.add(onSettled) + d.signal.addEventListener('abort', onAbort, { once: true }) + }) + } + return this.view(job) + } + /** - * Append one chunk through a recording producer face. A chunk against a - * settled job is logged and dropped; an empty chunk is dropped silently. A - * chunk staged inside the starter call is retained and signals no observer — - * the registration commit publishes it. + * Append one chunk to the ring. A producer chunk against a settled job is + * logged and dropped; the registry's own pump drains silently after + * settlement (a forced settlement may precede the producer's). A chunk + * staged inside the starter call is retained and signals no observer — the + * registration commit publishes it. */ - private appendRecord(state: ProducerState, record: RecordState, text: string, options?: JobAppendOptions): void { + private appendRing( + state: ProducerState, + ring: OutputRing, + text: string, + options: JobAppendOptions | undefined, + writer: 'producer' | 'pump', + ): void { const job = state.job if (job !== undefined && isTerminal(job.status)) { - this.selfCtx.logger.warn(`jobs: append to settled job ${job.id} dropped`) + if (writer === 'producer') this.selfCtx.logger.warn(`jobs: append to settled job ${job.id} dropped`) return } - if (text.length === 0) return - const bytes = Buffer.byteLength(text, 'utf8') - record.chunks.push({ - at: record.total, - text, - bytes, - ...options?.channel !== undefined ? { channel: options.channel } : {}, - ...options?.gapBefore !== undefined ? { gapBefore: options.gapBefore } : {}, - }) - record.total += bytes - record.retainedBytes += bytes - this.trimRecord(record, this.retainBytes) - if (job !== undefined) this.notifyOutput(job) + if (!ring.append(text, options, this.retainBytes)) return + if (job !== undefined) this.emitOutput(job) + } + + /** Announce that one job's ring advanced (append or settlement). */ + private emitOutput(job: TrackedJob): void { + const owner = job.owner?.id + this.emit({ type: 'output', id: job.id, ...owner !== undefined ? { owner } : {}, total: job.ring.total }, job.owner) } /** - * Replace the live detail line through a producer face; a write against a + * Replace the live progress line through a producer face; a write against a * settled job is logged and dropped. A write staged inside the starter call - * seeds the registered snapshot and signals no observer. + * seeds the registered projection and signals no observer. */ - private updateDetail(state: ProducerState, detail: string): void { + private updateProgress(state: ProducerState, line: string): void { const job = state.job if (job !== undefined && isTerminal(job.status)) { - this.selfCtx.logger.warn(`jobs: detail update on settled job ${job.id} dropped`) + this.selfCtx.logger.warn(`jobs: progress update on settled job ${job.id} dropped`) return } - state.detail = detail - if (job !== undefined) this.notifyChanged(job.owner) + state.progress = line + if (job !== undefined) this.emit({ type: 'progress', job: this.view(job) }, job.owner) } /** - * Drop retained head chunks until the ring fits `cap`; a single oversized - * chunk keeps only its UTF-8-safe tail with a `gapBefore` marker. Offsets - * stay absolute: `earliest` advances over everything dropped. + * Record the first terminal outcome, release waiters, then announce the + * settlement. First-wins preserves a teardown force-failure against late + * producer settlement. The settled event follows every released waiter, so + * a consumer that claims while waiting always claims before the event. */ - private trimRecord(record: RecordState, cap: number): void { - while (record.retainedBytes > cap && record.chunks.length > 1) { - const dropped = record.chunks.shift() - /* v8 ignore next -- the length guard proves shift() returns a chunk; the check only satisfies noUncheckedIndexedAccess. */ - if (dropped === undefined) break - record.retainedBytes -= dropped.bytes - } - const single = record.chunks.length === 1 ? record.chunks[0] : undefined - if (single !== undefined && single.bytes > cap) { - const tail = utf8Tail(single.text, cap) - single.at += single.bytes - tail.bytes - single.text = tail.text - single.bytes = tail.bytes - single.gapBefore = true - record.retainedBytes = tail.bytes - } - record.earliest = record.chunks[0]?.at ?? record.total - } - - /** - * Record the first terminal outcome, release waiters, then announce - * completion. First-wins preserves a teardown force-failure against late - * producer settlement. Pending waits mark the job reported before listeners - * run. Completion is announced last because a reporter may open a model turn - * synchronously: every other observer of this settlement must already have - * seen the committed record. - */ - private settle(job: TrackedTask, outcome: JobOutcome): void { + private settle(job: TrackedJob, outcome: JobOutcome, cause: JobSettleCause): void { if (isTerminal(job.status)) return job.status = outcome.status - // A terminal detail replaces the live progress line; without one the last - // progress line stays, still describing what the job was doing. - if (outcome.detail !== undefined) job.state.detail = outcome.detail - job.output = outcome.output + // A killed settlement carries the recorded kill reason in its detail: + // producer facts first (`signal: SIGTERM; cancelled by the user`). A job + // that outran its kill request (settled `completed`/`failed`) keeps the + // producer detail alone — the reason describes a kill that never landed. + if (outcome.status === 'killed' && job.killReason !== undefined) { + job.detail = outcome.detail !== undefined + ? `${outcome.detail}; ${job.killReason}` + : job.killReason + } else if (outcome.detail !== undefined) { + job.detail = outcome.detail + } + job.state.progress = undefined + job.result = outcome.result job.finishedAt = Date.now() - if (job.waiters > 0) job.reported = true - // Settlement ends the record: trim to the settled cap before any observer - // reads the terminal snapshot. - const record = job.state.record - if (record !== undefined) this.trimRecord(record, this.settledRetainBytes) - const snapshot = this.snapshot(job) + // Settlement ends the stream: trim to the settled cap before any observer + // reads the terminal projection. + job.ring.trim(this.settledRetainBytes) const waitResolvers = [...job.waitResolvers] job.waitResolvers.clear() for (const resolveWait of waitResolvers) resolveWait() job.markSettled() - this.notifyChanged(job.owner) - // The record stream ends with settlement; the signal follows the committed - // visible-set change and precedes completion listeners, which stay last. - if (record !== undefined) this.notifyOutput(job) - if (this.listenersClosed) return - for (const listener of this.listenersFor(job.owner)) { - try { - const returned = listener(snapshot, job.owner) - void Promise.resolve(returned).catch((error: unknown) => { - this.selfCtx.logger.warn(`jobs: onJobDone listener rejected for ${job.id}: ${String(error)}`) - }) - } catch (error: unknown) { - this.selfCtx.logger.warn(`jobs: onJobDone listener threw for ${job.id}: ${String(error)}`) - } - } + this.emit({ type: 'settled', job: this.view(job), cause }, job.owner) + // The ring's stream ends with settlement; the signal follows the committed + // settlement so an observer that wakes on it reads the terminal state. + this.emitOutput(job) } /** * Attach one awaited cleanup through the exact owner's scope. This survives * producer reloads and joins agent quiescence; the retained disposer lets - * service teardown detach the cross-fiber effect. Fails when the registry is - * absent or the owner is not its currently registered instance. + * service teardown detach the cross-fiber effect. */ private ensureOwnerCleanup(owner: Agent): void { - const ownerId = owner.id - const agents = this.selfCtx.get('agents') - if (agents === undefined) { - throw new Error('background job ownership requires the agent registry (load @deepseek-ai/dsh-agent)') - } - if (agents.get(ownerId) !== owner) { - throw new Error(`agent "${ownerId}" is not the registered agent instance (background job owner must be live)`) - } if (this.ownerCleanups.has(owner)) return // Record only after attach succeeds; a disposing scope rejects new effects. const detach = owner.ctx.effect(() => async () => { @@ -700,31 +562,30 @@ export class LocalJobRegistry extends JobRegistry { const owned = [...this.store.values()].filter(job => job.owner === owner) this.cancelForTeardown(owned, 'owner disposed') await Promise.all(owned.map(job => job.settled)) - for (const job of owned) this.store.delete(job.id) - // Removal is the one visible-set change no per-job record carries, so it - // must be announced here or an observer keeps the dropped rows forever. - if (owned.length > 0) this.notifyChanged(owner) + this.remove(owned) + } + + /** Drop settled records and announce each removal, the one visible-set change no per-job record carries. */ + private remove(jobs: readonly TrackedJob[]): void { + for (const job of jobs) { + this.store.delete(job.id) + this.emit({ type: 'removed', job: this.view(job) }, job.owner) + } } /** - * Close listeners, cancel live jobs, await settlement, and detach owner + * Cancel live jobs, await settlement, drop every record, and detach owner * effects. Throwing cancels are force-failed to avoid teardown deadlock. */ private async disposeAll(): Promise { - // The flag is the whole guard: each layer entry's undo belongs to the fiber - // that registered it, so this service may not drop them on its own way out. - this.listenersClosed = true const all = [...this.store.values()] this.cancelForTeardown(all, 'jobs service disposed') await Promise.all(all.map(job => job.settled)) - // Distinct owners whose records just disappeared. A change observer files - // into the layer of the context that registered it, so a consumer mounted - // outside this service — the api-proxy carrier registers from the mux - // stream — is still reachable here. Without this it keeps the rows it last - // received after a registry reload. - const emptied = new Set(all.map(job => job.owner)) - this.store.clear() - for (const owner of emptied) this.notifyChanged(owner) + // A subscriber mounted outside this service — the job controller's rows + // stream registers from its own context — is still reachable here. + // Without the removals it keeps the rows it last received after a + // registry reload. + this.remove(all) // Detach cross-fiber owner effects after the shared store is quiescent. const ownerCleanups = [...this.ownerCleanups.values()] this.ownerCleanups.clear() @@ -736,28 +597,24 @@ export class LocalJobRegistry extends JobRegistry { * force-fails the record and reports a possible orphan; a cancel that returns * without settling remains indistinguishable from a slow stop and may stall. */ - private cancelForTeardown(jobs: TrackedTask[], reason: string): void { + private cancelForTeardown(jobs: TrackedJob[], reason: string): void { for (const job of jobs) { if (isTerminal(job.status)) continue - // Teardown cancellation is a kill without a caller, so it claims the - // terminal report the same way `kill()` does. Nothing will read a notice - // for a job whose owner or service is being destroyed, and a waking - // reporter would spend a model request per teardown layer. This is - // decided before the producer runs: the force-failure below settles the - // record too, so a throwing cancel must not be the one path that - // announces an unreported completion into a disposing owner. - job.reported = true + // Whatever settles this job from here on, its owner or the service is + // being destroyed: the settlement announces `teardown` so a completion + // reporter does not address a reader that no longer exists. + job.settleCause = 'teardown' try { job.cancel(reason) job.status = 'stopping' // Teardown reaches settlement only after the producer releases, which a // slow stop can defer; announcing the transition here is what keeps an // observer from showing `running` for that whole window. - this.notifyChanged(job.owner) + this.emit({ type: 'stopping', job: this.view(job) }, job.owner) } catch (error: unknown) { const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}` this.selfCtx.logger.warn(`jobs: cancel of ${job.id} threw during teardown; job record forced failed and work may be orphaned: ${String(error)}`) - this.settle(job, { status: 'failed', detail }) + this.settle(job, { status: 'failed', detail }, 'teardown') } } } diff --git a/packages/jobs/jobs-local/tests/events.spec.ts b/packages/jobs/jobs-local/tests/events.spec.ts new file mode 100644 index 0000000000..b77a4e5c8c --- /dev/null +++ b/packages/jobs/jobs-local/tests/events.spec.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { ScopedLayers, bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' +import { JobId } from '@deepseek-ai/dsh-jobs' +import type { JobEvent, JobView } from '@deepseek-ai/dsh-jobs' +import { JobEventHub, JobLayer } from '../src/events.ts' + +function agentIn(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { + const id = SessionId(rawId) + let agentCtx = ctx.plugin(() => {}).ctx + if (presetScope !== undefined) { + const key = {} + bindScopeParent(key, presetScope) + agentCtx = createScope(agentCtx, key).ctx + } + const session = Session.create(id) + return { + id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: agentCtx, + } as unknown as Agent +} + +function view(owner: Agent | undefined): JobView { + return { + id: JobId('bash-1'), + kind: 'bash', + label: 'x', + ...owner !== undefined ? { owner: owner.id } : {}, + status: 'running', + startedAt: 0, + output: { total: 0, earliest: 0 }, + } +} + +function bench() { + const ctx = new Context() + const warnings: string[] = [] + const layers = new ScopedLayers(() => new JobLayer(), () => {}) + const hub = new JobEventHub(layers, message => warnings.push(message)) + return { ctx, hub, warnings } +} + +describe('JobEventHub', () => { + it('delivers by owner: that session plus every unowned job, never another session', () => { + const { ctx, hub } = bench() + const alice = agentIn(ctx, 'alice') + const bob = agentIn(ctx, 'bob') + const seen: (string | undefined)[] = [] + hub.subscribe(ctx, { owner: alice.id }, (event) => { seen.push(event.type === 'output' ? event.owner : event.job.owner) }) + + hub.emit({ type: 'registered', job: view(alice) }, alice) + hub.emit({ type: 'registered', job: view(bob) }, bob) + hub.emit({ type: 'registered', job: view(undefined) }, undefined) + hub.emit({ type: 'output', id: JobId('bash-1'), owner: bob.id, total: 1 }, bob) + hub.emit({ type: 'output', id: JobId('bash-1'), total: 1 }, undefined) + expect(seen).toEqual(['alice', undefined, undefined]) + }) + + it("delivers 'all' everything and 'scope' only the owners composed under the subscriber", async () => { + const { ctx, hub } = bench() + const mount = createScope(ctx, {}) + const otherMount = createScope(ctx, {}) + const alice = agentIn(ctx, 'alice', scopeOf(mount.ctx)) + const bob = agentIn(ctx, 'bob', scopeOf(otherMount.ctx)) + + const all: string[] = [] + hub.subscribe(ctx, { owners: 'all' }, (event) => { all.push(event.type === 'output' ? String(event.owner) : String(event.job.owner)) }) + const scoped: string[] = [] + await mount.ctx.plugin((pluginCtx: Context) => { + hub.subscribe(pluginCtx, { owners: 'scope' }, (event) => { scoped.push(event.type === 'output' ? String(event.owner) : String(event.job.owner)) }) + }) + const unscoped: string[] = [] + hub.subscribe(ctx, { owners: 'scope' }, (event) => { unscoped.push(event.type === 'output' ? String(event.owner) : String(event.job.owner)) }) + + hub.emit({ type: 'registered', job: view(alice) }, alice) + hub.emit({ type: 'registered', job: view(bob) }, bob) + hub.emit({ type: 'registered', job: view(undefined) }, undefined) + expect(all).toEqual(['alice', 'bob', 'undefined']) + // A preset-scoped subscription hears its own agents only; an unowned job + // has no chain to walk, so only unscoped subscriptions hear it. + expect(scoped).toEqual(['alice']) + expect(unscoped).toEqual(['alice', 'bob', 'undefined']) + }) + + it('contains a throwing listener and keeps delivering to later ones', () => { + const { ctx, hub, warnings } = bench() + const seen: JobEvent[] = [] + hub.subscribe(ctx, { owners: 'all' }, () => { throw new Error('listener boom') }) + hub.subscribe(ctx, { owners: 'all' }, (event) => { seen.push(event) }) + hub.emit({ type: 'registered', job: view(undefined) }, undefined) + expect(seen).toHaveLength(1) + expect(warnings).toEqual([expect.stringContaining('event listener threw on registered: Error: listener boom')]) + }) + + it('unregisters through the disposer and with the subscribing fiber', async () => { + const { ctx, hub } = bench() + const seen: number[] = [] + const detach = hub.subscribe(ctx, { owners: 'all' }, () => { seen.push(1) }) + const fiber = await ctx.plugin((pluginCtx: Context) => { + hub.subscribe(pluginCtx, { owners: 'scope' }, () => { seen.push(2) }) + hub.subscribe(pluginCtx, { owner: SessionId('nobody') }, () => { seen.push(3) }) + }) + + // Filter-kind order is not a contract; the assertion only fixes it for determinism. + hub.emit({ type: 'registered', job: view(undefined) }, undefined) + expect(seen).toEqual([1, 3, 2]) + detach() + detach() + hub.emit({ type: 'registered', job: view(undefined) }, undefined) + expect(seen).toEqual([1, 3, 2, 3, 2]) + await fiber.dispose() + hub.emit({ type: 'registered', job: view(undefined) }, undefined) + expect(seen).toEqual([1, 3, 2, 3, 2]) + }) + + it('keeps a scoped layer alive only while a subscription or controller remains in it', async () => { + const { ctx, hub } = bench() + const mount = createScope(ctx, {}) + const spy = vi.fn() + const fiber = await mount.ctx.plugin((pluginCtx: Context) => { + hub.subscribe(pluginCtx, { owners: 'scope' }, spy) + }) + const alice = agentIn(ctx, 'alice', scopeOf(mount.ctx)) + hub.emit({ type: 'registered', job: view(alice) }, alice) + expect(spy).toHaveBeenCalledTimes(1) + await fiber.dispose() + hub.emit({ type: 'registered', job: view(alice) }, alice) + expect(spy).toHaveBeenCalledTimes(1) + expect(new JobLayer().isEmpty()).toBe(true) + }) +}) diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 5bcb9b156e..550a713f6b 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -6,7 +6,9 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { JobId } from '@deepseek-ai/dsh-jobs' -import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RecordingJob } from '@deepseek-ai/dsh-jobs' +import type { + JobEvent, JobEventFilter, JobHandle, JobHooks, JobKind, JobOutcome, JobOutputSource, JobSpec, JobView, VisibleJobs, +} from '@deepseek-ai/dsh-jobs' import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local' declare module '@deepseek-ai/dsh-jobs' { @@ -55,25 +57,36 @@ async function disposeAgentScope(agent: Agent): Promise { await dispose() } -/** A controllable producer start-spec: settle its `done` on demand, record cancels, expose the job face. */ -function producer(overrides: Partial & JobHooks> = {}) { +/** Register a stub agent so the registry can resolve it as a live owner. */ +function liveAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { + const agent = stubAgent(ctx, rawId, presetScope) + ctx.agents.register(agent) + return agent +} + +type ProducerOverrides = Partial & JobHooks> & { + owner?: Agent +} + +/** A controllable producer spec: settle its `done` on demand, record cancels, expose the handle. */ +function producer(overrides: ProducerOverrides = {}) { let settle!: (outcome: JobOutcome) => void let reject!: (error: unknown) => void const cancels: (string | undefined)[] = [] - const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, record, ...hookOverrides } = overrides + const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, output, ...hookOverrides } = overrides const hooks: JobHooks = { cancel(reason) { cancels.push(reason) }, done: new Promise((res, rej) => { settle = res; reject = rej }), ...hookOverrides, } - let started: RecordingJob | undefined - const spec: JobStart = { + let started: JobHandle | undefined + const spec: JobSpec = { kind, label, - ...owner !== undefined ? { owner } : {}, + ...owner !== undefined ? { owner: owner.id } : {}, ...outputLimitBytes !== undefined ? { outputLimitBytes } : {}, - ...record !== undefined ? { record } : {}, - run: (job: RecordingJob) => { + ...output !== undefined ? { output } : {}, + run: (job) => { started = job return hooks }, @@ -83,7 +96,7 @@ function producer(overrides: Partial & JobHooks> = {}) { settle, reject, cancels, - job(): RecordingJob { + job(): JobHandle { if (started === undefined) throw new Error('producer not started') return started }, @@ -98,12 +111,24 @@ async function harness(config: JobsConfig = {}) { return ctx } +/** The caller-bound view for an agent, or the anonymous view. */ +function jobsOf(ctx: Context, agent?: Agent): VisibleJobs { + return ctx.jobs.visibleTo(agent?.id) +} + +/** Collect events matching `filter`; `types` narrows what is recorded. */ +function collect(ctx: Context, filter: JobEventFilter = { owners: 'all' }, types?: JobEvent['type'][]): JobEvent[] { + const seen: JobEvent[] = [] + ctx.jobs.events.subscribe(filter, (event) => { + if (types === undefined || types.includes(event.type)) seen.push(event) + }) + return seen +} + /** * Attach a job controller the way `tool-jobs` does: from a plugin whose own * `inject` resolves `ctx.jobs`, so the service method binds to the REGISTERING - * context and the controller files into that context's scope layer. Reading the - * service off a bare scoped context instead throws `cannot get property "jobs" - * without inject`, which is the same rule the shipped plugin obeys. + * context and the controller files into that context's scope layer. * @param ctx - the context whose scope should own the controller. */ async function attachControllerIn(ctx: Context): Promise { @@ -124,9 +149,22 @@ function waitResolverCount(ctx: Context, id: JobId): number { return job.waitResolvers.size } +/** A scripted pull source: each read shifts the next scripted result. */ +function scriptedSource(reads: { text: string; lossy?: boolean }[], channel?: 'stdout' | 'stderr'): JobOutputSource { + let offset = 0 + return { + ...channel !== undefined ? { channel } : {}, + read() { + const next = reads.shift() ?? { text: '' } + offset += Buffer.byteLength(next.text, 'utf8') + return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false } + }, + } +} + describe('LocalJobRegistry.start', () => { - it('preserves the SessionId brand on public owner snapshots', () => { - expectTypeOf().toEqualTypeOf() + it('preserves the SessionId brand on public owner projections', () => { + expectTypeOf().toEqualTypeOf() }) it('refuses to register while no job controller serves the owner', async () => { @@ -146,10 +184,8 @@ describe('LocalJobRegistry.start', () => { const withoutControls = createScope(ctx, {}) await attachControllerIn(withControls.ctx) - const served = stubAgent(ctx, 'served', scopeOf(withControls.ctx)) - const unserved = stubAgent(ctx, 'unserved', scopeOf(withoutControls.ctx)) - ctx.agents.register(served) - ctx.agents.register(unserved) + const served = liveAgent(ctx, 'served', scopeOf(withControls.ctx)) + const unserved = liveAgent(ctx, 'unserved', scopeOf(withoutControls.ctx)) expect(() => ctx.jobs.start(producer({ owner: served }).spec)).not.toThrow() expect(() => ctx.jobs.start(producer({ owner: unserved }).spec)) @@ -166,8 +202,7 @@ describe('LocalJobRegistry.start', () => { // The host-plane composition's own controls: no scope, so the global layer // holds them and every owner's read includes it. await attachControllerIn(ctx) - const scoped = stubAgent(ctx, 'scoped', scopeOf(createScope(ctx, {}).ctx)) - ctx.agents.register(scoped) + const scoped = liveAgent(ctx, 'scoped', scopeOf(createScope(ctx, {}).ctx)) expect(() => ctx.jobs.start(producer({ owner: scoped }).spec)).not.toThrow() expect(() => ctx.jobs.start(producer().spec)).not.toThrow() @@ -189,6 +224,11 @@ describe('LocalJobRegistry.start', () => { }, ) + it('rejects a non-positive pump interval', async () => { + const ctx = new Context() + await expect(ctx.plugin(LocalJobRegistry, { pumpPollMs: 0 })).rejects.toThrow() + }) + it('accepts the largest safe integer limit', async () => { const ctx = await harness({ maxConcurrentJobsPerOwner: Number.MAX_SAFE_INTEGER }) expect(ctx.jobs).toBeInstanceOf(LocalJobRegistry) @@ -200,7 +240,7 @@ describe('LocalJobRegistry.start', () => { for (const job of live) ctx.jobs.start(job.spec) const blocked = producer() - const run = vi.fn((job: RecordingJob) => blocked.spec.run(job)) + const run = vi.fn((job: JobHandle) => blocked.spec.run(job)) expect(() => ctx.jobs.start({ ...blocked.spec, run })) .toThrow('background job limit reached for this owner (limit: 10)') expect(run).not.toHaveBeenCalled() @@ -213,7 +253,7 @@ describe('LocalJobRegistry.start', () => { expect(ctx.jobs.start(first.spec)).toBe('bash-1') const blocked = producer() - const run = vi.fn((job: RecordingJob) => blocked.spec.run(job)) + const run = vi.fn((job: JobHandle) => blocked.spec.run(job)) expect(() => ctx.jobs.start({ ...blocked.spec, run })) .toThrow('use job_kill to stop an unneeded job, wait for it to finish, then retry') expect(run).not.toHaveBeenCalled() @@ -227,7 +267,7 @@ describe('LocalJobRegistry.start', () => { const ctx = await harness({ maxConcurrentJobsPerOwner: 1 }) const first = producer() const id = ctx.jobs.start(first.spec) - expect(ctx.jobs.kill(id)).toBe('requested') + expect(jobsOf(ctx).kill(id)).toBe('requested') const replacement = producer() expect(() => ctx.jobs.start(replacement.spec)).toThrow('(limit: 1)') @@ -249,17 +289,18 @@ describe('LocalJobRegistry.start', () => { }, ) - it('isolates exact owners, replacement objects with the same session id, and the unowned bucket', async () => { + it('buckets by exact owner: a same-session replacement and the unowned bucket count separately', async () => { const ctx = await harness({ maxConcurrentJobsPerOwner: 1 }) const oldOwner = stubAgent(ctx, 'shared-session') const detachOld = ctx.agents.register(oldOwner) const oldTask = producer({ owner: oldOwner }) ctx.jobs.start(oldTask.spec) - const otherOwner = stubAgent(ctx, 'other-session') - ctx.agents.register(otherOwner) - expect(() => ctx.jobs.start(producer({ owner: otherOwner }).spec)).not.toThrow() + liveAgent(ctx, 'other-session') + expect(() => ctx.jobs.start({ ...producer().spec, owner: SessionId('other-session') })).not.toThrow() + // The session id resolves to whichever agent is live now; a replacement + // instance starts with its own bucket even while the old job runs on. detachOld() const replacement = stubAgent(ctx, 'shared-session') ctx.agents.register(replacement) @@ -267,8 +308,6 @@ describe('LocalJobRegistry.start', () => { ctx.jobs.start(producer().spec) expect(() => ctx.jobs.start(producer().spec)).toThrow('(limit: 1)') - expect(() => ctx.jobs.start(producer({ owner: oldOwner }).spec)) - .toThrow('is not the registered agent instance') oldTask.settle({ status: 'completed' }) await tick() @@ -285,94 +324,81 @@ describe('LocalJobRegistry.start', () => { }) describe('LocalJobRegistry reads and settlement', () => { - it('stream kinds read a consuming delta; terminal reads mark reported', async () => { + it('read consumes the ring from the model cursor and advances it; readAt never moves it', async () => { const ctx = await harness() - const chunks = ['first', '', 'rest'] - const p = producer({ readOutput: () => chunks.shift() ?? '' }) + const p = producer() const id = ctx.jobs.start(p.spec) + const jobs = jobsOf(ctx) + p.job().append('first', { channel: 'stdout' }) + p.job().append('err', { channel: 'stderr' }) - expect(ctx.jobs.read(id)).toMatchObject({ text: 'first', snapshot: { status: 'running', reported: false } }) - expect(ctx.jobs.read(id).text).toBe('') + const first = jobs.read(id) + expect(first.chunks).toEqual([{ at: 0, text: 'first', channel: 'stdout' }, { at: 5, text: 'err', channel: 'stderr' }]) + expect(first.lossy).toBe(false) + expect(first.result).toBeUndefined() + expect(first.job).toMatchObject({ status: 'running', output: { total: 8, earliest: 0 } }) + expect(jobs.read(id).chunks).toEqual([]) - p.settle({ status: 'completed', detail: 'exit code: 0' }) - await tick() - const read = ctx.jobs.read(id) - expect(read.text).toBe('rest') - expect(read.snapshot).toMatchObject({ status: 'completed', detail: 'exit code: 0', reported: true }) - expect(read.snapshot.finishedAt).toBeTypeOf('number') + // A non-consuming read from the head sees everything again, and leaves the cursor alone. + expect(jobs.readAt(id, 0).chunks).toHaveLength(2) + p.job().append('rest') + expect(jobs.read(id).chunks).toEqual([{ at: 8, text: 'rest' }]) }) - it('projects a producer-owned model output limit into reads and snapshots', async () => { - const ctx = await harness() - const p = producer({ outputLimitBytes: 64, readOutput: () => 'delta' }) - const id = ctx.jobs.start(p.spec) - expect(ctx.jobs.read(id)).toMatchObject({ - text: 'delta', snapshot: { outputLimitBytes: 64 }, - }) - expect(ctx.jobs.get(id)).toMatchObject({ outputLimitBytes: 64 }) - }) - - it('final-output kinds read empty while live, the outcome output idempotently once settled', async () => { + it('hands the producer result out once, on the first read after settlement', async () => { const ctx = await harness() const p = producer({ kind: 'subagent', label: 'research job' }) const id = ctx.jobs.start(p.spec) + const jobs = jobsOf(ctx) + expect(jobs.read(id)).toMatchObject({ chunks: [], job: { status: 'running' } }) - expect(ctx.jobs.read(id)).toMatchObject({ text: '', snapshot: { status: 'running' } }) - - p.settle({ status: 'completed', output: 'final answer' }) + p.settle({ status: 'completed', result: 'final answer' }) await tick() - expect(ctx.jobs.read(id).text).toBe('final answer') - expect(ctx.jobs.read(id).text).toBe('final answer') // idempotent, not consumed + const read = jobs.read(id) + expect(read.result).toBe('final answer') + expect(read.job).toMatchObject({ status: 'completed' }) + expect(read.job.finishedAt).toBeTypeOf('number') + expect(jobs.read(id).result).toBeUndefined() }) - it('a settled job without output reads as empty text', async () => { + it('a settled job without a result reads as empty', async () => { const ctx = await harness() const p = producer({ kind: 'subagent' }) const id = ctx.jobs.start(p.spec) p.settle({ status: 'failed', detail: 'max-tokens' }) await tick() - expect(ctx.jobs.read(id)).toMatchObject({ text: '', snapshot: { status: 'failed', detail: 'max-tokens' } }) + expect(jobsOf(ctx).read(id)).toMatchObject({ chunks: [], job: { status: 'failed', detail: 'max-tokens' } }) + }) + + it('projects a producer-owned model output limit into views', async () => { + const ctx = await harness() + const p = producer({ outputLimitBytes: 64 }) + const id = ctx.jobs.start(p.spec) + expect(jobsOf(ctx).get(id)).toMatchObject({ outputLimitBytes: 64 }) + expect(jobsOf(ctx).read(id).job).toMatchObject({ outputLimitBytes: 64 }) }) it('throws for unknown job ids', async () => { const ctx = await harness() - expect(() => ctx.jobs.read(JobId('bash-99'))).toThrow('unknown job bash-99') + expect(() => jobsOf(ctx).read(JobId('bash-99'))).toThrow('unknown job bash-99') + expect(() => jobsOf(ctx).get(JobId('bash-99'))).toThrow('unknown job bash-99') }) - it('notifies onJobDone once per job with containment across listeners', async () => { + it('announces settlement once with containment across listeners', async () => { const ctx = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(() => { throw new Error('listener boom') }) - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + ctx.jobs.events.subscribe({ owners: 'all' }, () => { throw new Error('listener boom') }) + const seen = collect(ctx, { owners: 'all' }, ['settled']) const p = producer() const id = ctx.jobs.start(p.spec) p.settle({ status: 'completed', detail: 'exit code: 0' }) await tick() - expect(seen).toHaveLength(1) - expect(seen[0]).toMatchObject({ id, status: 'completed', reported: false }) + expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id, status: 'completed', detail: 'exit code: 0' }) }]) expect(warn).toHaveBeenCalledWith(expect.stringContaining('listener boom')) }) - it('contains a rejecting onJobDone listener without starving later listeners', async () => { - const ctx = await harness() - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const seen: JobId[] = [] - ctx.jobs.onJobDone(async () => { throw new Error('async listener boom') }) - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot.id)) - - const p = producer() - const id = ctx.jobs.start(p.spec) - p.settle({ status: 'completed' }) - await tick() - - expect(seen).toEqual([id]) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('onJobDone listener rejected')) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('async listener boom')) - }) - it("contains rejection from the producer's done promise as a failed outcome (producer contract violation)", async () => { const ctx = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) @@ -381,19 +407,19 @@ describe('LocalJobRegistry reads and settlement', () => { p.reject(new Error('transport exploded')) await tick() - expect(ctx.jobs.read(id).snapshot).toMatchObject({ status: 'failed', detail: 'Error: transport exploded' }) + expect(jobsOf(ctx).get(id)).toMatchObject({ status: 'failed', detail: 'Error: transport exploded' }) expect(warn).toHaveBeenCalledWith(expect.stringContaining('producer contract violation')) }) - it('unregisters onJobDone listeners with the contributing fiber (HMR safety)', async () => { + it('unregisters event listeners with the contributing fiber (HMR safety)', async () => { const ctx = await harness() const seen: string[] = [] const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.jobs.onJobDone(snapshot => void seen.push(snapshot.id)) + inner.jobs.events.subscribe({ owners: 'all' }, event => void seen.push(event.type)) }, { inject: ['jobs'] })) await fiber.dispose() // The returned disposer detaches too (the non-fiber path). - const detach = ctx.jobs.onJobDone(snapshot => void seen.push(snapshot.id)) + const detach = ctx.jobs.events.subscribe({ owners: 'all' }, event => void seen.push(event.type)) detach() const p = producer() @@ -405,22 +431,39 @@ describe('LocalJobRegistry reads and settlement', () => { }) describe('LocalJobRegistry.kill', () => { - it('cancels a live job with the forwarded reason and suppresses the notice', async () => { + it('cancels a live job with the forwarded reason and settles with cause kill', async () => { const ctx = await harness() - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + const seen = collect(ctx, { owners: 'all' }, ['stopping', 'settled']) const p = producer() const id = ctx.jobs.start(p.spec) - expect(ctx.jobs.kill(id, undefined, 'no longer needed')).toBe('requested') + expect(jobsOf(ctx).kill(id, { reason: 'no longer needed' })).toBe('requested') expect(p.cancels).toEqual(['no longer needed']) - expect(ctx.jobs.list()[0]).toMatchObject({ status: 'stopping', reported: true }) + expect(jobsOf(ctx).list()[0]).toMatchObject({ status: 'stopping' }) + expect(seen).toEqual([{ type: 'stopping', job: expect.objectContaining({ id, status: 'stopping' }) }]) - p.settle({ status: 'killed' }) + p.settle({ status: 'killed', detail: 'signal: SIGTERM' }) await tick() - // The listener still fires (telemetry may care), but carries reported: true - // so the notice path suppresses its redundant "finished". - expect(seen[0]).toMatchObject({ id, status: 'killed', reported: true }) + expect(seen[1]).toEqual({ + type: 'settled', + cause: 'kill', + job: expect.objectContaining({ id, status: 'killed', detail: 'signal: SIGTERM; no longer needed' }), + }) + }) + + it('merges a reason without producer detail and keeps producer detail alone when the job outran the kill', async () => { + const ctx = await harness() + const killed = producer() + const killedId = ctx.jobs.start(killed.spec) + jobsOf(ctx).kill(killedId, { reason: 'cancelled by the user' }) + killed.settle({ status: 'killed' }) + const outran = producer() + const outranId = ctx.jobs.start(outran.spec) + jobsOf(ctx).kill(outranId, { reason: 'too late' }) + outran.settle({ status: 'completed', detail: 'exit code: 0' }) + await tick() + expect(jobsOf(ctx).get(killedId).detail).toBe('cancelled by the user') + expect(jobsOf(ctx).get(outranId).detail).toBe('exit code: 0') }) it('reports an already-finished job instead of failing', async () => { @@ -429,13 +472,12 @@ describe('LocalJobRegistry.kill', () => { const id = ctx.jobs.start(p.spec) p.settle({ status: 'completed' }) await tick() - expect(ctx.jobs.kill(id)).toBe('already-finished') + expect(jobsOf(ctx).kill(id)).toBe('already-finished') }) it('propagates a throwing producer cancel and leaves the job untouched', async () => { const ctx = await harness() - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + const seen = collect(ctx, { owners: 'all' }, ['stopping', 'settled']) let broken = true let settle!: (outcome: JobOutcome) => void const id = ctx.jobs.start({ @@ -446,38 +488,41 @@ describe('LocalJobRegistry.kill', () => { done: new Promise((res) => { settle = res }), }), }) - expect(() => ctx.jobs.kill(id)).toThrow('cancel boom') - // The failed kill mutated NOTHING: still running, notice not suppressed, - // and a later (successful) kill still works. - expect(ctx.jobs.get(id)).toMatchObject({ status: 'running', reported: false }) + expect(() => jobsOf(ctx).kill(id)).toThrow('cancel boom') + // The failed kill mutated NOTHING: still running, and the settlement + // still reports the producer as its cause. + expect(jobsOf(ctx).get(id)).toMatchObject({ status: 'running' }) + expect(seen).toEqual([]) settle({ status: 'completed' }) await tick() - expect(seen[0]).toMatchObject({ id, reported: false }) // notice would still fire + expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id }) }]) broken = false - expect(ctx.jobs.kill(id)).toBe('already-finished') + expect(jobsOf(ctx).kill(id)).toBe('already-finished') }) }) describe('LocalJobRegistry.wait', () => { - it('resolves with the terminal snapshot when the job settles, marked reported', async () => { + it('resolves with the terminal projection when the job settles, before the settled event', async () => { const ctx = await harness() - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + const order: string[] = [] + ctx.jobs.events.subscribe({ owners: 'all' }, (event) => { if (event.type === 'settled') order.push('event') }) const p = producer() const id = ctx.jobs.start(p.spec) - const wait = ctx.jobs.wait(id, 5_000) + const wait = jobsOf(ctx).wait(id, 5_000).then((view) => { order.push('wait'); return view }) p.settle({ status: 'completed', detail: 'exit code: 0' }) - expect(await wait).toMatchObject({ status: 'completed', reported: true }) - // A waiting reader claims delivery before completion listeners inspect the snapshot. - expect(seen[0]).toMatchObject({ id, reported: true }) + expect(await wait).toMatchObject({ status: 'completed' }) + // Waiters are released synchronously inside the settlement, ahead of the + // event; their continuations run afterwards, which is why a claim made + // when the wait starts (not when it returns) is what stays ordered. + expect(order).toEqual(['event', 'wait']) }) - it('returns the live snapshot on timeout without marking reported', async () => { + it('returns the live projection on timeout', async () => { const ctx = await harness() const id = ctx.jobs.start(producer().spec) - expect(await ctx.jobs.wait(id, 5)).toMatchObject({ status: 'running', reported: false }) + expect(await jobsOf(ctx).wait(id, 5)).toMatchObject({ status: 'running' }) }) it('unregisters timed-out and aborted wait resolvers while the job remains live', async () => { @@ -485,19 +530,19 @@ describe('LocalJobRegistry.wait', () => { const id = ctx.jobs.start(producer().spec) for (let index = 0; index < 3; index += 1) { - const wait = ctx.jobs.wait(id, 5) + const wait = jobsOf(ctx).wait(id, 5) expect(waitResolverCount(ctx, id)).toBe(1) await expect(wait).resolves.toMatchObject({ status: 'running' }) expect(waitResolverCount(ctx, id)).toBe(0) } const controller = new AbortController() - const wait = ctx.jobs.wait(id, 5_000, undefined, controller.signal) + const wait = jobsOf(ctx).wait(id, 5_000, controller.signal) expect(waitResolverCount(ctx, id)).toBe(1) controller.abort() await expect(wait).rejects.toThrow('wait aborted') expect(waitResolverCount(ctx, id)).toBe(0) - expect(ctx.jobs.get(id).status).toBe('running') + expect(jobsOf(ctx).get(id).status).toBe('running') }) it('returns immediately for an already-finished job', async () => { @@ -506,14 +551,14 @@ describe('LocalJobRegistry.wait', () => { const id = ctx.jobs.start(p.spec) p.settle({ status: 'completed' }) await tick() - expect(await ctx.jobs.wait(id, 5_000)).toMatchObject({ status: 'completed', reported: true }) + expect(await jobsOf(ctx).wait(id, 5_000)).toMatchObject({ status: 'completed' }) }) it('rejects a non-positive or non-finite timeout', async () => { const ctx = await harness() const id = ctx.jobs.start(producer().spec) - await expect(ctx.jobs.wait(id, 0)).rejects.toThrow('invalid wait timeout') - await expect(ctx.jobs.wait(id, Number.NaN)).rejects.toThrow('invalid wait timeout') + await expect(jobsOf(ctx).wait(id, 0)).rejects.toThrow('invalid wait timeout') + await expect(jobsOf(ctx).wait(id, Number.NaN)).rejects.toThrow('invalid wait timeout') }) it('an aborted signal rejects the wait only — the job stays alive', async () => { @@ -521,120 +566,111 @@ describe('LocalJobRegistry.wait', () => { const id = ctx.jobs.start(producer().spec) const controller = new AbortController() - const wait = ctx.jobs.wait(id, 5_000, undefined, controller.signal) + const wait = jobsOf(ctx).wait(id, 5_000, controller.signal) controller.abort() await expect(wait).rejects.toThrow('wait aborted') - expect(ctx.jobs.list()[0]).toMatchObject({ status: 'running' }) + expect(jobsOf(ctx).list()[0]).toMatchObject({ status: 'running' }) const preAborted = new AbortController() preAborted.abort() - await expect(ctx.jobs.wait(id, 5_000, undefined, preAborted.signal)).rejects.toThrow('wait aborted') + await expect(jobsOf(ctx).wait(id, 5_000, preAborted.signal)).rejects.toThrow('wait aborted') }) - it('an abort racing settlement in the same tick does not swallow the notice', async () => { + it('an abort racing settlement in the same tick rejects the wait and leaves the settlement intact', async () => { const ctx = await harness() - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + const seen = collect(ctx, { owners: 'all' }, ['settled']) const p = producer() const id = ctx.jobs.start(p.spec) const controller = new AbortController() - const wait = ctx.jobs.wait(id, 5_000, undefined, controller.signal) - // Settlement is queued first, so abort must remove the waiter synchronously; - // otherwise settlement suppresses the notice for a reader that receives nothing. + const wait = jobsOf(ctx).wait(id, 5_000, controller.signal) p.settle({ status: 'completed', detail: 'exit code: 0' }) controller.abort() await expect(wait).rejects.toThrow('wait aborted') - expect(seen).toHaveLength(1) - expect(seen[0]).toMatchObject({ id, status: 'completed', reported: false }) + expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id, status: 'completed' }) }]) }) - it('an abort landing after settlement still delivers the terminal snapshot it owes', async () => { + it('an abort landing after settlement still delivers the terminal projection it owes', async () => { const ctx = await harness() const controller = new AbortController() - const seen: JobSnapshot[] = [] // The listener aborts after settlement released this waiter but before its // resolve microtask runs. Releasing waiters ahead of the announcement is // what makes that abort harmless; this is the guard on that ordering. - ctx.jobs.onJobDone((snapshot) => { - seen.push(snapshot) - controller.abort() - }) + ctx.jobs.events.subscribe({ owners: 'all' }, (event) => { if (event.type === 'settled') controller.abort() }) const p = producer() const id = ctx.jobs.start(p.spec) - const wait = ctx.jobs.wait(id, 5_000, undefined, controller.signal) + const wait = jobsOf(ctx).wait(id, 5_000, controller.signal) p.settle({ status: 'completed', detail: 'exit code: 0' }) - await expect(wait).resolves.toMatchObject({ status: 'completed', reported: true }) - expect(seen[0]).toMatchObject({ id, reported: true }) // suppression stays honest: the wait delivered + await expect(wait).resolves.toMatchObject({ status: 'completed' }) }) }) describe('LocalJobRegistry owner isolation', () => { - it('fences read/kill/wait to the owning session and keeps unowned jobs open', async () => { + it('fences every view operation to the owning session and keeps unowned jobs open', async () => { const ctx = await harness() - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + const owner = liveAgent(ctx, 'owner') const other = stubAgent(ctx, 'other') const owned = ctx.jobs.start(producer({ owner }).spec) const open = ctx.jobs.start(producer().spec) // The owner and the unowned job are reachable. - expect(ctx.jobs.read(owned, owner).snapshot.id).toBe(owned) - expect(ctx.jobs.read(open, other).snapshot.id).toBe(open) + expect(jobsOf(ctx, owner).read(owned).job.id).toBe(owned) + expect(jobsOf(ctx, other).read(open).job.id).toBe(open) - // A different session and a no-agent caller are rejected. - expect(() => ctx.jobs.read(owned, other)).toThrow(`job ${owned} belongs to another session`) - expect(() => ctx.jobs.kill(owned, other)).toThrow('belongs to another session') - await expect(ctx.jobs.wait(owned, 10, other)).rejects.toThrow('belongs to another session') - expect(() => ctx.jobs.read(owned)).toThrow('belongs to another session') + // A different session and an anonymous caller are rejected. + const foreign = jobsOf(ctx, other) + expect(() => foreign.read(owned)).toThrow(`job ${owned} belongs to another session`) + expect(() => foreign.get(owned)).toThrow('belongs to another session') + expect(() => foreign.readAt(owned, 0)).toThrow('belongs to another session') + expect(() => foreign.kill(owned)).toThrow('belongs to another session') + await expect(foreign.wait(owned, 10)).rejects.toThrow('belongs to another session') + expect(() => jobsOf(ctx).read(owned)).toThrow('belongs to another session') }) it('list() shows only caller-owned plus unowned jobs', async () => { const ctx = await harness() - const alice = stubAgent(ctx, 'alice') - const bob = stubAgent(ctx, 'bob') - ctx.agents.register(alice) - ctx.agents.register(bob) + const alice = liveAgent(ctx, 'alice') + const bob = liveAgent(ctx, 'bob') const aliceTask = ctx.jobs.start(producer({ owner: alice }).spec) const bobTask = ctx.jobs.start(producer({ owner: bob }).spec) const openTask = ctx.jobs.start(producer({ kind: 'subagent' }).spec) - expect(ctx.jobs.list(alice).map(t => t.id)).toEqual([aliceTask, openTask]) - expect(ctx.jobs.list(bob).map(t => t.id)).toEqual([bobTask, openTask]) - expect(ctx.jobs.list().map(t => t.id)).toEqual([openTask]) + expect(jobsOf(ctx, alice).list().map(t => t.id)).toEqual([aliceTask, openTask]) + expect(jobsOf(ctx, bob).list().map(t => t.id)).toEqual([bobTask, openTask]) + expect(jobsOf(ctx).list().map(t => t.id)).toEqual([openTask]) }) it('rejects an owned registration when no agent registry is mounted', async () => { const ctx = new Context() await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') - expect(() => ctx.jobs.start(producer({ owner: stubAgent(ctx, 'a') }).spec)) + expect(() => ctx.jobs.start({ ...producer().spec, owner: SessionId('a') })) .toThrow('background job ownership requires the agent registry') // The failed registration mutated nothing: no stored job, counter untouched. - expect(ctx.jobs.list()).toEqual([]) + expect(jobsOf(ctx).list()).toEqual([]) expect(ctx.jobs.start(producer().spec)).toBe('bash-1') }) - it('a failed owner-cleanup attach leaves the registry unchanged and does not poison the owner', async () => { + it('rejects a session without a live agent and attaches cleanup once it has one', async () => { const ctx = await harness() const ghost = stubAgent(ctx, 'ghost') // never registered in ctx.agents - // Exact-instance validation precedes registry mutation and cleanup attachment. + // Owner resolution precedes registry mutation and cleanup attachment. expect(() => ctx.jobs.start(producer({ owner: ghost }).spec)) - .toThrow('is not the registered agent instance') - expect(ctx.jobs.list(ghost)).toEqual([]) + .toThrow('has no live agent') + expect(jobsOf(ctx, ghost).list()).toEqual([]) - // A later valid registration must still attach cleanup for the same object. + // A later valid registration must still attach cleanup for the live instance. ctx.agents.register(ghost) const cancels: (string | undefined)[] = [] let settle!: (outcome: JobOutcome) => void const id = ctx.jobs.start({ kind: 'bash', label: 'after retry', - owner: ghost, + owner: ghost.id, run: () => ({ cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) }, done: new Promise((res) => { settle = res }), @@ -643,42 +679,37 @@ describe('LocalJobRegistry owner isolation', () => { expect(id).toBe('bash-1') // the failed attempt burned no counter await disposeAgentScope(ghost) expect(cancels).toEqual(['owner disposed']) - expect(ctx.jobs.list(ghost)).toEqual([]) + expect(jobsOf(ctx, ghost).list()).toEqual([]) }) - it('rejects a stale owner instance after another agent reuses its id', async () => { + it('binds a session id to the agent that is live now, and reads stay keyed by that id', async () => { const ctx = await harness() const staleOwner = stubAgent(ctx, 'owner') const unregisterStale = ctx.agents.register(staleOwner) unregisterStale() - const currentOwner = stubAgent(ctx, 'owner') ctx.agents.register(currentOwner) - const current = producer({ owner: currentOwner }) - ctx.jobs.start(current.spec) // Attach the current owner's cleanup first. - const stale = producer({ owner: staleOwner }) - const staleRun = vi.fn((job: RecordingJob) => stale.spec.run(job)) - expect(() => ctx.jobs.start({ ...stale.spec, run: staleRun })) - .toThrow('is not the registered agent instance') - expect(staleRun).not.toHaveBeenCalled() - // Access is keyed by the unified session id, so a reconnect carrying the - // same identity can observe the current job even though stale ownership - // registration is rejected by exact-instance validation. - expect(ctx.jobs.list(staleOwner)).toHaveLength(1) - expect(ctx.jobs.list(currentOwner)).toHaveLength(1) + const current = producer({ owner: currentOwner }) + const id = ctx.jobs.start(current.spec) + // The stale instance shares the id, so the fence lets it read; ownership + // (and therefore teardown) belongs to the live instance. + expect(jobsOf(ctx, staleOwner).get(id).owner).toBe(currentOwner.id) + await disposeAgentScope(staleOwner) + expect(jobsOf(ctx, currentOwner).list()).toHaveLength(1) current.settle({ status: 'completed' }) await tick() await disposeAgentScope(currentOwner) + expect(jobsOf(ctx, currentOwner).list()).toEqual([]) }) }) describe('LocalJobRegistry owner cleanup', () => { - it('drains the owner: cancels live jobs, awaits settlement, drops snapshots', async () => { + it('drains the owner: cancels live jobs, awaits settlement, removes the rows', async () => { const ctx = await harness() - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + const owner = liveAgent(ctx, 'owner') + const seen = collect(ctx, { owner: owner.id }, ['removed']) // The producer settles only when cancelled — models a child that stops on request. let settle!: (outcome: JobOutcome) => void @@ -686,7 +717,7 @@ describe('LocalJobRegistry owner cleanup', () => { ctx.jobs.start({ kind: 'subagent', label: 'long research', - owner, + owner: owner.id, run: () => ({ cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) }, done: new Promise((res) => { settle = res }), @@ -699,59 +730,33 @@ describe('LocalJobRegistry owner cleanup', () => { await disposeAgentScope(owner) expect(cancels).toEqual(['owner disposed']) - // Snapshots dropped: nothing of the owner's remains, listing is empty. - expect(ctx.jobs.list(owner)).toEqual([]) + expect(jobsOf(ctx, owner).list()).toEqual([]) + expect(seen.map(event => event.type === 'removed' ? event.job.id : undefined)).toEqual(['subagent-1', 'bash-1']) }) - it('publishes the settled visible set before announcing completion', async () => { + it('announces teardown settlements with cause teardown', async () => { const ctx = await harness() - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) - const p = producer({ owner }) - ctx.jobs.start(p.spec) - // Registered after start so only the settlement's notifications are ordered. - const order: string[] = [] - ctx.jobs.onJobsChanged(() => void order.push('changed')) - ctx.jobs.onJobDone(() => void order.push('done')) - - p.settle({ status: 'completed' }) - await tick() - - // A completion reporter may open a turn synchronously. Announcing before - // the visible set is published would let a client render that turn while - // its job row still reads `running`. - expect(order).toEqual(['changed', 'done']) - }) - - it('reports a teardown-cancelled record so completion reporters stay quiet', async () => { - const ctx = await harness() - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + const owner = liveAgent(ctx, 'owner') + const seen = collect(ctx, { owners: 'all' }, ['settled']) let settle!: (outcome: JobOutcome) => void ctx.jobs.start({ kind: 'subagent', label: 'long research', - owner, + owner: owner.id, run: () => ({ cancel() { settle({ status: 'killed' }) }, done: new Promise((res) => { settle = res }), }), }) - // Observers still receive the terminal record; the report bit is what - // keeps a notice reporter from addressing an owner being destroyed. await disposeAgentScope(owner) - expect(seen).toHaveLength(1) - expect(seen[0]?.reported).toBe(true) + expect(seen).toEqual([{ type: 'settled', cause: 'teardown', job: expect.objectContaining({ status: 'killed' }) }]) }) it('attaches one cleanup per owner and drains all owned jobs with the scope', async () => { const ctx = await harness() - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + const owner = liveAgent(ctx, 'owner') const first = producer({ owner }) const second = producer({ owner }) @@ -762,21 +767,21 @@ describe('LocalJobRegistry owner cleanup', () => { await tick() expect(owner.ctx.fiber.getEffects().filter(effect => effect.label === 'jobs.ownerCleanup()')).toHaveLength(1) await disposeAgentScope(owner) - expect(ctx.jobs.list(owner)).toEqual([]) + expect(jobsOf(ctx, owner).list()).toEqual([]) }) - it('does not let an old scope cleanup cancel a same-id/session replacement job', async () => { + it('does not let an old scope cleanup cancel a same-session replacement job', async () => { const ctx = await harness() const oldOwner = stubAgent(ctx, 'owner') const detachOld = ctx.agents.register(oldOwner) const cancels: string[] = [] - function start(owner: Agent, label: string): JobId { + function start(label: string): JobId { let settle!: (outcome: JobOutcome) => void return ctx.jobs.start({ kind: 'bash', label, - owner, + owner: SessionId('owner'), run: () => ({ cancel() { cancels.push(label); settle({ status: 'killed' }) }, done: new Promise((resolve) => { settle = resolve }), @@ -784,15 +789,15 @@ describe('LocalJobRegistry owner cleanup', () => { }) } - start(oldOwner, 'old job') + start('old job') detachOld() const replacement = stubAgent(ctx, 'owner') ctx.agents.register(replacement) - const replacementId = start(replacement, 'replacement job') + const replacementId = start('replacement job') await disposeAgentScope(oldOwner) expect(cancels).toEqual(['old job']) - expect(ctx.jobs.list(replacement).map(job => job.id)).toEqual([replacementId]) + expect(jobsOf(ctx, replacement).list().map(job => job.id)).toEqual([replacementId]) await disposeAgentScope(replacement) expect(cancels).toEqual(['old job', 'replacement job']) @@ -803,8 +808,7 @@ describe('LocalJobRegistry owner cleanup', () => { await ctx.plugin(AgentRegistry) const tasksFiber = await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + const owner = liveAgent(ctx, 'owner') const ownerCleanupEffects = () => owner.ctx.fiber.getEffects() .filter(effect => effect.label === 'jobs.ownerCleanup()') @@ -821,22 +825,19 @@ describe('LocalJobRegistry owner cleanup', () => { expect(ownerCleanupEffects()).toHaveLength(0) expect(ctx.get('jobs')).toBeDefined() expect(tasksFiber.getEffects().some(effect => effect.label === 'jobs teardown')).toBe(true) - }) it('force-fails a throwing teardown cancel without awaiting producer done, first outcome wins', async () => { const ctx = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + const owner = liveAgent(ctx, 'owner') + const seen = collect(ctx, { owners: 'all' }, ['settled']) let settle!: (outcome: JobOutcome) => void ctx.jobs.start({ kind: 'bash', label: 'broken producer', - owner, + owner: owner.id, run: () => ({ cancel() { throw new Error('cancel boom') }, done: new Promise((res) => { settle = res }), @@ -861,24 +862,27 @@ describe('LocalJobRegistry owner cleanup', () => { expect(drainedWithoutProducerDone).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned')) expect(seen).toHaveLength(1) - expect(seen[0]?.status).toBe('failed') - expect(seen[0]?.detail).toContain('cancel threw during teardown') - expect(ctx.jobs.list(owner)).toEqual([]) + expect(seen[0]).toMatchObject({ cause: 'teardown', job: { status: 'failed' } }) + expect((seen[0] as { job: JobView }).job.detail).toContain('cancel threw during teardown') + expect(jobsOf(ctx, owner).list()).toEqual([]) }) }) describe('LocalJobRegistry disposal', () => { - it('cancels live jobs, awaits settlement, and silences listeners', async () => { + it('cancels live jobs, awaits settlement, and announces the teardown to outside subscribers', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(LocalJobRegistry) - const controller = await ctx.plugin(Object.assign((inner: Context) => { + await ctx.plugin(Object.assign((inner: Context) => { inner.jobs.attachController('test-controller') }, { inject: ['jobs'] })) - void controller + // A subscriber outside the registry's fiber is still listening when the + // registry unloads: it must see the rows leave, not keep them forever. const seen: string[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot.id)) + ctx.jobs.events.subscribe({ owners: 'all' }, (event) => { + seen.push(event.type === 'settled' ? `settled:${event.cause}` : event.type) + }) let settle!: (outcome: JobOutcome) => void const cancels: (string | undefined)[] = [] ctx.jobs.start({ @@ -892,8 +896,7 @@ describe('LocalJobRegistry disposal', () => { await fiber.dispose() expect(cancels).toEqual(['jobs service disposed']) - // The teardown kill settles AFTER the listener registry closed: silent. - expect(seen).toEqual([]) + expect(seen).toEqual(['registered', 'stopping', 'settled:teardown', 'output', 'removed']) }) it('force-fails a throwing cancel so service disposal does not await producer done', async () => { @@ -902,8 +905,7 @@ describe('LocalJobRegistry disposal', () => { const fiber = await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + const seen = collect(ctx, { owners: 'all' }, ['settled']) let settle!: (outcome: JobOutcome) => void ctx.jobs.start({ @@ -931,7 +933,7 @@ describe('LocalJobRegistry disposal', () => { expect(disposedWithoutProducerDone).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned')) - expect(seen).toEqual([]) + expect(seen).toEqual([{ type: 'settled', cause: 'teardown', job: expect.objectContaining({ status: 'failed' }) }]) }) it('detaches owner effects from still-live agent scopes when the service unloads', async () => { @@ -939,13 +941,12 @@ describe('LocalJobRegistry disposal', () => { await ctx.plugin(AgentRegistry) const tasksFiber = await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + const owner = liveAgent(ctx, 'owner') let settle!: (outcome: JobOutcome) => void ctx.jobs.start({ kind: 'bash', label: 'owned work', - owner, + owner: owner.id, run: () => ({ cancel() { settle({ status: 'killed' }) }, done: new Promise((resolve) => { settle = resolve }), @@ -971,11 +972,10 @@ describe('LocalJobRegistry disposal', () => { inject: ['jobs'], apply(pluginCtx: Context) { pluginCtx.jobs.attachController('tool-jobs') - pluginCtx.jobs.onJobDone(() => {}) + pluginCtx.jobs.events.subscribe({ owners: 'scope' }, () => {}) }, }) - const owner = stubAgent(ctx, 'joined', scopeOf(standing.ctx)) - ctx.agents.register(owner) + const owner = liveAgent(ctx, 'joined', scopeOf(standing.ctx)) expect(() => ctx.jobs.start(producer({ owner }).spec)).not.toThrow() await mount.dispose() @@ -1003,198 +1003,162 @@ describe('LocalJobRegistry disposal', () => { }) }) -describe('LocalJobRegistry.onJobsChanged', () => { - it('fires after registration, the stopping transition, and settlement', async () => { +describe('LocalJobRegistry events', () => { + it('announces registration, progress, stopping, and settlement with the committed projection', async () => { const ctx = await harness() - const owner = stubAgent(ctx, 'alice') - ctx.agents.register(owner) - const seen: (string | undefined)[] = [] - ctx.jobs.onJobsChanged(changed => void seen.push(changed?.id)) + const owner = liveAgent(ctx, 'alice') + const seen = collect(ctx, { owner: owner.id }, ['registered', 'progress', 'stopping', 'settled']) const p = producer({ owner }) const id = ctx.jobs.start(p.spec) // Registration is announced only once the record is readable. - expect(seen).toEqual(['alice']) - expect(ctx.jobs.list(owner)).toHaveLength(1) + expect(seen).toEqual([{ type: 'registered', job: expect.objectContaining({ id, owner: 'alice', status: 'running' }) }]) + expect(jobsOf(ctx, owner).list()).toHaveLength(1) - expect(ctx.jobs.kill(id, owner)).toBe('requested') - expect(seen).toEqual(['alice', 'alice']) - expect(ctx.jobs.get(id, owner).status).toBe('stopping') + p.job().updateProgress('3/10') + expect(seen[1]).toEqual({ type: 'progress', job: expect.objectContaining({ id, progress: '3/10' }) }) + + expect(jobsOf(ctx, owner).kill(id)).toBe('requested') + expect(seen[2]).toEqual({ type: 'stopping', job: expect.objectContaining({ id, status: 'stopping', progress: '3/10' }) }) p.settle({ status: 'killed' }) await tick() - expect(seen).toEqual(['alice', 'alice', 'alice']) - expect(ctx.jobs.get(id, owner).status).toBe('killed') + const settled = seen[3] as { job: JobView } + expect(seen[3]).toMatchObject({ type: 'settled', cause: 'kill', job: { id, status: 'killed' } }) + expect(settled.job.progress).toBeUndefined() await disposeAgentScope(owner) }) - it('reports an unowned change as undefined, since every caller can see it', async () => { + it('announces an unowned job to every session filter, since every caller can see it', async () => { const ctx = await harness() - const seen: (string | undefined)[] = [] - ctx.jobs.onJobsChanged(changed => void seen.push(changed?.id)) - + const seen = collect(ctx, { owner: SessionId('anyone') }, ['registered']) ctx.jobs.start(producer().spec) - expect(seen).toEqual([undefined]) + expect(seen).toEqual([{ type: 'registered', job: expect.objectContaining({ id: 'bash-1' }) }]) + expect((seen[0] as { job: JobView }).job.owner).toBeUndefined() }) it('announces the owner-disposal removal, and stays silent when that owner had none', async () => { const ctx = await harness() - const owner = stubAgent(ctx, 'alice') - const bystander = stubAgent(ctx, 'bob') - ctx.agents.register(owner) - ctx.agents.register(bystander) + const owner = liveAgent(ctx, 'alice') + const bystander = liveAgent(ctx, 'bob') const p = producer({ owner }) ctx.jobs.start(p.spec) - const seen: (string | undefined)[] = [] - ctx.jobs.onJobsChanged(changed => void seen.push(changed?.id)) + const seen = collect(ctx, { owners: 'all' }, ['settled', 'removed']) p.settle({ status: 'completed' }) await tick() - expect(seen).toEqual(['alice']) + expect(seen.map(event => event.type)).toEqual(['settled']) // Disposing an owner with no records changes no visible set. await disposeAgentScope(bystander) - expect(seen).toEqual(['alice']) + expect(seen.map(event => event.type)).toEqual(['settled']) await disposeAgentScope(owner) - expect(seen).toEqual(['alice', 'alice']) - expect(ctx.jobs.list(owner)).toEqual([]) + expect(seen.map(event => event.type)).toEqual(['settled', 'removed']) + expect(jobsOf(ctx, owner).list()).toEqual([]) }) it('contains a throwing listener so the lifecycle commit still stands', async () => { const ctx = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const seen: (string | undefined)[] = [] - ctx.jobs.onJobsChanged(() => { throw new Error('observer boom') }) - ctx.jobs.onJobsChanged(changed => void seen.push(changed?.id)) + ctx.jobs.events.subscribe({ owners: 'all' }, () => { throw new Error('observer boom') }) + const seen = collect(ctx, { owners: 'all' }, ['registered']) const id = ctx.jobs.start(producer().spec) expect(id).toBe('bash-1') - expect(seen).toEqual([undefined]) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('onJobsChanged listener threw')) + expect(seen).toHaveLength(1) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('event listener threw on registered')) }) - it('unregisters through its disposer and with its fiber (HMR safety)', async () => { - const ctx = await harness() - const seen: number[] = [] - const detach = ctx.jobs.onJobsChanged(() => void seen.push(1)) - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.jobs.onJobsChanged(() => void seen.push(2)) - }, { inject: ['jobs'] })) - - ctx.jobs.start(producer().spec) - expect(seen).toEqual([1, 2]) - - detach() - detach() // second call of the same disposer is a no-op - ctx.jobs.start(producer().spec) - expect(seen).toEqual([1, 2, 2]) - - await fiber.dispose() - ctx.jobs.start(producer().spec) - expect(seen).toEqual([1, 2, 2]) - }) -}) - -describe('LocalJobRegistry teardown change notifications', () => { it('announces the stopping transition during owner teardown, before settlement', async () => { const ctx = await harness() - const owner = stubAgent(ctx, 'alice') - ctx.agents.register(owner) + const owner = liveAgent(ctx, 'alice') const p = producer({ owner }) - const id = ctx.jobs.start(p.spec) + ctx.jobs.start(p.spec) - const statuses: (string | undefined)[] = [] - ctx.jobs.onJobsChanged((changed) => { - statuses.push(changed === undefined ? undefined : ctx.jobs.list(changed)[0]?.status) + const statuses: string[] = [] + ctx.jobs.events.subscribe({ owner: owner.id }, (event) => { + if (event.type !== 'output') statuses.push(`${event.type}:${event.job.status}`) }) // A slow producer keeps teardown parked between cancel and settlement; // an observer must not be left showing `running` for that whole window. const disposal = disposeAgentScope(owner) await tick() - expect(statuses).toEqual(['stopping']) + expect(statuses).toEqual(['stopping:stopping']) p.settle({ status: 'killed' }) await disposal // Settlement, then the removal that empties the visible set. - expect(statuses).toEqual(['stopping', 'killed', undefined]) - expect(ctx.jobs.list(owner)).toEqual([]) - void id + expect(statuses).toEqual(['stopping:stopping', 'settled:killed', 'removed:killed']) + expect(jobsOf(ctx, owner).list()).toEqual([]) }) - it('announces the emptied set to a listener registered outside this service (reload safety)', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - const fiber = await ctx.plugin(LocalJobRegistry) - ctx.jobs.attachController('test-controller') - - // The api-proxy carrier registers from its own stream context, not the - // registry's fiber, so it is still listening when the registry unloads. - const seen: (string | undefined)[] = [] - ctx.jobs.onJobsChanged(changed => void seen.push(changed?.id)) - let settle!: (outcome: JobOutcome) => void - ctx.jobs.start({ - kind: 'bash', - label: 'sleep 600', - run: () => ({ - cancel() { settle({ status: 'killed' }) }, - done: new Promise((resolve) => { settle = resolve }), - }), + it('delivers scoped subscriptions to the owning composition only', async () => { + const ctx = await harness() + const mount = createScope(ctx, {}) + const otherMount = createScope(ctx, {}) + const alice = liveAgent(ctx, 'alice', scopeOf(mount.ctx)) + const bob = liveAgent(ctx, 'bob', scopeOf(otherMount.ctx)) + const everyone = collect(ctx, { owners: 'all' }, ['output']) + const scoped: string[] = [] + await mount.ctx.plugin({ + inject: ['jobs'], + apply(scopedCtx: Context) { + scopedCtx.jobs.events.subscribe({ owners: 'scope' }, (event) => { + if (event.type === 'output') scoped.push(String(event.id)) + }) + }, }) - seen.length = 0 - - await fiber.dispose() - // stopping (teardown cancel), settlement, then the final empty set. - expect(seen).toEqual([undefined, undefined, undefined]) + const pa = producer({ owner: alice }) + const aliceId = ctx.jobs.start(pa.spec) + const pb = producer({ owner: bob }) + ctx.jobs.start(pb.spec) + pa.job().append('for alice observers') + pb.job().append('for bob observers') + expect(everyone).toHaveLength(2) + expect(scoped).toEqual([String(aliceId)]) }) }) -describe('LocalJobRegistry record', () => { - it('appends advance absolute offsets; record reads are non-consuming and never mark reported', async () => { +describe('LocalJobRegistry output ring', () => { + it('appends advance absolute offsets and reach observers as output events', async () => { const ctx = await harness() - const p = producer({ record: true }) + const outputs = collect(ctx, { owners: 'all' }, ['output']) + const p = producer() const id = ctx.jobs.start(p.spec) p.job().append('hello ') p.job().append('world', { channel: 'stderr' }) - const first = ctx.jobs.readRecord(id, 0) - expect(first.lossy).toBe(false) - expect(first.next).toBe(11) - expect(first.chunks).toEqual([ - { at: 0, text: 'hello ' }, - { at: 6, text: 'world', channel: 'stderr' }, - ]) + const first = jobsOf(ctx).readAt(id, 0) + expect(first).toEqual({ + chunks: [{ at: 0, text: 'hello ' }, { at: 6, text: 'world', channel: 'stderr' }], + next: 11, + lossy: false, + }) // A second identical read proves nothing was consumed. - expect(ctx.jobs.readRecord(id, 0)).toEqual(first) + expect(jobsOf(ctx).readAt(id, 0)).toEqual(first) // Resuming from `next` yields nothing until more output arrives. - expect(ctx.jobs.readRecord(id, first.next).chunks).toEqual([]) + expect(jobsOf(ctx).readAt(id, first.next).chunks).toEqual([]) p.job().append('!') - expect(ctx.jobs.readRecord(id, first.next).chunks).toEqual([{ at: 11, text: '!' }]) - const snapshot = ctx.jobs.get(id) - expect(snapshot.outputTotal).toBe(12) - expect(snapshot.outputEarliest).toBe(0) - // The observer surface owns no notice state: even a terminal record read - // leaves the completion notice due. - p.settle({ status: 'completed' }) - await tick() - ctx.jobs.readRecord(id, 0) - expect(ctx.jobs.get(id).reported).toBe(false) + expect(jobsOf(ctx).readAt(id, first.next).chunks).toEqual([{ at: 11, text: '!' }]) + expect(jobsOf(ctx).get(id).output).toEqual({ total: 12, earliest: 0 }) + expect(outputs).toEqual([ + { type: 'output', id, total: 6 }, + { type: 'output', id, total: 11 }, + { type: 'output', id, total: 12 }, + ]) }) it('staged starter writes surface at the registration commit without observer signals', async () => { const ctx = await harness() - const outputSignals: string[] = [] - ctx.jobs.onOutput((id) => { outputSignals.push(String(id)) }) - const changes: number[] = [] - ctx.jobs.onJobsChanged(() => { changes.push(1) }) + const seen = collect(ctx) let settle!: (outcome: JobOutcome) => void const id = ctx.jobs.start({ kind: 'bash', label: 'staged writes', - record: true, run: (job) => { job.append('early ', { channel: 'stdout' }) - job.updateDetail('booting') + job.updateProgress('booting') return { cancel() {}, done: new Promise((resolve) => { settle = resolve }), @@ -1202,253 +1166,170 @@ describe('LocalJobRegistry record', () => { }, }) // Only the registration commit announced anything. - expect(outputSignals).toEqual([]) - expect(changes).toEqual([1]) - const snapshot = ctx.jobs.get(id) - expect(snapshot.detail).toBe('booting') - expect(snapshot.outputTotal).toBe(6) - expect(ctx.jobs.readRecord(id, 0).chunks).toEqual([{ at: 0, text: 'early ', channel: 'stdout' }]) + expect(seen.map(event => event.type)).toEqual(['registered']) + const view = jobsOf(ctx).get(id) + expect(view.progress).toBe('booting') + expect(view.output.total).toBe(6) + expect(jobsOf(ctx).readAt(id, 0).chunks).toEqual([{ at: 0, text: 'early ', channel: 'stdout' }]) settle({ status: 'completed' }) await tick() }) it('drops an empty append without waking observers', async () => { const ctx = await harness() - const signals: string[] = [] - ctx.jobs.onOutput((id) => { signals.push(String(id)) }) - const p = producer({ record: true }) + const outputs = collect(ctx, { owners: 'all' }, ['output']) + const p = producer() const id = ctx.jobs.start(p.spec) p.job().append('') - expect(signals).toEqual([]) - expect(ctx.jobs.get(id).outputTotal).toBe(0) + expect(outputs).toEqual([]) + expect(jobsOf(ctx).get(id).output.total).toBe(0) }) - it('hands a start without a record declaration no append; updateDetail still works', async () => { + it('rejects a negative or fractional read offset', async () => { const ctx = await harness() const p = producer() const id = ctx.jobs.start(p.spec) - // The plain face is built without the method, not with a dropping stub. - expect(p.job()).not.toHaveProperty('append') - expect(() => ctx.jobs.readRecord(id, 0)).toThrow(`job ${id} declared no output record`) - const snapshot = ctx.jobs.get(id) - expect(snapshot.outputTotal).toBeUndefined() - expect(snapshot.outputEarliest).toBeUndefined() - p.job().updateDetail('7/10 items') - expect(ctx.jobs.get(id).detail).toBe('7/10 items') - }) - - it('rejects a negative or fractional record read offset', async () => { - const ctx = await harness() - const p = producer({ record: true }) - const id = ctx.jobs.start(p.spec) - expect(() => ctx.jobs.readRecord(id, -1)).toThrow(/invalid record read offset/) - expect(() => ctx.jobs.readRecord(id, 0.5)).toThrow(/invalid record read offset/) - expect(() => ctx.jobs.readRecord(JobId('bash-99'), 0)).toThrow(/unknown job/) - }) - - it('fences record reads to the owning session', async () => { - const ctx = await harness() - const alice = stubAgent(ctx, 'alice') - const bob = stubAgent(ctx, 'bob') - ctx.agents.register(alice) - ctx.agents.register(bob) - const p = producer({ owner: alice, record: true }) - const id = ctx.jobs.start(p.spec) - p.job().append('secret') - expect(() => ctx.jobs.readRecord(id, 0, bob)).toThrow(/belongs to another session/) - expect(() => ctx.jobs.readRecord(id, 0)).toThrow(/belongs to another session/) - expect(ctx.jobs.readRecord(id, 0, alice).chunks[0]?.text).toBe('secret') + expect(() => jobsOf(ctx).readAt(id, -1)).toThrow(/invalid output read offset/) + expect(() => jobsOf(ctx).readAt(id, 0.5)).toThrow(/invalid output read offset/) + expect(() => jobsOf(ctx).readAt(JobId('bash-99'), 0)).toThrow(/unknown job/) }) it('evicts whole head chunks past the live cap and flags stale readers lossy', async () => { const ctx = await harness({ retainBytes: 8 }) - const p = producer({ record: true }) + const p = producer() const id = ctx.jobs.start(p.spec) p.job().append('aaaa') p.job().append('bbbb') p.job().append('cccc') - const read = ctx.jobs.readRecord(id, 0) + const read = jobsOf(ctx).readAt(id, 0) expect(read.lossy).toBe(true) expect(read.chunks).toEqual([{ at: 4, text: 'bbbb' }, { at: 8, text: 'cccc' }]) expect(read.next).toBe(12) - const snapshot = ctx.jobs.get(id) - expect(snapshot.outputEarliest).toBe(4) - expect(snapshot.outputTotal).toBe(12) + expect(jobsOf(ctx).get(id).output).toEqual({ total: 12, earliest: 4 }) // A reader at the retained boundary is not lossy. - expect(ctx.jobs.readRecord(id, 4).lossy).toBe(false) + expect(jobsOf(ctx).readAt(id, 4).lossy).toBe(false) + // The model cursor is subject to the same eviction. + expect(jobsOf(ctx).read(id).lossy).toBe(true) }) - it('keeps only the UTF-8-safe tail of a single oversized chunk', async () => { - const ctx = await harness({ retainBytes: 5 }) - const p = producer({ record: true }) - const id = ctx.jobs.start(p.spec) - // '你好' is six UTF-8 bytes; a five-byte budget must not split the second code point. - p.job().append('你好') - const read = ctx.jobs.readRecord(id, 0) - expect(read.lossy).toBe(true) - expect(read.chunks).toEqual([{ at: 3, text: '好', gapBefore: true }]) - const snapshot = ctx.jobs.get(id) - expect(snapshot.outputEarliest).toBe(3) - expect(snapshot.outputTotal).toBe(6) - }) - - it('an exhausted byte budget leaves an empty gap-marked tail at the total offset', async () => { - const ctx = await harness({ retainBytes: 1 }) - const p = producer({ record: true }) - const id = ctx.jobs.start(p.spec) - // Every byte of '好' from the cut is a continuation byte, so no code point survives. - p.job().append('好') - const read = ctx.jobs.readRecord(id, 0) - expect(read.lossy).toBe(true) - expect(read.chunks).toEqual([{ at: 3, text: '', gapBefore: true }]) - expect(ctx.jobs.get(id).outputEarliest).toBe(3) - }) - - it('returns the whole overlapped chunk for a foreign mid-chunk offset', async () => { - const ctx = await harness() - const p = producer({ record: true }) - const id = ctx.jobs.start(p.spec) - p.job().append('abcdef') - const read = ctx.jobs.readRecord(id, 3) - expect(read.chunks).toEqual([{ at: 0, text: 'abcdef' }]) - expect(read.lossy).toBe(false) - }) - - it('preserves a producer-reported gap marker through reads', async () => { - const ctx = await harness() - const p = producer({ record: true }) - const id = ctx.jobs.start(p.spec) - p.job().append('tail after drop', { gapBefore: true, channel: 'stdout' }) - expect(ctx.jobs.readRecord(id, 0).chunks).toEqual([ - { at: 0, text: 'tail after drop', channel: 'stdout', gapBefore: true }, - ]) - }) - - it('settlement trims to the settled cap, signals once more, and drops later writes', async () => { + it('settlement trims to the settled cap, ends the stream, and drops later writes', async () => { const ctx = await harness({ retainBytes: 1024, settledRetainBytes: 4 }) const warn = vi.fn() ctx.logger.warn = warn as never - const signals: string[] = [] - ctx.jobs.onOutput((id) => { signals.push(String(id)) }) - const p = producer({ record: true }) + const outputs = collect(ctx, { owners: 'all' }, ['output']) + const p = producer() const id = ctx.jobs.start(p.spec) p.job().append('abcdefgh') p.settle({ status: 'completed', detail: 'exit code: 0' }) await tick() - expect(signals).toEqual([String(id), String(id)]) - const snapshot = ctx.jobs.get(id) - expect(snapshot).toMatchObject({ status: 'completed', detail: 'exit code: 0', outputTotal: 8, outputEarliest: 4 }) - const read = ctx.jobs.readRecord(id, 0) + expect(outputs.map(event => event.type === 'output' ? event.total : -1)).toEqual([8, 8]) + expect(jobsOf(ctx).get(id)).toMatchObject({ status: 'completed', detail: 'exit code: 0', output: { total: 8, earliest: 4 } }) + const read = jobsOf(ctx).readAt(id, 0) expect(read.lossy).toBe(true) expect(read.chunks).toEqual([{ at: 4, text: 'efgh', gapBefore: true }]) // The stream ended with the settlement: a trailing flush cannot break teardown. p.job().append('late') - p.job().updateDetail('late detail') + p.job().updateProgress('late progress') expect(warn.mock.calls.map(call => String(call[0]))).toEqual([ expect.stringContaining('append to settled job'), - expect.stringContaining('detail update on settled job'), + expect.stringContaining('progress update on settled job'), ]) - expect(signals).toHaveLength(2) - expect(ctx.jobs.get(id).detail).toBe('exit code: 0') + expect(outputs).toHaveLength(2) + expect(jobsOf(ctx).get(id).progress).toBeUndefined() }) - it('a terminal outcome without detail keeps the last progress line', async () => { + it('settlement clears the progress line; the terminal detail is the outcome alone', async () => { const ctx = await harness() - const changes: number[] = [] - const p = producer({ record: true }) + const p = producer() const id = ctx.jobs.start(p.spec) - ctx.jobs.onJobsChanged(() => { changes.push(1) }) - p.job().updateDetail('3/10 agents done') - expect(changes).toHaveLength(1) - expect(ctx.jobs.get(id).detail).toBe('3/10 agents done') + p.job().updateProgress('3/10 agents done') + expect(jobsOf(ctx).get(id)).toMatchObject({ progress: '3/10 agents done' }) p.settle({ status: 'completed' }) await tick() - expect(ctx.jobs.get(id)).toMatchObject({ status: 'completed', detail: '3/10 agents done' }) + const view = jobsOf(ctx).get(id) + expect(view.status).toBe('completed') + expect(view.progress).toBeUndefined() + expect(view.detail).toBeUndefined() }) - it('a teardown-forced settlement also ends the record', async () => { + it('a teardown-forced settlement also ends the ring', async () => { const ctx = await harness() const warn = vi.fn() ctx.logger.warn = warn as never - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) - const p = producer({ owner, record: true }) + const owner = liveAgent(ctx, 'owner') + const p = producer({ owner }) const id = ctx.jobs.start(p.spec) p.job().append('partial') const disposal = disposeAgentScope(owner) p.settle({ status: 'killed' }) await disposal - expect(() => ctx.jobs.readRecord(id, 0, owner)).toThrow(/unknown job/) + expect(() => jobsOf(ctx, owner).readAt(id, 0)).toThrow(/unknown job/) // The producer face outlives the row; its trailing flush is dropped, not thrown. p.job().append('late') expect(warn).toHaveBeenCalledWith(expect.stringContaining('append to settled job')) }) - it('delivers onOutput to the global layer and to the owning scope only', async () => { + it('hands out fresh projections, never live registry state', async () => { const ctx = await harness() - const mount = createScope(ctx, {}) - const otherMount = createScope(ctx, {}) - const alice = stubAgent(ctx, 'alice', scopeOf(mount.ctx)) - const bob = stubAgent(ctx, 'bob', scopeOf(otherMount.ctx)) - ctx.agents.register(alice) - ctx.agents.register(bob) - const globalSignals: string[] = [] - ctx.jobs.onOutput((id) => { globalSignals.push(String(id)) }) - const scopedSignals: string[] = [] - await mount.ctx.plugin({ - inject: ['jobs'], - apply(scopedCtx: Context) { - scopedCtx.jobs.onOutput((id) => { scopedSignals.push(String(id)) }) - }, - }) - const pa = producer({ owner: alice, record: true }) - const aliceId = ctx.jobs.start(pa.spec) - const pb = producer({ owner: bob, record: true }) - ctx.jobs.start(pb.spec) - pa.job().append('for alice observers') - pb.job().append('for bob observers') - expect(globalSignals).toHaveLength(2) - expect(scopedSignals).toEqual([String(aliceId)]) - }) - - it('contains a throwing onOutput listener without breaking the append commit', async () => { - const ctx = await harness() - const warn = vi.fn() - ctx.logger.warn = warn as never - ctx.jobs.onOutput(() => { throw new Error('output boom') }) - const p = producer({ record: true }) + const p = producer() const id = ctx.jobs.start(p.spec) - p.job().append('x') - expect(ctx.jobs.get(id).outputTotal).toBe(1) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('onOutput listener threw')) - }) - - it('unregisters onOutput listeners with their registering fiber (HMR safety)', async () => { - const ctx = await harness() - const signals: string[] = [] - const fiber = ctx.plugin({ - inject: ['jobs'], - apply(pluginCtx: Context) { - pluginCtx.jobs.onOutput((id) => { signals.push(String(id)) }) - }, - }) - await fiber - const p = producer({ record: true }) - ctx.jobs.start(p.spec) - p.job().append('a') - expect(signals).toHaveLength(1) - await fiber.dispose() - p.job().append('b') - expect(signals).toHaveLength(1) - }) - - it('hands out fresh record snapshots, never live registry state', async () => { - const ctx = await harness() - const p = producer({ record: true }) - const id = ctx.jobs.start(p.spec) - const before = ctx.jobs.get(id) + const before = jobsOf(ctx).get(id) p.job().append('grow') - expect(before.outputTotal).toBe(0) - expect(ctx.jobs.get(id).outputTotal).toBe(4) + expect(before.output.total).toBe(0) + expect(jobsOf(ctx).get(id).output.total).toBe(4) + }) +}) + +describe('LocalJobRegistry pull sources', () => { + it('pumps the spec sources into the ring and drains them once more before settlement', async () => { + const ctx = await harness({ pumpPollMs: 1 }) + const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout') + // stderr only becomes readable once the producer has settled: the pump's + // final drain is the only read that can collect it. + let finishing = false + let errOffset = 0 + const err: JobOutputSource = { + channel: 'stderr', + read() { + const text = finishing && errOffset === 0 ? 'E' : '' + errOffset += text.length + return { text, nextOffset: errOffset, lossy: false } + }, + } + const p = producer({ output: [out, err] }) + const id = ctx.jobs.start(p.spec) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(jobsOf(ctx).readAt(id, 0).chunks.map(chunk => chunk.text)).toEqual(['a', 'bc']) + + const settled = collect(ctx, { owners: 'all' }, ['settled']) + finishing = true + p.settle({ status: 'completed', detail: 'exit code: 0' }) + await new Promise(resolve => setTimeout(resolve, 10)) + // The final drain landed before the settlement announced itself. + expect(settled).toHaveLength(1) + expect(jobsOf(ctx).readAt(id, 0).chunks.map(chunk => chunk.text)).toEqual(['a', 'bc', 'E']) + expect(jobsOf(ctx).get(id)).toMatchObject({ status: 'completed', output: { total: 4 } }) + }) + + it('stops pumping silently after a forced settlement', async () => { + const ctx = await harness({ pumpPollMs: 1 }) + const warn = vi.fn() + ctx.logger.warn = warn as never + const owner = liveAgent(ctx, 'owner') + const source: JobOutputSource = { + read: fromByte => ({ text: 'x', nextOffset: fromByte + 1, lossy: false }), + } + ctx.jobs.start({ + kind: 'bash', + label: 'endless', + owner: owner.id, + output: [source], + run: () => ({ cancel() { throw new Error('cancel boom') }, done: new Promise(() => {}) }), + }) + await new Promise(resolve => setTimeout(resolve, 5)) + await disposeAgentScope(owner) + await new Promise(resolve => setTimeout(resolve, 5)) + // The forced failure is the only warning: the pump's later drains are silent. + expect(warn.mock.calls.map(call => String(call[0]))).toEqual([expect.stringContaining('work may be orphaned')]) }) }) From 6626c74dbc66b292061cbde6063c12654a30bde8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 16:09:07 +0800 Subject: [PATCH 030/284] refactor(jobs-local): contain a throwing pull source instead of freezing the job --- packages/jobs/jobs-local/src/index.ts | 28 ++++++++++++++++++--- packages/jobs/jobs-local/tests/jobs.spec.ts | 17 +++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index 965de7a124..165cf83aff 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -18,8 +18,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' import type { - JobAppendOptions, JobEvent, JobEvents, JobHandle, JobKillOptions, JobKind, JobOutcome, JobOutputRead, JobRead, - JobSettleCause, JobSpec, JobStatus, JobView, VisibleJobs, + JobAppendOptions, JobEvent, JobEvents, JobHandle, JobKillOptions, JobKind, JobOutcome, JobOutputRead, JobOutputSource, + JobRead, JobSettleCause, JobSpec, JobStatus, JobView, VisibleJobs, } from '@deepseek-ai/dsh-jobs' import { JobEventHub, JobLayer } from './events.ts' import { startPump } from './pump.ts' @@ -270,7 +270,7 @@ export class LocalJobRegistry extends JobRegistry { ) if (spec.output !== undefined && spec.output.length > 0) { job.pump = startPump( - spec.output, + spec.output.map(source => this.guardSource(job, source)), (text, options) => { this.appendRing(state, ring, text, options, 'pump') }, this.pumpPollMs, Promise.race([producerDone, settled]), @@ -485,6 +485,28 @@ export class LocalJobRegistry extends JobRegistry { if (job !== undefined) this.emitOutput(job) } + /** + * Contain a failing pull source: the first throw is logged, and the source + * reads as exhausted from then on, so the job runs to its own settlement + * with whatever the ring holds instead of freezing on a pump failure. + */ + private guardSource(job: TrackedJob, source: JobOutputSource): JobOutputSource { + let failed = false + return { + ...source.channel !== undefined ? { channel: source.channel } : {}, + read: (fromByte) => { + if (failed) return { text: '', nextOffset: fromByte, lossy: false } + try { + return source.read(fromByte) + } catch (error: unknown) { + failed = true + this.selfCtx.logger.warn(`jobs: output source for ${job.id} failed; its stream stops here: ${String(error)}`) + return { text: '', nextOffset: fromByte, lossy: false } + } + }, + } + } + /** Announce that one job's ring advanced (append or settlement). */ private emitOutput(job: TrackedJob): void { const owner = job.owner?.id diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 550a713f6b..6f65916157 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -1311,6 +1311,23 @@ describe('LocalJobRegistry pull sources', () => { expect(jobsOf(ctx).get(id)).toMatchObject({ status: 'completed', output: { total: 4 } }) }) + it('contains a throwing source: warns once, keeps the job live, and settles on the producer', async () => { + const ctx = await harness({ pumpPollMs: 1 }) + const warn = vi.fn() + ctx.logger.warn = warn as never + const broken: JobOutputSource = { channel: 'stdout', read() { throw new Error('reader boom') } } + const healthy = scriptedSource([{ text: 'ok' }], 'stderr') + const p = producer({ output: [broken, healthy] }) + const id = ctx.jobs.start(p.spec) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(jobsOf(ctx).get(id).status).toBe('running') + expect(jobsOf(ctx).readAt(id, 0).chunks).toEqual([{ at: 0, text: 'ok', channel: 'stderr' }]) + expect(warn.mock.calls.map(call => String(call[0]))).toEqual([expect.stringContaining(`output source for ${id} failed`)]) + p.settle({ status: 'completed', detail: 'exit code: 0' }) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(jobsOf(ctx).get(id)).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) + }) + it('stops pumping silently after a forced settlement', async () => { const ctx = await harness({ pumpPollMs: 1 }) const warn = vi.fn() From 0e35952802da6648f09fe5e824fd48c928983349 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 16:30:57 +0800 Subject: [PATCH 031/284] refactor(tool-jobs): render model reads from the ring and own the notice ledger - job_output reads the per-job ring through visibleTo(): stdout, then one [stderr] section, log narration excluded, a dropped-output notice on a lossy read, and the result exactly once after settlement - completion notices come from the registry event stream subscribed with { owners: 'scope' }; a wait claims its job when it starts and withdraws on timeout or abort, job_kill claims on 'requested', teardown settlements and unowned jobs are skipped, and a removal drops the ledger entry - the notice destination is the agent registered for the owner session at settlement, so tool-jobs now injects the agent registry - JobView exposes the producer's outputLimitBytes for the tool-result bound - lint: hub disposer identity comment, unknown-typed matchers in seam tests --- packages/jobs/jobs-local/src/events.ts | 4 +- packages/jobs/jobs-local/src/index.ts | 2 +- packages/jobs/jobs-local/tests/jobs.spec.ts | 22 +- packages/jobs/jobs/src/view.ts | 2 + packages/jobs/jobs/tests/service.spec.ts | 2 +- packages/jobs/tool-jobs/src/index.ts | 153 ++++---- packages/jobs/tool-jobs/src/render.ts | 80 ++++ .../jobs/tool-jobs/tests/tool-jobs.spec.ts | 354 ++++++++++++------ 8 files changed, 433 insertions(+), 186 deletions(-) create mode 100644 packages/jobs/tool-jobs/src/render.ts diff --git a/packages/jobs/jobs-local/src/events.ts b/packages/jobs/jobs-local/src/events.ts index 2e39f7089f..333d5db5fc 100644 --- a/packages/jobs/jobs-local/src/events.ts +++ b/packages/jobs/jobs-local/src/events.ts @@ -62,10 +62,12 @@ export class JobEventHub { if ('owners' in filter && filter.owners === 'scope') { return this.layers.effect(ctx, layer => layer.scoped.append(subscription), { label: 'jobs.events.subscribe()' }) } - return ctx.effect(() => { + const dispose = ctx.effect(() => { this.unscoped.add(subscription) return () => { this.unscoped.delete(subscription) } }, 'jobs.events.subscribe()') + // oxlint-disable-next-line typescript/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity + return dispose } /** diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index 165cf83aff..990c03209c 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -177,7 +177,7 @@ export class LocalJobRegistry extends JobRegistry { this.settledRetainBytes = resolved.settledRetainBytes this.pumpPollMs = resolved.pumpPollMs this.selfCtx = ctx - this.hub = new JobEventHub(this.layers, message => ctx.logger.warn(message)) + this.hub = new JobEventHub(this.layers, (message) => { ctx.logger.warn(message) }) ctx.effect(() => () => this.disposeAll(), 'jobs teardown') } diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 6f65916157..55e5615a58 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -395,7 +395,7 @@ describe('LocalJobRegistry reads and settlement', () => { p.settle({ status: 'completed', detail: 'exit code: 0' }) await tick() - expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id, status: 'completed', detail: 'exit code: 0' }) }]) + expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id, status: 'completed', detail: 'exit code: 0' }) as unknown }]) expect(warn).toHaveBeenCalledWith(expect.stringContaining('listener boom')) }) @@ -440,14 +440,14 @@ describe('LocalJobRegistry.kill', () => { expect(jobsOf(ctx).kill(id, { reason: 'no longer needed' })).toBe('requested') expect(p.cancels).toEqual(['no longer needed']) expect(jobsOf(ctx).list()[0]).toMatchObject({ status: 'stopping' }) - expect(seen).toEqual([{ type: 'stopping', job: expect.objectContaining({ id, status: 'stopping' }) }]) + expect(seen).toEqual([{ type: 'stopping', job: expect.objectContaining({ id, status: 'stopping' }) as unknown }]) p.settle({ status: 'killed', detail: 'signal: SIGTERM' }) await tick() expect(seen[1]).toEqual({ type: 'settled', cause: 'kill', - job: expect.objectContaining({ id, status: 'killed', detail: 'signal: SIGTERM; no longer needed' }), + job: expect.objectContaining({ id, status: 'killed', detail: 'signal: SIGTERM; no longer needed' }) as unknown, }) }) @@ -495,7 +495,7 @@ describe('LocalJobRegistry.kill', () => { expect(seen).toEqual([]) settle({ status: 'completed' }) await tick() - expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id }) }]) + expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id }) as unknown }]) broken = false expect(jobsOf(ctx).kill(id)).toBe('already-finished') @@ -587,7 +587,7 @@ describe('LocalJobRegistry.wait', () => { p.settle({ status: 'completed', detail: 'exit code: 0' }) controller.abort() await expect(wait).rejects.toThrow('wait aborted') - expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id, status: 'completed' }) }]) + expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id, status: 'completed' }) as unknown }]) }) it('an abort landing after settlement still delivers the terminal projection it owes', async () => { @@ -751,7 +751,7 @@ describe('LocalJobRegistry owner cleanup', () => { }) await disposeAgentScope(owner) - expect(seen).toEqual([{ type: 'settled', cause: 'teardown', job: expect.objectContaining({ status: 'killed' }) }]) + expect(seen).toEqual([{ type: 'settled', cause: 'teardown', job: expect.objectContaining({ status: 'killed' }) as unknown }]) }) it('attaches one cleanup per owner and drains all owned jobs with the scope', async () => { @@ -933,7 +933,7 @@ describe('LocalJobRegistry disposal', () => { expect(disposedWithoutProducerDone).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned')) - expect(seen).toEqual([{ type: 'settled', cause: 'teardown', job: expect.objectContaining({ status: 'failed' }) }]) + expect(seen).toEqual([{ type: 'settled', cause: 'teardown', job: expect.objectContaining({ status: 'failed' }) as unknown }]) }) it('detaches owner effects from still-live agent scopes when the service unloads', async () => { @@ -1012,14 +1012,14 @@ describe('LocalJobRegistry events', () => { const p = producer({ owner }) const id = ctx.jobs.start(p.spec) // Registration is announced only once the record is readable. - expect(seen).toEqual([{ type: 'registered', job: expect.objectContaining({ id, owner: 'alice', status: 'running' }) }]) + expect(seen).toEqual([{ type: 'registered', job: expect.objectContaining({ id, owner: 'alice', status: 'running' }) as unknown }]) expect(jobsOf(ctx, owner).list()).toHaveLength(1) p.job().updateProgress('3/10') - expect(seen[1]).toEqual({ type: 'progress', job: expect.objectContaining({ id, progress: '3/10' }) }) + expect(seen[1]).toEqual({ type: 'progress', job: expect.objectContaining({ id, progress: '3/10' }) as unknown }) expect(jobsOf(ctx, owner).kill(id)).toBe('requested') - expect(seen[2]).toEqual({ type: 'stopping', job: expect.objectContaining({ id, status: 'stopping', progress: '3/10' }) }) + expect(seen[2]).toEqual({ type: 'stopping', job: expect.objectContaining({ id, status: 'stopping', progress: '3/10' }) as unknown }) p.settle({ status: 'killed' }) await tick() @@ -1033,7 +1033,7 @@ describe('LocalJobRegistry events', () => { const ctx = await harness() const seen = collect(ctx, { owner: SessionId('anyone') }, ['registered']) ctx.jobs.start(producer().spec) - expect(seen).toEqual([{ type: 'registered', job: expect.objectContaining({ id: 'bash-1' }) }]) + expect(seen).toEqual([{ type: 'registered', job: expect.objectContaining({ id: 'bash-1' }) as unknown }]) expect((seen[0] as { job: JobView }).job.owner).toBeUndefined() }) diff --git a/packages/jobs/jobs/src/view.ts b/packages/jobs/jobs/src/view.ts index 033fcfbe76..d3c73ae018 100644 --- a/packages/jobs/jobs/src/view.ts +++ b/packages/jobs/jobs/src/view.ts @@ -62,6 +62,8 @@ export interface JobView { readonly label: string /** Owning session; absent for an unowned job, which every caller can see. */ readonly owner?: SessionId + /** Producer-owned cap for complete model-facing notices and reads, in UTF-8 bytes. */ + readonly outputLimitBytes?: number /** Current lifecycle state. */ readonly status: JobStatus /** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */ diff --git a/packages/jobs/jobs/tests/service.spec.ts b/packages/jobs/jobs/tests/service.spec.ts index f3b456acbc..5bcde91862 100644 --- a/packages/jobs/jobs/tests/service.spec.ts +++ b/packages/jobs/jobs/tests/service.spec.ts @@ -77,7 +77,7 @@ describe('JobRegistry seam', () => { expect(jobs.list()).toHaveLength(1) expect(jobs.get(id).status).toBe('running') expect(jobs.get(id).owner).toBe(caller) - expect(jobs.read(id)).toEqual({ chunks: [], lossy: false, job: expect.objectContaining({ id }) }) + expect(jobs.read(id)).toEqual({ chunks: [], lossy: false, job: expect.objectContaining({ id }) as unknown }) expect(jobs.readAt(id, 7)).toEqual({ chunks: [], next: 7, lossy: false }) expect(jobs.kill(id, { reason: 'seam test' })).toBe('requested') await expect(jobs.wait(id, 5)).resolves.toMatchObject({ id }) diff --git a/packages/jobs/tool-jobs/src/index.ts b/packages/jobs/tool-jobs/src/index.ts index 325766ee2b..d189182b72 100644 --- a/packages/jobs/tool-jobs/src/index.ts +++ b/packages/jobs/tool-jobs/src/index.ts @@ -1,9 +1,10 @@ /** * Model-facing `job_output`, `job_list`, and `job_kill` tools over * `ctx.jobs`. Loading the plugin attaches the controller required by - * producers. It also delivers unreported completions to the owning agent: - * injected into a busy owner's next step, or opening a turn on an idle one - * under the default `wakeup` delivery, bounded per owner. + * producers. It also delivers completions the model has not already + * collected to the owning agent: injected into a busy owner's next step, or + * opening a turn on an idle one under the default `wakeup` delivery, bounded + * per owner. * @module @deepseek-ai/dsh-tool-jobs */ @@ -14,14 +15,19 @@ import { TextRetainer } from '@deepseek-ai/dsh-output-retention' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' import { JobId } from '@deepseek-ai/dsh-jobs' -import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' +import type { JobView, VisibleJobs } from '@deepseek-ai/dsh-jobs' import type { Agent } from '@deepseek-ai/dsh-agent' +import { jobDetail, publicJob, renderModelDelta, statusLine } from './render.ts' +import type { PublicJobSnapshot } from './render.ts' + +export { publicJob, renderModelDelta, statusLine } from './render.ts' +export type { PublicJobSnapshot } from './render.ts' export const name = 'tool-jobs' -export const inject = ['tools', 'jobs', 'systemPrompt'] +export const inject = ['tools', 'jobs', 'agents', 'systemPrompt'] /** - * How an unreported completion reaches an owner that is already idle: `wakeup` + * How an uncollected completion reaches an owner that is already idle: `wakeup` * opens a turn for it, `quiet` leaves it pending until something else wakes the * owner. A busy owner is injected either way. */ @@ -51,17 +57,6 @@ export const Config: z = z.object({ maxConsecutiveWakes: z.number().min(1).default(3), }) -/** Task state safe for model-authored programs; ownership/bookkeeping fields are omitted. */ -export interface PublicJobSnapshot { - id: string - kind: string - label: string - status: JobSnapshot['status'] - detail?: string - startedAt: number - finishedAt?: number -} - /** Shared schema for job-control outputs. */ const PUBLIC_TASK_SCHEMA = { type: 'object', @@ -81,30 +76,6 @@ const PUBLIC_TASK_SCHEMA = { }, } as const -/** Remove job ownership and notification bookkeeping from a registry snapshot. */ -function publicJob(snapshot: JobSnapshot): PublicJobSnapshot { - return { - id: snapshot.id, - kind: snapshot.kind, - label: snapshot.label, - status: snapshot.status, - ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, - startedAt: snapshot.startedAt, - ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, - } -} - -/** - * Render generic status with optional producer detail. - * @param snapshot - job state to render. - * @returns a bracketed status line. - */ -export function statusLine(snapshot: Pick): string { - return snapshot.detail !== undefined - ? `[status: ${snapshot.status}, ${snapshot.detail}]` - : `[status: ${snapshot.status}]` -} - const encoder = new TextEncoder() function retainTail(text: string, maxBytes: number): string { @@ -135,19 +106,19 @@ function fitWithSuffix( /** * One-line account of a settled job for the `notice` form's collapsed row. - * @param snapshot - the settled job. + * @param job - the settled job. * @returns its kind, label, and status, bounded like every notice summary. */ -function completionSummary(snapshot: JobSnapshot): string { - return boundContextSummary(`${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`) +function completionSummary(job: JobView): string { + return boundContextSummary(`${job.kind} ${job.label} ${statusLine({ status: job.status, detail: jobDetail(job) })}`) } -function fitCompletionNotice(snapshot: JobSnapshot): string { - const prefix = `background job ${snapshot.id}` - const detail = ` (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}` +function fitCompletionNotice(job: JobView): string { + const prefix = `background job ${job.id}` + const detail = ` (${job.kind}: ${job.label}) finished ${statusLine({ status: job.status, detail: jobDetail(job) })}` const action = '\nDone; job_output.' const complete = `${prefix}${detail}. Read its output with job_output.` - const maxBytes = snapshot.outputLimitBytes + const maxBytes = job.outputLimitBytes if (maxBytes === undefined || encoder.encode(complete).byteLength <= maxBytes) return complete const omitted = '\n[notice truncated]' const fixed = `${prefix}${omitted}${action}` @@ -181,11 +152,12 @@ function boundSingleText(content: readonly ContentBlock[], maxBytes: number): Co }] } +/** The producer's cap for the job a `job_output` or `job_kill` call names, when it is visible to the caller. */ function visibleOutputLimit(ctx: Context, exec: ToolExecution): number | undefined { if (exec.name !== 'job_output' && exec.name !== 'job_kill') return undefined const jobId = (exec.arguments as { job_id?: unknown } | null | undefined)?.job_id if (typeof jobId !== 'string' || jobId.length === 0) return undefined - return ctx.jobs.list(exec.agent).find(snapshot => snapshot.id === jobId)?.outputLimitBytes + return ctx.jobs.visibleTo(exec.agent?.id).list().find(job => job.id === jobId)?.outputLimitBytes } /** Validate the non-empty constraint that ParameterSchemaSpec cannot express. */ @@ -201,6 +173,21 @@ function presentTaskCall(title: string, kind: 'read' | 'execute', rawInput?: str return { card: 'generic', title, kind, ...rawInput !== undefined ? { rawInput } : {} } } +/** True for the three terminal statuses. */ +function isTerminal(job: JobView): boolean { + return job.status === 'completed' || job.status === 'killed' || job.status === 'failed' +} + +/** The consuming read as the model sees it: the delta, then the result once, then the status line. */ +function readBody(jobs: VisibleJobs, id: JobId): { text: string; job: PublicJobSnapshot } { + const read = jobs.read(id) + const delta = renderModelDelta(read.chunks, read.lossy) + const text = read.result === undefined + ? delta + : `${delta}${delta.length > 0 && !delta.endsWith('\n') ? '\n' : ''}${read.result}` + return { text, job: publicJob(read.job) } +} + export function apply(ctx: Context, config: Config): void { const waitDefault = config.waitTimeoutMs ?? 30_000 const waitCap = config.maxWaitTimeoutMs ?? 600_000 @@ -262,31 +249,52 @@ export function apply(ctx: Context, config: Config): void { ctx.systemPrompt.section({ name: 'tool:jobs', order: ctx.systemPrompt.getSectionOrder('TOOL_JOBS'), - text: 'Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job\'s work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.', + text: 'Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not stop your turn just to wait. Use job_output when you need its output now. Use job_kill to stop a job you no longer need.', }) - // Use the exact lifecycle owner; reusable ids could resolve to a replacement. + // Jobs whose terminal state this plugin already handed to the model through + // a tool result: a terminal `job_output`, a wait that returned it, or a + // `job_kill` it requested. A wait claims when it starts, not when it + // returns — settlement releases waiters before it announces, but their + // continuations run after the event — and withdraws the claim if the job + // outlives the wait. The settlement event drops the entry, so the ledger + // holds only live jobs the model already touched. + const delivered = new Set() + const claim = (id: JobId): (() => void) => { + delivered.add(id) + return () => { delivered.delete(id) } + } + // A busy owner is injected: the notice waits in its next-step inbox, which // the turn cannot close over, so jobs settling together cost one step. An // idle owner is woken instead, because an unclaimed notice is a completion // the model never learns about. Either way, disposal before the claim - // discards it with the owner, and teardown settlements arrive `reported`. + // discards it with the owner, and a teardown settlement has no reader left. // - // The registry routes each settlement to the listeners its owner's scope - // chain reaches, so a mount under one preset never sees another preset's - // agents; this listener owns delivery, not the choice of whom to deliver to. - ctx.jobs.onJobDone((snapshot, owner) => { - if (snapshot.reported || owner === undefined) return + // The registry routes each settlement to the scope this plugin was mounted + // under, so a mount under one preset never sees another preset's agents; + // this listener owns delivery, not the choice of whom to deliver to. + ctx.jobs.events.subscribe({ owners: 'scope' }, (event) => { + if (event.type === 'removed') { + delivered.delete(event.job.id) + return + } + if (event.type !== 'settled') return + const claimed = delivered.delete(event.job.id) + if (claimed || event.cause === 'teardown' || event.job.owner === undefined) return + // Use the exact lifecycle owner; a session whose agent left has no inbox. + const owner = ctx.agents.get(event.job.owner) + if (owner === undefined) return const message = createUserMessage({ content: [{ type: 'text', - text: fitCompletionNotice(snapshot), + text: fitCompletionNotice(event.job), }], source: { kind: 'plugin', plugin: 'tool-jobs', form: 'notice', - summary: completionSummary(snapshot), + summary: completionSummary(event.job), }, }) const spent = spentWakes.get(owner) ?? 0 @@ -328,12 +336,19 @@ export function apply(ctx: Context, config: Config): void { }, async execute(args, exec) { const id = validateJobId(args.job_id) + const jobs = ctx.jobs.visibleTo(exec.agent?.id) if (args.wait === true) { const timeout = Math.min(args.timeout_ms ?? waitDefault, waitCap) - await ctx.jobs.wait(id, timeout, exec.agent, exec.signal) + const withdraw = claim(id) + try { + const view = await jobs.wait(id, timeout, exec.signal) + if (!isTerminal(view)) withdraw() + } catch (error: unknown) { + withdraw() + throw error + } } - const read = ctx.jobs.read(id, exec.agent) - return { text: read.text, job: publicJob(read.snapshot) } + return readBody(jobs, id) }, presentCall: args => presentTaskCall(`Read output from background job ${args.job_id}`, 'read', args.job_id), })) @@ -352,7 +367,7 @@ export function apply(ctx: Context, config: Config): void { }], }, execute(_args, exec) { - const jobs = ctx.jobs.list(exec.agent) + const jobs = ctx.jobs.visibleTo(exec.agent?.id).list() return Promise.resolve(jobs.map(publicJob)) }, presentCall: () => presentTaskCall('List background jobs', 'read'), @@ -388,12 +403,16 @@ export function apply(ctx: Context, config: Config): void { }, execute(args, exec) { const id = validateJobId(args.job_id) - const result = ctx.jobs.kill(id, exec.agent, args.reason) - // A snapshot describes current state without consuming pending output. - const snapshot = publicJob(ctx.jobs.get(id, exec.agent)) + const jobs = ctx.jobs.visibleTo(exec.agent?.id) + const result = jobs.kill(id, args.reason === undefined ? {} : { reason: args.reason }) + // The model's own kill is its delivery: the settlement notice would only + // repeat what this tool result already said. + if (result === 'requested') claim(id) + // A projection describes current state without consuming pending output. + const job = publicJob(jobs.get(id)) return Promise.resolve({ outcome: result === 'already-finished' ? 'already-finished' as const : 'cancellation-requested' as const, - job: snapshot, + job, }) }, presentCall: args => presentTaskCall(`Kill background job ${args.job_id}`, 'execute', args.job_id), diff --git a/packages/jobs/tool-jobs/src/render.ts b/packages/jobs/tool-jobs/src/render.ts new file mode 100644 index 0000000000..74b0255cbd --- /dev/null +++ b/packages/jobs/tool-jobs/src/render.ts @@ -0,0 +1,80 @@ +/** + * Model-facing rendering of registry reads: the consuming delta as the + * shell tools have always shown it (stdout, then one marked stderr section), + * the `[status: …]` line, and the public job projection the tool schemas + * expose. + * @module @deepseek-ai/dsh-tool-jobs/render + */ + +import type { JobChunk, JobView } from '@deepseek-ai/dsh-jobs' + +/** Job state safe for model-authored programs; ownership and offsets are omitted. */ +export interface PublicJobSnapshot { + id: string + kind: string + label: string + status: JobView['status'] + /** The live progress line while running, the terminal reason once settled. */ + detail?: string + startedAt: number + finishedAt?: number +} + +/** + * The one-line qualifier the model reads beside a status: live progress while + * the job runs, the terminal reason once it settled. + * @param job - the job projection. + * @returns the qualifier, or undefined when the job carries neither. + */ +export function jobDetail(job: Pick): string | undefined { + return job.progress ?? job.detail +} + +/** + * Remove ownership, offsets, and limits from a registry projection. + * @param job - the registry projection. + * @returns the model-safe projection. + */ +export function publicJob(job: JobView): PublicJobSnapshot { + const detail = jobDetail(job) + return { + id: job.id, + kind: job.kind, + label: job.label, + status: job.status, + ...detail !== undefined ? { detail } : {}, + startedAt: job.startedAt, + ...job.finishedAt !== undefined ? { finishedAt: job.finishedAt } : {}, + } +} + +/** + * Render generic status with optional detail. + * @param snapshot - job state to render. + * @returns a bracketed status line. + */ +export function statusLine(snapshot: Pick): string { + return snapshot.detail !== undefined + ? `[status: ${snapshot.status}, ${snapshot.detail}]` + : `[status: ${snapshot.status}]` +} + +/** + * Render one consuming read for the model: stdout and unlabeled chunks in + * order, then every stderr chunk in one `[stderr]` section, exactly as the + * shell tools render a foreground result. `log` chunks are producer + * narration for observers and never reach the model. A lossy read — the + * cursor fell behind the ring's retention — ends with a dropped-output + * notice. + * @param chunks - the chunks since the model cursor, in offset order. + * @param lossy - whether bytes before `chunks` were evicted unread. + * @returns the delta text, possibly empty. + */ +export function renderModelDelta(chunks: readonly JobChunk[], lossy: boolean): string { + const out = chunks.filter(chunk => chunk.channel === undefined || chunk.channel === 'stdout').map(chunk => chunk.text).join('') + const err = chunks.filter(chunk => chunk.channel === 'stderr').map(chunk => chunk.text).join('') + const separator = out.length > 0 && !out.endsWith('\n') ? '\n' : '' + const body = out + (err.length > 0 ? `${separator}[stderr]\n${err}` : '') + if (!lossy) return body + return `${body}${body.length > 0 && !body.endsWith('\n') ? '\n' : ''}[some output was dropped from memory before this read]` +} diff --git a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts index 4c6a81e48a..6c792226d4 100644 --- a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts +++ b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts @@ -10,21 +10,26 @@ import { SessionId } from '@deepseek-ai/dsh-session' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import { JobId } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' -import type { JobHooks, JobOutcome, JobSnapshot, JobStart } from '@deepseek-ai/dsh-jobs' +import type { JobAppendOptions, JobHandle, JobHooks, JobOutcome, JobSpec, JobView } from '@deepseek-ai/dsh-jobs' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' -import { statusLine } from '@deepseek-ai/dsh-tool-jobs' +import { publicJob, renderModelDelta, statusLine } from '@deepseek-ai/dsh-tool-jobs' const testToolSignal = new AbortController().signal const agentRegistryDisposers = new WeakMap void>() const agentScopeFibers = new WeakMap Promise }>() -async function setup(config: ToolTasks.Config = {}) { +/** Registry knobs a test may narrow; every producer here pushes, so the pump stays idle. */ +interface RegistryConfig { + retainBytes?: number +} + +async function setup(config: ToolTasks.Config = {}, registry: RegistryConfig = {}) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) const agentsFiber = await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) + await ctx.plugin(LocalJobRegistry, registry) const toolsFiber = await ctx.plugin(ToolTasks, config) return { ctx, agentsFiber, toolsFiber } } @@ -70,9 +75,13 @@ async function disposeAgentScope(agent: Agent): Promise { await fiber.dispose() } -/** A controllable producer start-spec (settle `done` on demand, record cancels). */ -function producer(overrides: Partial & JobHooks> = {}) { +/** + * A controllable producer spec: pushes output and progress through the handle + * the registry hands its starter, settles `done` on demand, records cancels. + */ +function producer(overrides: Partial & JobHooks> = {}) { let settle!: (outcome: JobOutcome) => void + let handle: JobHandle | undefined const cancels: (string | undefined)[] = [] const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides const hooks: JobHooks = { @@ -80,14 +89,24 @@ function producer(overrides: Partial & JobHooks> = {}) { done: new Promise((res) => { settle = res }), ...hookOverrides, } - const spec: JobStart = { + const spec: JobSpec = { kind, label, ...owner !== undefined ? { owner } : {}, ...outputLimitBytes !== undefined ? { outputLimitBytes } : {}, - run: () => hooks, + run: (job) => { handle = job; return hooks }, + } + const started = (): JobHandle => { + if (handle === undefined) throw new Error('producer not started') + return handle + } + return { + spec, + settle, + cancels, + append: (text: string, options?: JobAppendOptions) => { started().append(text, options) }, + progress: (line: string) => { started().updateProgress(line) }, } - return { spec, settle, cancels } } let callCounter = 0 @@ -104,7 +123,7 @@ const tick = () => new Promise(r => setTimeout(r, 0)) /** Start and settle `count` owned jobs one at a time, letting each notice land. */ async function settleTasks(ctx: Context, owner: Agent, count: number): Promise { for (let i = 0; i < count; i += 1) { - const p = producer({ owner }) + const p = producer({ owner: owner.id }) ctx.jobs.start(p.spec) p.settle({ status: 'completed' }) await tick() @@ -119,10 +138,18 @@ describe('tool-jobs setup', () => { expect(() => ctx.jobs.start(producer().spec)).toThrow('no job controller serves this agent') }) + it('leaves with the agent registry it resolves owners through', async () => { + const { ctx, agentsFiber } = await setup() + await agentsFiber.dispose() + expect(ctx.tools.get('job_output')).toBeUndefined() + expect(() => ctx.jobs.start(producer().spec)).toThrow('no job controller serves this agent') + }) + it('rejects a config whose default wait exceeds the cap', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await expect(ctx.plugin(ToolTasks, { waitTimeoutMs: 100, maxWaitTimeoutMs: 50 })) .rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)') @@ -141,6 +168,7 @@ describe('tool-jobs setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) try { await ctx.plugin(ToolTasks, { maxConsecutiveWakes }) @@ -158,9 +186,35 @@ describe('tool-jobs setup', () => { }) it('renders status lines with and without producer detail', () => { - const base = { id: 'bash-1', kind: 'bash', label: 'x', startedAt: 0, reported: false } as unknown as JobSnapshot - expect(statusLine({ ...base, status: 'running' })).toBe('[status: running]') - expect(statusLine({ ...base, status: 'completed', detail: 'exit code: 0' })).toBe('[status: completed, exit code: 0]') + expect(statusLine({ status: 'running' })).toBe('[status: running]') + expect(statusLine({ status: 'completed', detail: 'exit code: 0' })).toBe('[status: completed, exit code: 0]') + }) + + it('projects the public job: live progress or terminal detail, never ownership or offsets', () => { + const live: JobView = { + id: JobId('bash-1'), kind: 'bash', label: 'x', owner: SessionId('sess-1'), outputLimitBytes: 8, + status: 'running', progress: '3/10 files', startedAt: 5, output: { total: 12, earliest: 0 }, + } + expect(publicJob(live)).toEqual({ id: 'bash-1', kind: 'bash', label: 'x', status: 'running', detail: '3/10 files', startedAt: 5 }) + const settled: JobView = { + id: JobId('bash-1'), kind: 'bash', label: 'x', status: 'completed', detail: 'exit code: 0', + startedAt: 5, finishedAt: 9, output: { total: 12, earliest: 0 }, + } + expect(publicJob(settled)).toEqual({ id: 'bash-1', kind: 'bash', label: 'x', status: 'completed', detail: 'exit code: 0', startedAt: 5, finishedAt: 9 }) + }) + + it('renders a model delta as stdout, one stderr section, and a dropped-output notice', () => { + expect(renderModelDelta([], false)).toBe('') + expect(renderModelDelta([], true)).toBe('[some output was dropped from memory before this read]') + expect(renderModelDelta([ + { at: 0, text: 'a' }, + { at: 1, text: 'e1\n', channel: 'stderr' }, + { at: 4, text: 'narration', channel: 'log' }, + { at: 13, text: 'b', channel: 'stdout' }, + { at: 14, text: 'e2', channel: 'stderr' }, + ], false)).toBe('ab\n[stderr]\ne1\ne2') + expect(renderModelDelta([{ at: 0, text: 'tail' }], true)).toBe('tail\n[some output was dropped from memory before this read]') + expect(renderModelDelta([{ at: 0, text: 'line\n' }], true)).toBe('line\n[some output was dropped from memory before this read]') }) it('applies the built-in wait bounds when apply() receives a bare config', async () => { @@ -169,6 +223,7 @@ describe('tool-jobs setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) ToolTasks.apply(ctx, {}) expect(ctx.tools.get('job_output')).toBeDefined() @@ -179,8 +234,9 @@ describe('tool-jobs setup', () => { describe('job_output', () => { it('reads a consuming delta with a trailing status line', async () => { const { ctx } = await setup() - const chunks = ['line one\n', ''] - ctx.jobs.start(producer({ readOutput: () => chunks.shift() ?? '' }).spec) + const p = producer() + ctx.jobs.start(p.spec) + p.append('line one\n') // A body already ending in a newline gets no doubled separator. const first = await call(ctx, 'job_output', { job_id: 'bash-1' }) @@ -190,29 +246,75 @@ describe('job_output', () => { text: 'line one\n', job: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'running' }, }) - expect(firstValue.job).not.toHaveProperty('ownerSession') - expect(firstValue.job).not.toHaveProperty('reported') + expect(firstValue.job).not.toHaveProperty('owner') + expect(firstValue.job).not.toHaveProperty('output') expect(text(first)).toBe('line one\n[status: running]') expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: running]') }) - it('returns the final output of a settled final-output job', async () => { + it('renders stderr in a trailing section and never shows log narration', async () => { + const { ctx } = await setup() + const p = producer() + ctx.jobs.start(p.spec) + p.append('out\n') + p.append('warn\n', { channel: 'stderr' }) + p.append('observer-only narration', { channel: 'log' }) + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('out\n[stderr]\nwarn\n[status: running]') + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: running]') + }) + + it('shows live progress beside the status until settlement replaces it with the terminal detail', async () => { + const { ctx } = await setup() + const p = producer() + ctx.jobs.start(p.spec) + p.progress('3/10 files') + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: running, 3/10 files]') + p.settle({ status: 'completed', detail: 'exit code: 0' }) + await tick() + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: completed, exit code: 0]') + }) + + it('flags a read whose cursor fell behind the ring', async () => { + const { ctx } = await setup({}, { retainBytes: 16 }) + const p = producer() + ctx.jobs.start(p.spec) + p.append('x'.repeat(40)) + const first = text(await call(ctx, 'job_output', { job_id: 'bash-1' })) + expect(first).toContain('[some output was dropped from memory before this read]') + expect(first).toContain('[status: running]') + expect(first).not.toContain('x'.repeat(17)) + // The cursor now sits at the ring's end: the next read is clean. + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: running]') + }) + + it('returns the result of a settled job exactly once, after the pending delta', async () => { const { ctx } = await setup() const p = producer({ kind: 'subagent', label: 'research' }) ctx.jobs.start(p.spec) expect(text(await call(ctx, 'job_output', { job_id: 'subagent-1' }))).toBe('(no new output)\n[status: running]') - p.settle({ status: 'completed', detail: 'completed', output: 'the answer' }) + p.append('partial') + p.settle({ status: 'completed', detail: 'completed', result: 'the answer' }) await tick() - expect(text(await call(ctx, 'job_output', { job_id: 'subagent-1' }))).toBe('the answer\n[status: completed, completed]') + expect(text(await call(ctx, 'job_output', { job_id: 'subagent-1' }))).toBe('partial\nthe answer\n[status: completed, completed]') + expect(text(await call(ctx, 'job_output', { job_id: 'subagent-1' }))).toBe('(no new output)\n[status: completed, completed]') + }) + + it('keeps a newline-terminated delta and the result on separate lines', async () => { + const { ctx } = await setup() + const p = producer({ kind: 'subagent', label: 'research' }) + ctx.jobs.start(p.spec) + p.append('progress log\n') + p.settle({ status: 'completed', result: 'the answer' }) + await tick() + expect(text(await call(ctx, 'job_output', { job_id: 'subagent-1' }))).toBe('progress log\nthe answer\n[status: completed]') }) it('applies a producer limit to the complete body and status result', async () => { const { ctx } = await setup() - ctx.jobs.start(producer({ - outputLimitBytes: 48, - readOutput: () => '界'.repeat(100), - }).spec) + const p = producer({ outputLimitBytes: 48 }) + ctx.jobs.start(p.spec) + p.append('界'.repeat(100)) const output = text(await call(ctx, 'job_output', { job_id: 'bash-1' })) expect(Buffer.byteLength(output)).toBeLessThanOrEqual(48) @@ -221,24 +323,21 @@ describe('job_output', () => { it('preserves empty and newline-terminated output under a producer limit', async () => { const { ctx } = await setup() - const chunks = ['', 'line\n'] - ctx.jobs.start(producer({ - outputLimitBytes: 64, - readOutput: () => chunks.shift() ?? '', - }).spec) + const p = producer({ outputLimitBytes: 64 }) + ctx.jobs.start(p.spec) expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))) .toBe('(no new output)\n[status: running]') + p.append('line\n') expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))) .toBe('line\n[status: running]') }) it('bounds post-policy output without restoring the canonical status rendering', async () => { const { ctx } = await setup() - ctx.jobs.start(producer({ - outputLimitBytes: 64, - readOutput: () => 'canonical output', - }).spec) + const p = producer({ outputLimitBytes: 64 }) + ctx.jobs.start(p.spec) + p.append('canonical output') ctx.on('tools/post-execute', (exec, _result, next) => { if (exec.name !== 'job_output') return next() return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'p'.repeat(1_000) }] }) @@ -250,17 +349,30 @@ describe('job_output', () => { expect(text(result)).not.toContain('[status: running]') }) - it('applies a producer limit to a normalized read failure', async () => { + it('does not double a truncation marker a policy already appended', async () => { const { ctx } = await setup() - ctx.jobs.start(producer({ - outputLimitBytes: 64, - readOutput: () => { throw new Error('read failed: '.repeat(100)) }, - }).spec) + ctx.jobs.start(producer({ outputLimitBytes: 64 }).spec) + ctx.on('tools/post-execute', (exec, _result, next) => { + if (exec.name !== 'job_output') return next() + return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: `${'z'.repeat(1_000)}\n[result truncated]` }] }) + }) const result = await call(ctx, 'job_output', { job_id: 'bash-1' }) - expect(result.isError).toBe(true) expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64) - expect(text(result)).toContain('[result truncated]') + expect(text(result).match(/\[result truncated\]/g)).toHaveLength(1) + }) + + it('refuses a job owned by another session as an errored result', async () => { + const { ctx } = await setup() + const alice = fakeAgent(ctx, 'sess-alice') + ctx.jobs.start(producer({ owner: alice.id }).spec) + const bob = fakeAgent(ctx, 'sess-bob') + + const result = await call(ctx, 'job_output', { job_id: 'bash-1' }, bob) + expect(result.isError).toBe(true) + expect(text(result)).toContain('belongs to another session') + expect((await call(ctx, 'job_output', { job_id: 'bash-1' })).isError).toBe(true) + expect((await call(ctx, 'job_output', { job_id: 'bash-1' }, alice)).isError).toBe(false) }) it('bounds pre-, around-, and post-execute policy outcomes and failures', async () => { @@ -325,7 +437,7 @@ describe('job_output', () => { ctx.jobs.start(p.spec) const pending = call(ctx, 'job_output', { job_id: 'subagent-1', wait: true }) - p.settle({ status: 'completed', output: 'done deal' }) + p.settle({ status: 'completed', result: 'done deal' }) expect(text(await pending)).toBe('done deal\n[status: completed]') }) @@ -354,9 +466,9 @@ describe('job_list', () => { expect(text(await call(ctx, 'job_list', {}))).toBe('(no background jobs)') const alice = fakeAgent(ctx, 'sess-alice') - ctx.jobs.start(producer({ owner: alice, label: 'pnpm test' }).spec) + ctx.jobs.start(producer({ owner: alice.id, label: 'pnpm test' }).spec) ctx.jobs.start(producer({ kind: 'subagent', label: 'open research' }).spec) - const p = producer({ owner: alice, label: 'build' }) + const p = producer({ owner: alice.id, label: 'build' }) ctx.jobs.start(p.spec) p.settle({ status: 'completed', detail: 'exit code: 0' }) await tick() @@ -368,8 +480,8 @@ describe('job_list', () => { expect(listedValue[0]).toMatchObject({ id: 'bash-1', kind: 'bash', label: 'pnpm test', status: 'running' }) expect(listedValue[2]).toMatchObject({ id: 'bash-2', kind: 'bash', label: 'build', status: 'completed', detail: 'exit code: 0' }) for (const job of listedValue) { - expect(job).not.toHaveProperty('ownerSession') - expect(job).not.toHaveProperty('reported') + expect(job).not.toHaveProperty('owner') + expect(job).not.toHaveProperty('output') } expect(text(listed)).toBe([ 'bash-1 [bash] running — pnpm test', @@ -395,8 +507,8 @@ describe('job_kill', () => { outcome: 'cancellation-requested', job: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'stopping' }, }) - expect(killValue.job).not.toHaveProperty('ownerSession') - expect(killValue.job).not.toHaveProperty('reported') + expect(killValue.job).not.toHaveProperty('owner') + expect(killValue.job).not.toHaveProperty('output') expect(text(result)).toBe('requested cancellation of job bash-1') expect(p.cancels).toEqual(['superseded']) }) @@ -422,7 +534,7 @@ describe('job_kill', () => { expect(result.isError).toBe(true) expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64) expect(text(result)).toContain('[result truncated]') - expect(ctx.jobs.get(JobId('bash-1'))).toMatchObject({ status: 'running', reported: false }) + expect(ctx.jobs.visibleTo().get(JobId('bash-1')).status).toBe('running') }) it('bounds single-text post policy while preserving structured policy results', async () => { @@ -470,9 +582,9 @@ describe('job_kill', () => { it('reports an already-finished job without consuming its pending delta', async () => { const { ctx } = await setup() - let delta = 'unread tail' - const p = producer({ readOutput: () => { const d = delta; delta = ''; return d } }) + const p = producer() ctx.jobs.start(p.spec) + p.append('unread tail') p.settle({ status: 'completed', detail: 'exit code: 0' }) await tick() @@ -483,7 +595,7 @@ describe('job_kill', () => { job: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'completed', detail: 'exit code: 0' }, }) expect(text(killed)).toBe('job bash-1 had already finished [status: completed, exit code: 0]') - // The kill described the job via a non-consuming snapshot: the delta is intact. + // The kill described the job via a non-consuming projection: the delta is intact. expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('unread tail\n[status: completed, exit code: 0]') }) @@ -508,9 +620,9 @@ describe('tool-owned UI presentation (presentCall)', () => { describe('completion notices across scoped mounts', () => { /** * Two agent presets mounting `tool-jobs` over ONE host registry: each mount - * registers its own `onJobDone` listener on the shared service, and - * `settle()` broadcasts one snapshot to every listener with no scope filter. - * Only the mount whose scope the owner belongs to may deliver the notice. + * subscribes with `{ owners: 'scope' }`, which the registry files into the + * mount's own scope layer and reaches along the owner's scope chain. Only + * the mount whose scope the owner belongs to sees the settlement. */ it('delivers one notice from the owning scope when two mounts share the registry', async () => { const ctx = new Context() @@ -539,9 +651,7 @@ describe('completion notices across scoped mounts', () => { const dispose = ctx.agents.register(owner) try { - // No waiter: `settle()` leaves `reported` false, which is the only path - // that reaches the notice listeners at all. - const p = producer({ owner, label: 'pnpm test' }) + const p = producer({ owner: owner.id, label: 'pnpm test' }) ctx.jobs.start(p.spec) p.settle({ status: 'completed', detail: 'exit code: 0' }) await tick() @@ -559,7 +669,7 @@ describe('completion notice delivery', () => { const inject = vi.fn() const followup = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) - const p = producer({ owner, label: 'pnpm test' }) + const p = producer({ owner: owner.id, label: 'pnpm test' }) ctx.jobs.start(p.spec) p.settle({ status: 'completed', detail: 'exit code: 0' }) @@ -573,7 +683,7 @@ describe('completion notice delivery', () => { const inject = vi.fn() const followup = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) - const p = producer({ owner }) + const p = producer({ owner: owner.id }) ctx.jobs.start(p.spec) p.settle({ status: 'completed' }) @@ -621,15 +731,15 @@ describe('completion notice delivery', () => { ctx.jobs.start({ kind: 'bash', label: 'sleep 60', - owner, + owner: owner.id, run: () => ({ cancel() { settle({ status: 'killed' }) }, done: new Promise((res) => { settle = res }), }), }) - // Disposal cancels and settles the owned job. Waking here would spend a - // model request on an agent the host is destroying, once per tree layer. + // Disposal cancels and settles the owned job with a teardown cause. Waking + // here would spend a model request on an agent the host is destroying. await disposeAgentScope(owner) await tick() expect(followup).not.toHaveBeenCalled() @@ -645,17 +755,16 @@ describe('completion notice delivery', () => { ctx.jobs.start({ kind: 'bash', label: 'broken producer', - owner, + owner: owner.id, run: () => ({ cancel() { throw new Error('cancel boom') }, done: new Promise(() => {}), }), }) - // The registry force-fails the record instead of deadlocking. That path - // settles the job too, so it must claim the report as the ordinary - // teardown cancel does — otherwise a throwing producer is all it takes to - // spend a model request on an owner being destroyed. + // The registry force-fails the record instead of deadlocking. That + // settlement also announces a teardown cause, so a throwing producer is + // not enough to spend a model request on an owner being destroyed. await disposeAgentScope(owner) await tick() expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned')) @@ -682,11 +791,11 @@ describe('completion notice delivery', () => { }) describe('completion notices', () => { - it('injects a notice into the owning agent when an unreported job settles', async () => { + it('injects a notice into the owning agent when an uncollected job settles', async () => { const { ctx } = await setup() const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject }) - const p = producer({ owner, label: 'pnpm test' }) + const p = producer({ owner: owner.id, label: 'pnpm test' }) ctx.jobs.start(p.spec) p.settle({ status: 'completed', detail: 'exit code: 0' }) @@ -710,7 +819,7 @@ describe('completion notices', () => { const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject }) const first = producer({ - owner, + owner: owner.id, kind: 'subagent', label: 'x'.repeat(1_000), outputLimitBytes: 61, @@ -737,7 +846,7 @@ describe('completion notices', () => { ) const second = producer({ - owner, + owner: owner.id, kind: 'subagent', label: 'x'.repeat(1_000), outputLimitBytes: 80, @@ -764,7 +873,7 @@ describe('completion notices', () => { const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject }) const target = producer({ - owner, + owner: owner.id, kind: 'pty-send', label: 'x'.repeat(1_000), outputLimitBytes: 64, @@ -784,8 +893,8 @@ describe('completion notices', () => { const { ctx } = await setup() const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject }) - const tiny = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 }) - const short = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 }) + const tiny = producer({ owner: owner.id, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 }) + const short = producer({ owner: owner.id, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 }) ctx.jobs.start(tiny.spec) ctx.jobs.start(short.spec) @@ -805,7 +914,7 @@ describe('completion notices', () => { const { ctx } = await setup() const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject }) - const p = producer({ owner }) + const p = producer({ owner: owner.id }) ctx.jobs.start(p.spec) await call(ctx, 'job_kill', { job_id: 'bash-1' }, owner) @@ -818,15 +927,55 @@ describe('completion notices', () => { const { ctx } = await setup() const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject }) - const p = producer({ owner, kind: 'subagent' }) + const p = producer({ owner: owner.id, kind: 'subagent' }) ctx.jobs.start(p.spec) const pending = call(ctx, 'job_output', { job_id: 'subagent-1', wait: true }, owner) - p.settle({ status: 'completed', output: 'answer' }) + p.settle({ status: 'completed', result: 'answer' }) expect(text(await pending)).toContain('answer') + await tick() expect(inject).not.toHaveBeenCalled() }) + it('withdraws the wait claim when the wait times out, so the later settlement still notifies', async () => { + const { ctx } = await setup({ waitTimeoutMs: 10, maxWaitTimeoutMs: 20 }) + const inject = vi.fn() + const owner = fakeAgent(ctx, 'sess-1', { inject }) + const p = producer({ owner: owner.id }) + ctx.jobs.start(p.spec) + + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1', wait: true }, owner))).toBe('(no new output)\n[status: running]') + p.settle({ status: 'completed' }) + await tick() + expect(inject).toHaveBeenCalledTimes(1) + }) + + it('withdraws the wait claim when the caller aborts the wait', async () => { + const { ctx } = await setup() + const inject = vi.fn() + const owner = fakeAgent(ctx, 'sess-1', { inject }) + const p = producer({ owner: owner.id }) + ctx.jobs.start(p.spec) + + const aborter = new AbortController() + const pending = ctx.tools.execute({ + signal: aborter.signal, + callId: ToolCallId('call-aborted-wait'), + name: 'job_output', + arguments: { job_id: 'bash-1', wait: true }, + agent: owner, + }) + await tick() + aborter.abort() + const result = await pending + expect(result.isError).toBe(true) + expect(text(result)).toContain('wait aborted') + + p.settle({ status: 'completed' }) + await tick() + expect(inject).toHaveBeenCalledTimes(1) + }) + it('drops the notice for unowned jobs without throwing', async () => { const { ctx } = await setup() // Unowned: settles with nobody to notify — nothing throws. @@ -836,31 +985,45 @@ describe('completion notices', () => { await tick() }) - it('does not route an old owner completion notice to a same-session replacement', async () => { + it('delivers to the agent currently registered for the owner session', async () => { const { ctx } = await setup() - // Delivery into a tearing-down owner is a plain inject: the loop has no - // terminal state, so the notice lands in the old owner's (detached) - // session instead of throwing or re-routing. + // The settlement names the owner session; the notice goes to whichever + // agent holds that session when the job settles, which can read the job + // through the same session id. const oldInject = vi.fn() const oldOwner = fakeAgent(ctx, 'shared', { inject: oldInject }) - const p = producer({ owner: oldOwner }) + const p = producer({ owner: oldOwner.id }) ctx.jobs.start(p.spec) detachAgent(oldOwner) const replacementInject = vi.fn() - fakeAgent(ctx, 'shared', { inject: replacementInject }) + const replacement = fakeAgent(ctx, 'shared', { inject: replacementInject }) p.settle({ status: 'completed' }) await tick() - expect(oldInject).toHaveBeenCalledTimes(1) - expect(replacementInject).not.toHaveBeenCalled() + expect(oldInject).not.toHaveBeenCalled() + expect(replacementInject).toHaveBeenCalledTimes(1) + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }, replacement))).toBe('(no new output)\n[status: completed]') + }) + + it('drops the notice when the owner session has no live agent at settlement', async () => { + const { ctx } = await setup() + const inject = vi.fn() + const owner = fakeAgent(ctx, 'sess-1', { inject }) + const p = producer({ owner: owner.id }) + ctx.jobs.start(p.spec) + + detachAgent(owner) + p.settle({ status: 'completed' }) + await tick() + expect(inject).not.toHaveBeenCalled() }) it('surfaces an inject failure through listener containment (a real bug must be visible)', async () => { const { ctx } = await setup() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const owner = fakeAgent(ctx, 'sess-1', { inject: () => { throw new Error('unexpected inject bug') } }) - const p = producer({ owner }) + const p = producer({ owner: owner.id }) ctx.jobs.start(p.spec) p.settle({ status: 'completed' }) await tick() @@ -868,23 +1031,4 @@ describe('completion notices', () => { // registry's per-listener containment — visible, not swallowed. expect(warn).toHaveBeenCalledWith(expect.stringContaining('unexpected inject bug')) }) - - it('keeps using the exact owner after the agent registry is gone', async () => { - const { ctx, agentsFiber } = await setup() - const inject = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject }) - - // Settlement must not depend on a later registry lookup: the exact owner - // supplied at start remains the destination while its own scope is live. - const p1 = producer({ owner }) - ctx.jobs.start(p1.spec) - const p2 = producer({ owner }) - ctx.jobs.start(p2.spec) - - await agentsFiber.dispose() - p1.settle({ status: 'completed' }) - p2.settle({ status: 'failed' }) - await tick() - expect(inject).toHaveBeenCalledTimes(2) - }) }) From 053d302210a2e1d9c30c6efa0ae9530e8c8c313e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 16:30:57 +0800 Subject: [PATCH 032/284] refactor(shell tools): hand the job registry pull sources and terminal outcomes tool-bash and tool-pwsh no longer render their own background reads. processSources() wraps the process's observed offset readers as registry pull sources, pumped at dsh-jobs-local's pumpPollMs, and processOutcome() folds the settled sandbox facts into the terminal detail. observe.ts and the recordPollMs config field are gone; READMEs and pairings follow. --- packages/shell/tool-bash/README.i18n.yaml | 4 +- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-bash/src/background.ts | 67 +++++++-- packages/shell/tool-bash/src/index.ts | 36 ++--- packages/shell/tool-bash/src/observe.ts | 47 ------- packages/shell/tool-bash/src/render.ts | 34 +---- .../{observe.spec.ts => background.spec.ts} | 127 ++++++++---------- packages/shell/tool-bash/tests/tools.spec.ts | 62 ++------- packages/shell/tool-pwsh/README.i18n.yaml | 4 +- packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/README.zh.md | 2 +- packages/shell/tool-pwsh/src/background.ts | 69 ++++++++-- packages/shell/tool-pwsh/src/index.ts | 36 ++--- packages/shell/tool-pwsh/src/observe.ts | 49 ------- packages/shell/tool-pwsh/src/render.ts | 32 +---- .../{observe.spec.ts => background.spec.ts} | 71 +++++----- packages/shell/tool-pwsh/tests/tools.spec.ts | 79 +++-------- 18 files changed, 280 insertions(+), 445 deletions(-) delete mode 100644 packages/shell/tool-bash/src/observe.ts rename packages/shell/tool-bash/tests/{observe.spec.ts => background.spec.ts} (51%) delete mode 100644 packages/shell/tool-pwsh/src/observe.ts rename packages/shell/tool-pwsh/tests/{observe.spec.ts => background.spec.ts} (73%) diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index 57f2951bfa..8832f9e8ee 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -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/shell/tool-bash/README.md -README.md: 50047a5fe0d69da1feb38f75ada27595b88fa5c5 -README.zh.md: c74a45561298defb4baa5b34be7cebc1cdfbc744 +README.md: c824ee897162e8ca1b1e616f3dcace43e58f8c9a +README.zh.md: f6240153855edd2ab4eb12e8038e2108f0842b3b diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 50047a5fe0..c824ee8971 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -55,7 +55,7 @@ The tool executes `bash -c ` and returns the combined output. Commands ### Running long commands in the background -Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted. The background job declares an observation record: the run's non-consuming `observed` readers are pumped into it at `recordPollMs` (default 150), so the Web client streams live output without touching the job's consuming cursor; a pump failure is logged and swallowed while the job runs on. The record is a best-effort live preview: stdout and stderr are copied per poll round, so writes the two streams made inside one `recordPollMs` window appear stdout first rather than in write order. +Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted. The background job hands the run's non-consuming `observed` readers to the job registry as pull sources; the registry pumps them into the job's output ring at its own cadence (`pumpPollMs` on `dsh-jobs-local`), so the Web client streams live output and the model's `job_output` reads consume the same bytes through a separate cursor. A reader that throws is logged once and its stream stops; the job runs on to its own settlement. The ring is a best-effort live preview: stdout and stderr are copied per poll round, so writes the two streams made inside one poll window appear stdout first rather than in write order. ### Sandboxed execution and escalation diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index c74a455612..f624015385 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -55,7 +55,7 @@ kind: "package-reference" ### 后台运行长时间命令 -传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。后台 job 声明观测 record:运行的非消费 `observed` 读取器以 `recordPollMs`(默认 150)的节奏泵入其中,使 Web 客户端在不触碰 job 消耗游标的情况下流式看到实时输出;泵失败会被记录并吞掉,job 继续运行。record 是尽力而为的实时预览:stdout 与 stderr 按轮询轮次复制,同一个 `recordPollMs` 窗口内两条流的写入会先 stdout 后 stderr 出现,而不是按写入顺序。 +传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。后台 job 把运行的非消费 `observed` 读取器交给 job 注册表作为拉式源;注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)把它们泵入 job 的输出环,Web 客户端由此流式看到实时输出,模型的 `job_output` 读取则经另一个游标消费同一份字节。读取器抛错只记录一次,该流就此停止,job 继续跑到自己的结算。输出环是尽力而为的实时预览:stdout 与 stderr 按轮询轮次复制,同一个轮询窗口内两条流的写入会先 stdout 后 stderr 出现,而不是按写入顺序。 ### 沙箱执行与升权 diff --git a/packages/shell/tool-bash/src/background.ts b/packages/shell/tool-bash/src/background.ts index c01f91a586..dbe6d08c5e 100644 --- a/packages/shell/tool-bash/src/background.ts +++ b/packages/shell/tool-bash/src/background.ts @@ -1,27 +1,74 @@ /** - * Generic-task adaptation for background bash process handles. + * Generic-job adaptation for background bash process handles: the terminal + * outcome the registry records and the pull sources it pumps. * * @module @deepseek-ai/dsh-tool-bash/background */ -import type { ShellProcess } from '@deepseek-ai/dsh-shell' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' +import type { ShellProcess, ShellSandboxInfo } from '@deepseek-ai/dsh-shell' +import type { JobOutcome, JobOutputSource } from '@deepseek-ai/dsh-jobs' /** - * Map a settled background process onto the generic task-outcome vocabulary: + * Sandbox facts worth the terminal detail: a runner that never ran the + * command, or a denial (with the escalation hint this composition offers). + * @param sandbox - settled sandbox facts, when this was a confined process. + * @param escalationModes - escalation targets advertised by this composition. + * @returns the markers to append, oldest first. + */ +function sandboxNotes(sandbox: ShellSandboxInfo | undefined, escalationModes: readonly SandboxMode[]): string[] { + if (sandbox?.runnerFailed) { + return [`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`] + } + if (sandbox?.denied) { + const notes = [sandboxDenialMarker(sandbox.mode)] + if (escalationModes.length > 0) notes.push(escalationHintMarker('command')) + return notes + } + return [] +} + +/** + * Map a settled background process onto the generic job-outcome vocabulary: * `killed` stays `killed` (detail: the signal when one is known), everything * else is `completed` with the exit code as detail. A nonzero command exit is - * reported, not failed, exactly like the foreground rendering. + * reported, not failed, exactly like the foreground rendering. Sandbox facts + * join the detail, since a job's terminal reason is the one line every + * reader — the model's status line, the roster row — shows. * @param proc - the settled process handle. + * @param escalationModes - escalation targets advertised by this composition. * @returns the outcome for the `ctx.jobs` registration. */ -export function processOutcome(proc: ShellProcess): { status: 'completed' | 'killed'; detail: string } { +export function processOutcome(proc: ShellProcess, escalationModes: readonly SandboxMode[] = []): JobOutcome { // TODO(background-infrastructure-outcome): widen ShellProcess with an explicit - // infrastructure-failure outcome, then map it to task `failed`. Restricted + // infrastructure-failure outcome, then map it to job `failed`. Restricted // runner failures expose sandbox.runnerFailed, but unconfined spawn failures // still alias a signal-less kill; real nonzero command exits must remain // `completed`. - if (proc.status === 'killed') { - return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } - } - return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } + const base: JobOutcome = proc.status === 'killed' + ? { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } + : { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } + const notes = sandboxNotes(proc.sandbox, escalationModes) + return notes.length === 0 ? base : { ...base, detail: `${base.detail}; ${notes.join(' ')}` } +} + +/** + * The process's non-consuming stream readers as registry pull sources. They + * bind lazily because the process is spawned inside the starter, after the + * registry admitted the job; a backend without offset readers degrades to no + * observation rather than an error, and the pump keeps the model's consuming + * cursor untouched. + * @param proc - the started process, once the starter has spawned it. + * @returns one source per stream, stdout first. + */ +export function processSources(proc: () => ShellProcess | undefined): JobOutputSource[] { + const source = (channel: 'stdout' | 'stderr'): JobOutputSource => ({ + channel, + read: (fromByte) => { + const reader = proc()?.observed?.[channel] + return reader === undefined ? { text: '', nextOffset: fromByte, lossy: false } : reader.readFrom(fromByte) + }, + }) + return [source('stdout'), source('stderr')] } diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts index c4586d0b10..8abc59a2c7 100644 --- a/packages/shell/tool-bash/src/index.ts +++ b/packages/shell/tool-bash/src/index.ts @@ -22,10 +22,9 @@ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandb import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' -import type { ShellRunResult } from '@deepseek-ai/dsh-shell' -import { processOutcome } from './background.ts' -import { observeProcessRecord } from './observe.ts' -import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' +import type { ShellRunResult, ShellProcess } from '@deepseek-ai/dsh-shell' +import { processOutcome, processSources } from './background.ts' +import { parseExitStatus, renderResult } from './render.ts' export const name = 'tool-bash' export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] @@ -34,18 +33,11 @@ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ - recordPollMs?: number } /** Runtime configuration schema for the bash tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), - recordPollMs: z.number() - .step(1) - .min(1) - .max(Number.MAX_SAFE_INTEGER) - .default(150), }) /** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ @@ -196,7 +188,6 @@ const BACKGROUND_OUTPUT_PROPERTIES = { export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true - const recordPollMs = config.recordPollMs ?? 150 const defaultMode = ctx.shell.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') @@ -369,20 +360,21 @@ export function apply(ctx: Context, config: Config = {}): void { error.name = 'AbortError' throw error } + // The registry pumps the process's non-consuming readers into the + // job's ring; the sources bind lazily because the process is spawned + // only once admission has passed. + let proc: ShellProcess | undefined const id = jobs.start({ kind: 'bash', label: args.command, - ...exec.agent ? { owner: exec.agent } : {}, - record: true, - run: (job) => { - const proc = ctx.shell.start(ctx.shell.resolve(request)) - // The pump's final drain is folded into `done` so the record holds - // its last bytes before settlement trims and closes it. - const observed = observeProcessRecord(ctx, job, proc, recordPollMs) + ...exec.agent ? { owner: exec.agent.id } : {}, + output: processSources(() => proc), + run: () => { + const started = ctx.shell.start(ctx.shell.resolve(request)) + proc = started return { - cancel: () => void proc.kill(), - done: observed.then(() => proc.done).then(() => processOutcome(proc)), - readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox, escalationModes), + cancel: () => void started.kill(), + done: started.done.then(() => processOutcome(started, escalationModes)), } }, }) diff --git a/packages/shell/tool-bash/src/observe.ts b/packages/shell/tool-bash/src/observe.ts deleted file mode 100644 index 89a28817bc..0000000000 --- a/packages/shell/tool-bash/src/observe.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Record tap for background bash runs: copies the spawned process's captured - * streams into the job's observation record, so non-consuming observers (the - * Web client) see live output without touching the job's consuming - * `readOutput` cursor. Observation is strictly best-effort: absent backend - * offset readers degrade to no observation, and a pump failure is logged and - * swallowed — the job path is unaffected either way. - * The record interleaves stdout and stderr per poll round, not per write: - * output the two streams produced inside one `pollMs` window lands stdout - * first, so the panel is a live preview, not a terminal-exact transcript. - * - * @module @deepseek-ai/dsh-tool-bash/observe - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { ShellProcess } from '@deepseek-ai/dsh-shell' -import { pumpJobOutput } from '@deepseek-ai/dsh-jobs' -import type { JobPumpSource, RecordingJob } from '@deepseek-ai/dsh-jobs' - -/** - * Pump the process's non-consuming offset readers into the job record until - * the run settles, then drain once more. The returned promise never rejects: - * a pump failure is logged and the record simply stops advancing, while the - * process and its job continue untouched. - * @param ctx - plugin context used for the warning log. - * @param job - the running job's producer face receiving the copied chunks. - * @param proc - the started background process handle. - * @param pollMs - producer-configured pump cadence in milliseconds. - * @returns resolves after the final drain; fold into the job's `done` chain so - * the record holds its final bytes before settlement closes it. - */ -export function observeProcessRecord( - ctx: Context, - job: RecordingJob, - proc: ShellProcess, - pollMs: number, -): Promise { - const sources: JobPumpSource[] = [] - const stdout = proc.observed?.stdout - if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) - const stderr = proc.observed?.stderr - if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) - return pumpJobOutput(job, sources, { pollMs, done: proc.done }) - .catch((error: unknown) => { - ctx.logger.warn(`record observation pump for ${job.id} failed: ${String(error)}`) - }) -} diff --git a/packages/shell/tool-bash/src/render.ts b/packages/shell/tool-bash/src/render.ts index 82403cca44..a13b8348f1 100644 --- a/packages/shell/tool-bash/src/render.ts +++ b/packages/shell/tool-bash/src/render.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-tool-bash/render */ -import type { ShellProcessRead, ShellRunResult, ShellSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-shell' +import type { ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' @@ -62,38 +62,6 @@ export function renderResult( return body + markers.join('\n') } -/** - * Shape one background-process read into the `job_output` delta the model - * sees: the incremental delta, plus the lossy-read notice (with full-stream - * spill paths) when in-memory truncation dropped unread bytes. Empty-delta - * rendering (`(no new output)`) is the generic job controller's job. - * @param read - one incremental read from the process handle. - * @param sandbox - settled sandbox facts, when this was a confined process. - * @param escalationModes - escalation targets advertised by this composition. - * @returns the delta text with any loss or sandbox notice appended. - */ -export function renderProcessRead( - read: ShellProcessRead, - sandbox?: ShellSandboxInfo, - escalationModes: readonly SandboxMode[] = [], -): string { - const notices: string[] = [] - if (read.lossy) { - const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined) - notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`) - } - if (sandbox?.runnerFailed) { - notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`) - } else if (sandbox?.denied) { - notices.push(sandboxDenialMarker(sandbox.mode)) - if (escalationModes.length > 0) { - notices.push(escalationHintMarker('command')) - } - } - if (notices.length === 0) return read.delta - return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` -} - /** * The exit-status parse is the shared marker-contract half of the shell-tool * rendering story, owned by `@deepseek-ai/dsh-shell` so `dsh-tool-pwsh` reuses diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/background.spec.ts similarity index 51% rename from packages/shell/tool-bash/tests/observe.spec.ts rename to packages/shell/tool-bash/tests/background.spec.ts index 4586ada7a5..5862887118 100644 --- a/packages/shell/tool-bash/tests/observe.spec.ts +++ b/packages/shell/tool-bash/tests/background.spec.ts @@ -1,7 +1,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' @@ -10,31 +10,31 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' -import type { JobId, RecordingJob } from '@deepseek-ai/dsh-jobs' +import type { JobId } from '@deepseek-ai/dsh-jobs' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import type { ShellProcess } from '@deepseek-ai/dsh-shell' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' -import { observeProcessRecord } from '../src/observe.ts' +import { processSources } from '../src/background.ts' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' const testToolSignal = new AbortController().signal -const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-observe-spec-')) +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-background-spec-')) -/** Job harness with a fast record pump for tests. */ +/** Job harness with a fast registry pump for tests. */ async function setup() { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) + await ctx.plugin(LocalJobRegistry, { pumpPollMs: 25 }) await ctx.plugin(ToolTasks) await ctx.plugin(LocalSubprocessRuntime) ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - await ctx.plugin(ToolBash, { recordPollMs: 25 }) + await ctx.plugin(ToolBash) return ctx } @@ -42,7 +42,7 @@ let callCounter = 0 function call(ctx: Context, args: Record) { return ctx.tools.execute({ signal: testToolSignal, - callId: ToolCallId(`observe-call-${++callCounter}`), + callId: ToolCallId(`background-call-${++callCounter}`), name: 'bash', arguments: args, }) @@ -63,11 +63,11 @@ async function until(read: () => T | undefined, timeoutMs = 5_000): Promise chunk.text).join('') + return ctx.jobs.visibleTo(caller?.id).readAt(id, 0).chunks.map(chunk => chunk.text).join('') } -describe('background bash record', () => { - it('streams a background run into the job record with live output and settlement', async () => { +describe('background bash output', () => { + it('streams a background run into the job ring with live output and settlement', async () => { const ctx = await setup() const ack = await call(ctx, { command: 'printf "line-1\\n"; sleep 0.4; printf "line-2\\n"', @@ -75,34 +75,33 @@ describe('background bash record', () => { run_in_background: true, }) expect(text(ack)).toContain('started background job') - const job = ctx.jobs.list()[0] + const jobs = ctx.jobs.visibleTo() + const job = jobs.list()[0] expect(job).toBeDefined() - expect(job!.outputTotal).toBeDefined() - // Live output appears in the record while the command is still running. + // Live output appears in the ring while the command is still running. await until(() => retainedText(ctx, job!.id).includes('line-1') ? true : undefined) - expect(ctx.jobs.get(job!.id).status).toBe('running') + expect(jobs.get(job!.id).status).toBe('running') - // Settlement ends the record with the job; the trailing bytes are drained first. - await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) - expect(ctx.jobs.get(job!.id).detail).toBe('exit code: 0') + // Settlement ends the ring with the job; the trailing bytes are drained first. + await until(() => jobs.get(job!.id).status === 'completed' ? true : undefined) + expect(jobs.get(job!.id).detail).toBe('exit code: 0') expect(retainedText(ctx, job!.id)).toContain('line-2') - // The model-facing consuming cursor still delivers everything: observation stole nothing. - const collected = await until(() => { - const read = ctx.jobs.read(job!.id) - return read.text.includes('line-1') ? read.text : undefined - }) - expect(collected).toContain('line-2') + // The model-facing consuming cursor reads the same bytes: observation stole nothing. + const consumed = jobs.read(job!.id).chunks.map(chunk => chunk.text).join('') + expect(consumed).toContain('line-1') + expect(consumed).toContain('line-2') }) - it('a killed background job ends its record with the killed settlement', async () => { + it('a killed background job settles killed with the kill reason merged into its detail', async () => { const ctx = await setup() await call(ctx, { command: 'sleep 60', description: 'test command', run_in_background: true }) - const job = ctx.jobs.list()[0] - ctx.jobs.kill(job!.id, undefined, 'test cleanup') - await until(() => ctx.jobs.get(job!.id).status === 'killed' ? true : undefined) - expect(ctx.jobs.get(job!.id).detail).toMatch(/signal|killed before exit/) + const jobs = ctx.jobs.visibleTo() + const job = jobs.list()[0] + jobs.kill(job!.id, { reason: 'test cleanup' }) + await until(() => jobs.get(job!.id).status === 'killed' ? true : undefined) + expect(jobs.get(job!.id).detail).toMatch(/(signal|killed before exit).*; test cleanup$/) }) it('labels stderr chunks with their channel', async () => { @@ -112,73 +111,63 @@ describe('background bash record', () => { description: 'test command', run_in_background: true, }) - const job = ctx.jobs.list()[0] - await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) - const chunks = ctx.jobs.readRecord(job!.id, 0).chunks + const jobs = ctx.jobs.visibleTo() + const job = jobs.list()[0] + await until(() => jobs.get(job!.id).status === 'completed' ? true : undefined) + const chunks = jobs.readAt(job!.id, 0).chunks expect(chunks.find(chunk => chunk.text.includes('out-line'))?.channel).toBe('stdout') expect(chunks.find(chunk => chunk.text.includes('err-line'))?.channel).toBe('stderr') }) }) -describe('observeProcessRecord', () => { - /** A RecordingJob face recording appends for pump assertions. */ - function recordingJob(): { job: RecordingJob; appends: string[] } { - const appends: string[] = [] - return { - job: { - id: 'bash-1' as JobId, - append(chunk) { appends.push(chunk) }, - updateDetail() {}, - }, - appends, - } - } - - it('degrades to no observation when the backend exposes no offset readers', async () => { - const ctx = new Context() - const { job, appends } = recordingJob() - const proc = { done: Promise.resolve() } as unknown as ShellProcess - await observeProcessRecord(ctx, job, proc, 25) - expect(appends).toEqual([]) +describe('processSources', () => { + it('reads nothing while the process is not spawned yet and when the backend exposes no readers', () => { + const spawned: { proc?: ShellProcess } = {} + const [stdout, stderr] = processSources(() => spawned.proc) + expect(stdout!.channel).toBe('stdout') + expect(stderr!.channel).toBe('stderr') + expect(stdout!.read(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) + spawned.proc = { done: Promise.resolve() } as unknown as ShellProcess + expect(stderr!.read(3)).toEqual({ text: '', nextOffset: 3, lossy: false }) }) - it('contains a pump failure with a warning; the job path is unaffected', async () => { - const ctx = new Context() - const warn = vi.fn() - ctx.logger.warn = warn as never - const { job } = recordingJob() + it('forwards each read to the matching stream reader once the process exists', () => { + const reads: number[] = [] const proc = { - done: new Promise((resolve) => { setTimeout(resolve, 10) }), observed: { - stdout: { readFrom() { throw new Error('reader boom') } }, + stdout: { readFrom: (from: number) => { reads.push(from); return { text: 'out', nextOffset: from + 3, lossy: false } } }, }, } as unknown as ShellProcess - await observeProcessRecord(ctx, job, proc, 5) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('record observation pump for bash-1 failed')) + const [stdout, stderr] = processSources(() => proc) + expect(stdout!.read(2)).toEqual({ text: 'out', nextOffset: 5, lossy: false }) + expect(reads).toEqual([2]) + // A missing stderr reader is an absent stream, not an error. + expect(stderr!.read(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) }) }) -describe('owned background record', () => { - it('fences an owned background run record under the owning session', async () => { +describe('owned background output', () => { + it('fences an owned background run under the owning session', async () => { const ctx = await setup() const owner = { - id: SessionId('observe-owner'), - session: { id: SessionId('observe-owner'), header: { cwd: process.cwd() } }, + id: SessionId('background-owner'), + session: { id: SessionId('background-owner'), header: { cwd: process.cwd() } }, status: 'idle', ctx, } as unknown as Agent ctx.agents.register(owner) await ctx.tools.execute({ signal: testToolSignal, - callId: ToolCallId('observe-owned-1'), + callId: ToolCallId('background-owned-1'), name: 'bash', arguments: { command: 'echo owned', description: 'test command', run_in_background: true }, agent: owner, }) - const job = ctx.jobs.list(owner)[0] + const owned = ctx.jobs.visibleTo(owner.id) + const job = owned.list()[0] expect(job).toBeDefined() - expect(job!.ownerSession).toBe(owner.id) - await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined) + expect(job!.owner).toBe(owner.id) + await until(() => owned.get(job!.id).status === 'completed' ? true : undefined) expect(() => retainedText(ctx, job!.id)).toThrow(/belongs to another session/) await until(() => retainedText(ctx, job!.id, owner).includes('owned') ? true : undefined) }) diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 747d98b48d..8a4d7a41b4 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { ShellExecutor } from '@deepseek-ai/dsh-shell' -import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellProcessRead, ShellRunResult } from '@deepseek-ai/dsh-shell' +import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -22,8 +22,9 @@ import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' +import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' import { processOutcome } from '../src/background.ts' -import { renderProcessRead, renderResult } from '../src/render.ts' +import { renderResult } from '../src/render.ts' const testToolSignal = new AbortController().signal @@ -731,53 +732,6 @@ describe('sandbox escalation through the generic task producer', () => { }) }) -describe('renderProcessRead', () => { - const base: ShellProcessRead = { delta: 'out\n', lossy: false } - - it('returns the delta verbatim for a lossless read', () => { - expect(renderProcessRead(base)).toBe('out\n') - expect(renderProcessRead({ delta: '', lossy: false })).toBe('') - }) - - it('appends the loss notice with the available spill paths', () => { - expect(renderProcessRead({ ...base, lossy: true, stdoutSpillPath: '/spill/out.log' })) - .toBe('out\n[some output was dropped from memory; full output: /spill/out.log]') - expect(renderProcessRead({ ...base, lossy: true, stdoutSpillPath: '/spill/out.log', stderrSpillPath: '/spill/err.log' })) - .toBe('out\n[some output was dropped from memory; full output: /spill/out.log, /spill/err.log]') - }) - - it('reports (unavailable) when a lossy read has no safe spill path', () => { - expect(renderProcessRead({ ...base, lossy: true })) - .toBe('out\n[some output was dropped from memory; full output: (unavailable)]') - }) - - it('an empty lossy delta is the notice alone', () => { - expect(renderProcessRead({ delta: '', lossy: true, stderrSpillPath: '/spill/err.log' })) - .toBe('[some output was dropped from memory; full output: /spill/err.log]') - }) - - it('inserts the separating newline only when the delta lacks one', () => { - expect(renderProcessRead({ delta: 'tail', lossy: true })) - .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') - expect(renderProcessRead({ delta: 'tail\n', lossy: true })) - .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') - }) - - it('appends settled sandbox denial and runner-failure facts', () => { - expect(renderProcessRead(base, { mode: 'read-only', denied: true }, ['workspace-write'])) - .toContain('[sandbox: escalation available') - expect(renderProcessRead({ delta: 'tail', lossy: false }, { mode: 'read-only', denied: true })) - .toBe('tail\n[sandbox: file access denied under read-only mode]') - const runner = renderProcessRead( - { delta: '', lossy: false }, - { mode: 'workspace-write', denied: true, runnerFailed: true }, - ['danger-full-access'], - ) - expect(runner).toContain('sandbox runner itself failed under workspace-write mode') - expect(runner).not.toContain('file access denied') - }) -}) - describe('processOutcome', () => { function settled(over: Partial): ShellProcess { return { @@ -810,6 +764,16 @@ describe('processOutcome', () => { expect(processOutcome(settled({ exitCode: null }))) .toEqual({ status: 'completed', detail: 'exit code: 0' }) }) + + it('appends sandbox facts to the terminal detail', () => { + const denied = processOutcome(settled({ exitCode: 1, sandbox: { mode: 'read-only', denied: true } }), ['workspace-write']) + expect(denied.detail).toBe(`exit code: 1; ${sandboxDenialMarker('read-only')} ${escalationHintMarker('command')}`) + const deniedWithoutEscalation = processOutcome(settled({ exitCode: 1, sandbox: { mode: 'read-only', denied: true } })) + expect(deniedWithoutEscalation.detail).toBe(`exit code: 1; ${sandboxDenialMarker('read-only')}`) + const runnerFailed = processOutcome(settled({ exitCode: 1, sandbox: { mode: 'read-only', denied: false, runnerFailed: true } })) + expect(runnerFailed.detail).toContain('the sandbox runner itself failed under read-only mode') + expect(processOutcome(settled({ sandbox: { mode: 'read-only', denied: false } })).detail).toBe('exit code: 0') + }) }) describe('session-cwd routing (per-session workdir)', () => { diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index fbf8f4b9ae..ff3bcb8f9b 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/README.i18n.yaml @@ -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/shell/tool-pwsh/README.md -README.md: 6ce58c103e0e11c5011632e6c4a7024deeaa79cd -README.zh.md: b583cf3f9198ed9dd01a239405a6ada1094ecb9f +README.md: ead65fb453dfbcf36307ebda00d79e2e08c58744 +README.zh.md: 8b92fffe3797a096c6c292c3f277812a31020048 diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index 6ce58c103e..ead65fb453 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-tool-pwsh` gives the agent a `pwsh` tool that runs PowerShell commands through the mounted shell executor — the Windows counterpart of `dsh-tool-bash`, mirroring it call-for-call. Each call runs in a fresh pwsh process, so no state survives; `run_in_background` turns long-running commands into background jobs. Commands are PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables, with no dialect translation. Every call runs with the managed `DSH_*` environment, and under a sandboxing executor the tool teaches and enforces the Windows-specific language-mode and named-pipe contracts. Mount it with a PowerShell executor such as `dsh-pwsh-local` and the `dsh-shell-env` plugin. A background run's job declares an observation record pumped at `recordPollMs` (default 150), exactly like `dsh-tool-bash`. +`dsh-tool-pwsh` gives the agent a `pwsh` tool that runs PowerShell commands through the mounted shell executor — the Windows counterpart of `dsh-tool-bash`, mirroring it call-for-call. Each call runs in a fresh pwsh process, so no state survives; `run_in_background` turns long-running commands into background jobs. Commands are PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables, with no dialect translation. Every call runs with the managed `DSH_*` environment, and under a sandboxing executor the tool teaches and enforces the Windows-specific language-mode and named-pipe contracts. Mount it with a PowerShell executor such as `dsh-pwsh-local` and the `dsh-shell-env` plugin. A background run hands the process's offset readers to the job registry as pull sources, pumped at `pumpPollMs` on `dsh-jobs-local`, exactly like `dsh-tool-bash`. ## Table of Contents diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index b583cf3f91..8b92fffe37 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-tool-pwsh` 为 agent 提供 `pwsh` 工具,通过已挂载的 shell 执行器运行 PowerShell 命令——它是 `dsh-tool-bash` 的 Windows 对应物,逐调用镜像。每次调用都运行在全新 pwsh 进程中,因此状态不会保留;`run_in_background` 把长时间运行的命令变成后台任务。命令是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量,不做方言翻译。每次调用都运行在受管 `DSH_*` 环境中;在沙箱执行器下,工具会教授并执行 Windows 特有的语言模式与命名管道约定。请与 `dsh-pwsh-local` 等 PowerShell 执行器以及 `dsh-shell-env` 插件一起挂载。后台运行的 job 声明按 `recordPollMs`(默认 150)泵送的观测 record,与 `dsh-tool-bash` 完全一致。 +`dsh-tool-pwsh` 为 agent 提供 `pwsh` 工具,通过已挂载的 shell 执行器运行 PowerShell 命令——它是 `dsh-tool-bash` 的 Windows 对应物,逐调用镜像。每次调用都运行在全新 pwsh 进程中,因此状态不会保留;`run_in_background` 把长时间运行的命令变成后台任务。命令是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量,不做方言翻译。每次调用都运行在受管 `DSH_*` 环境中;在沙箱执行器下,工具会教授并执行 Windows 特有的语言模式与命名管道约定。请与 `dsh-pwsh-local` 等 PowerShell 执行器以及 `dsh-shell-env` 插件一起挂载。后台运行把进程的偏移读取器作为拉取源交给 job 注册表,由 `dsh-jobs-local` 按其 `pumpPollMs` 泵送,与 `dsh-tool-bash` 完全一致。 ## 目录 diff --git a/packages/shell/tool-pwsh/src/background.ts b/packages/shell/tool-pwsh/src/background.ts index dc0f53ffab..81066ecb0b 100644 --- a/packages/shell/tool-pwsh/src/background.ts +++ b/packages/shell/tool-pwsh/src/background.ts @@ -1,31 +1,78 @@ /** - * Generic-task adaptation for background pwsh process handles — the shell-agnostic - * twin of `dsh-tool-bash`'s background adaptation. + * Generic-job adaptation for background pwsh process handles — the + * shell-agnostic twin of `dsh-tool-bash`'s background adaptation: the terminal + * outcome the registry records and the pull sources it pumps. * * @module @deepseek-ai/dsh-tool-pwsh/background */ -import type { ShellProcess } from '@deepseek-ai/dsh-shell' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' +import type { ShellProcess, ShellSandboxInfo } from '@deepseek-ai/dsh-shell' +import type { JobOutcome, JobOutputSource } from '@deepseek-ai/dsh-jobs' /* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/background.ts (Agent Note). */ /** - * Map a settled background process onto the generic task-outcome vocabulary: + * Sandbox facts worth the terminal detail: a runner that never ran the + * command, or a denial (with the escalation hint this composition offers). + * @param sandbox - settled sandbox facts, when this was a confined process. + * @param escalationModes - escalation targets advertised by this composition. + * @returns the markers to append, oldest first. + */ +function sandboxNotes(sandbox: ShellSandboxInfo | undefined, escalationModes: readonly SandboxMode[]): string[] { + if (sandbox?.runnerFailed) { + return [`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`] + } + if (sandbox?.denied) { + const notes = [sandboxDenialMarker(sandbox.mode)] + if (escalationModes.length > 0) notes.push(escalationHintMarker('command')) + return notes + } + return [] +} + +/** + * Map a settled background process onto the generic job-outcome vocabulary: * `killed` stays `killed` (detail: the signal when one is known), everything * else is `completed` with the exit code as detail. A nonzero command exit is - * reported, not failed, exactly like the foreground rendering. + * reported, not failed, exactly like the foreground rendering. Sandbox facts + * join the detail, since a job's terminal reason is the one line every + * reader — the model's status line, the roster row — shows. * @param proc - the settled process handle. + * @param escalationModes - escalation targets advertised by this composition. * @returns the outcome for the `ctx.jobs` registration. */ -export function processOutcome(proc: ShellProcess): { status: 'completed' | 'killed'; detail: string } { +export function processOutcome(proc: ShellProcess, escalationModes: readonly SandboxMode[] = []): JobOutcome { // TODO(background-infrastructure-outcome): widen ShellProcess with an explicit // infrastructure-failure outcome, then map spawn failures and - // sandbox.runnerFailed to task `failed`. The current contract aliases a spawn + // sandbox.runnerFailed to job `failed`. The current contract aliases a spawn // failure with a signal-less kill and a runner failure with an ordinary // wrapper exit; real nonzero command exits must remain `completed`. - if (proc.status === 'killed') { - return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } - } - return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } + const base: JobOutcome = proc.status === 'killed' + ? { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } + : { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } + const notes = sandboxNotes(proc.sandbox, escalationModes) + return notes.length === 0 ? base : { ...base, detail: `${base.detail}; ${notes.join(' ')}` } +} + +/** + * The process's non-consuming stream readers as registry pull sources. They + * bind lazily because the process is spawned inside the starter, after the + * registry admitted the job; a backend without offset readers degrades to no + * observation rather than an error, and the pump keeps the model's consuming + * cursor untouched. + * @param proc - the started process, once the starter has spawned it. + * @returns one source per stream, stdout first. + */ +export function processSources(proc: () => ShellProcess | undefined): JobOutputSource[] { + const source = (channel: 'stdout' | 'stderr'): JobOutputSource => ({ + channel, + read: (fromByte) => { + const reader = proc()?.observed?.[channel] + return reader === undefined ? { text: '', nextOffset: fromByte, lossy: false } : reader.readFrom(fromByte) + }, + }) + return [source('stdout'), source('stderr')] } /* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts index a7926e1b34..b84f618a33 100644 --- a/packages/shell/tool-pwsh/src/index.ts +++ b/packages/shell/tool-pwsh/src/index.ts @@ -32,11 +32,10 @@ import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' -import type { ShellRunResult } from '@deepseek-ai/dsh-shell' +import type { ShellRunResult, ShellProcess } from '@deepseek-ai/dsh-shell' import { parseExitStatus } from '@deepseek-ai/dsh-shell' -import { processOutcome } from './background.ts' -import { observeProcessRecord } from './observe.ts' -import { renderPwshProcessRead, renderPwshResult } from './render.ts' +import { processOutcome, processSources } from './background.ts' +import { renderPwshResult } from './render.ts' import type { RenderablePwshResult } from './render.ts' declare module '@deepseek-ai/dsh-jobs' { @@ -53,18 +52,11 @@ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ - recordPollMs?: number } /** Runtime configuration schema for the pwsh tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), - recordPollMs: z.number() - .step(1) - .min(1) - .max(Number.MAX_SAFE_INTEGER) - .default(150), }) /* jscpd:ignore-end */ @@ -203,7 +195,6 @@ const BACKGROUND_OUTPUT_PROPERTIES = { /* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */ export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true - const recordPollMs = config.recordPollMs ?? 150 const defaultMode = ctx.shell.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') @@ -387,20 +378,21 @@ export function apply(ctx: Context, config: Config = {}): void { error.name = 'AbortError' throw error } + // The registry pumps the process's non-consuming readers into the + // job's ring; the sources bind lazily because the process is spawned + // only once admission has passed. + let proc: ShellProcess | undefined const id = jobs.start({ kind: 'pwsh', label: args.command, - ...exec.agent ? { owner: exec.agent } : {}, - record: true, - run: (job) => { - const proc = ctx.shell.start(ctx.shell.resolve(request)) - // The pump's final drain is folded into `done` so the record holds - // its last bytes before settlement trims and closes it. - const observed = observeProcessRecord(ctx, job, proc, recordPollMs) + ...exec.agent ? { owner: exec.agent.id } : {}, + output: processSources(() => proc), + run: () => { + const started = ctx.shell.start(ctx.shell.resolve(request)) + proc = started return { - cancel: () => void proc.kill(), - done: observed.then(() => proc.done).then(() => processOutcome(proc)), - readOutput: () => renderPwshProcessRead(proc.readOutput(), proc.sandbox, escalationModes), + cancel: () => void started.kill(), + done: started.done.then(() => processOutcome(started, escalationModes)), } }, }) diff --git a/packages/shell/tool-pwsh/src/observe.ts b/packages/shell/tool-pwsh/src/observe.ts deleted file mode 100644 index 357567e31e..0000000000 --- a/packages/shell/tool-pwsh/src/observe.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Record tap for background pwsh runs: copies the spawned process's captured - * streams into the job's observation record, so non-consuming observers (the - * Web client) see live output without touching the job's consuming - * `readOutput` cursor. Observation is strictly best-effort: absent backend - * offset readers degrade to no observation, and a pump failure is logged and - * swallowed — the job path is unaffected either way. - * The record interleaves stdout and stderr per poll round, not per write: - * output the two streams produced inside one `pollMs` window lands stdout - * first, so the panel is a live preview, not a terminal-exact transcript. - * - * @module @deepseek-ai/dsh-tool-pwsh/observe - */ - -/* jscpd:ignore-start -- mirrors tool-bash's observe tap by design, like render/background. */ -import type { Context } from '@deepseek-ai/cordis' -import type { ShellProcess } from '@deepseek-ai/dsh-shell' -import { pumpJobOutput } from '@deepseek-ai/dsh-jobs' -import type { JobPumpSource, RecordingJob } from '@deepseek-ai/dsh-jobs' - -/** - * Pump the process's non-consuming offset readers into the job record until - * the run settles, then drain once more. The returned promise never rejects: - * a pump failure is logged and the record simply stops advancing, while the - * process and its job continue untouched. - * @param ctx - plugin context used for the warning log. - * @param job - the running job's producer face receiving the copied chunks. - * @param proc - the started background process handle. - * @param pollMs - producer-configured pump cadence in milliseconds. - * @returns resolves after the final drain; fold into the job's `done` chain so - * the record holds its final bytes before settlement closes it. - */ -export function observeProcessRecord( - ctx: Context, - job: RecordingJob, - proc: ShellProcess, - pollMs: number, -): Promise { - const sources: JobPumpSource[] = [] - const stdout = proc.observed?.stdout - if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) - const stderr = proc.observed?.stderr - if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) - return pumpJobOutput(job, sources, { pollMs, done: proc.done }) - .catch((error: unknown) => { - ctx.logger.warn(`record observation pump for ${job.id} failed: ${String(error)}`) - }) -} -/* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh/src/render.ts b/packages/shell/tool-pwsh/src/render.ts index cc7b8a661d..01e0a51e2e 100644 --- a/packages/shell/tool-pwsh/src/render.ts +++ b/packages/shell/tool-pwsh/src/render.ts @@ -10,7 +10,7 @@ * @module @deepseek-ai/dsh-tool-pwsh/render */ -import type { ShellProcessRead, ShellSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-shell' +import type { ShellSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-shell' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' @@ -80,34 +80,4 @@ export function renderPwshResult( return body + markers.join('\n') } -/** - * Shape one background-process read into the `job_output` delta the model - * sees: the incremental delta, plus the lossy-read notice (with full-stream - * spill paths) when in-memory truncation dropped unread bytes. - * @param read - one incremental read from the process handle. - * @param sandbox - settled sandbox facts, when this was a confined process. - * @param escalationModes - escalation targets advertised by this composition. - * @returns the delta text with any loss or sandbox notice appended. - */ -export function renderPwshProcessRead( - read: ShellProcessRead, - sandbox?: ShellSandboxInfo, - escalationModes: readonly SandboxMode[] = [], -): string { - const notices: string[] = [] - if (read.lossy) { - const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined) - notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`) - } - if (sandbox?.runnerFailed) { - notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`) - } else if (sandbox?.denied) { - notices.push(sandboxDenialMarker(sandbox.mode)) - if (escalationModes.length > 0) { - notices.push(escalationHintMarker('command')) - } - } - if (notices.length === 0) return read.delta - return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` -} /* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh/tests/observe.spec.ts b/packages/shell/tool-pwsh/tests/background.spec.ts similarity index 73% rename from packages/shell/tool-pwsh/tests/observe.spec.ts rename to packages/shell/tool-pwsh/tests/background.spec.ts index 0fbdcb80de..6c12ff8611 100644 --- a/packages/shell/tool-pwsh/tests/observe.spec.ts +++ b/packages/shell/tool-pwsh/tests/background.spec.ts @@ -78,11 +78,11 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) + await ctx.plugin(LocalJobRegistry, { pumpPollMs: 5 }) await ctx.plugin(ToolTasks) await ctx.plugin(BashEnvPlugin) await ctx.plugin(FakePwsh) - await ctx.plugin(ToolPwsh, { recordPollMs: 5 }) + await ctx.plugin(ToolPwsh) return { ctx, pwsh: ctx.shell as FakePwsh } } @@ -90,7 +90,7 @@ let callCounter = 0 function call(ctx: Context, args: Record) { return ctx.tools.execute({ signal: testToolSignal, - callId: ToolCallId(`pwsh-observe-${++callCounter}`), + callId: ToolCallId(`pwsh-background-${++callCounter}`), name: 'pwsh', arguments: args, }) @@ -106,8 +106,8 @@ async function until(read: () => T | undefined, timeoutMs = 5_000): Promise { - it('streams observed channels into the job record and settles with the mapped outcome', async () => { +describe('background pwsh output', () => { + it('streams observed channels into the job ring and settles with the mapped outcome', async () => { const { ctx, pwsh } = await setup() const stdout = { text: '' } const stderr = { text: '' } @@ -115,30 +115,31 @@ describe('background pwsh record', () => { pwsh.backgroundHandler = () => scripted.proc await call(ctx, { command: 'Get-Progress', description: 'test command', run_in_background: true }) - const job = ctx.jobs.list()[0] + const jobs = ctx.jobs.visibleTo() + const job = jobs.list()[0] expect(job).toBeDefined() expect(job!.kind).toBe('pwsh') - expect(job!.outputTotal).toBe(0) + expect(job!.output).toEqual({ total: 0, earliest: 0 }) stdout.text = 'progress-line\n' stderr.text = 'warn-line\n' await until(() => { - const chunks = ctx.jobs.readRecord(job!.id, 0).chunks + const chunks = jobs.readAt(job!.id, 0).chunks return chunks.some(chunk => chunk.text.includes('progress-line')) && chunks.some(chunk => chunk.text.includes('warn-line')) ? true : undefined }) - const chunks = ctx.jobs.readRecord(job!.id, 0).chunks + const chunks = jobs.readAt(job!.id, 0).chunks expect(chunks.find(chunk => chunk.text.includes('progress-line'))?.channel).toBe('stdout') expect(chunks.find(chunk => chunk.text.includes('warn-line'))?.channel).toBe('stderr') scripted.finish() - await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) - expect(ctx.jobs.get(job!.id).detail).toBe('exit code: 0') + await until(() => jobs.get(job!.id).status === 'completed' ? true : undefined) + expect(jobs.get(job!.id).detail).toBe('exit code: 0') }) - it('a pump failure warns and never fakes a terminal state onto the running job', async () => { + it('a failing reader warns once and never fakes a terminal state onto the running job', async () => { const { ctx, pwsh } = await setup() const warn = vi.fn() ctx.logger.warn = warn as never @@ -158,40 +159,43 @@ describe('background pwsh record', () => { } pwsh.backgroundHandler = () => proc await call(ctx, { command: 'Get-Broken', description: 'test command', run_in_background: true }) - const job = ctx.jobs.list()[0] - await until(() => warn.mock.calls.some(args => String(args[0]).includes('record observation pump')) ? true : undefined) - // The pump already failed; the job must wait for real settlement rather + const jobs = ctx.jobs.visibleTo() + const job = jobs.list()[0] + await until(() => warn.mock.calls.some(args => String(args[0]).includes('output source for')) ? true : undefined) + // The reader already failed; the job must wait for real settlement rather // than freezing a fake terminal state onto the running process. await new Promise(resolve => setTimeout(resolve, 50)) - expect(ctx.jobs.get(job!.id).status).toBe('running') + expect(jobs.get(job!.id).status).toBe('running') + expect(warn).toHaveBeenCalledTimes(1) proc.status = 'completed' proc.exitCode = 0 resolveDone() - await until(() => ctx.jobs.get(job!.id).status !== 'running' ? true : undefined) - expect(ctx.jobs.get(job!.id)).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) + await until(() => jobs.get(job!.id).status !== 'running' ? true : undefined) + expect(jobs.get(job!.id)).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) }) - it('a backend without observed readers still yields a status-only record', async () => { + it('a backend without observed readers still settles with an empty ring', async () => { const { ctx, pwsh } = await setup() const scripted = observableProcess() pwsh.backgroundHandler = () => scripted.proc await call(ctx, { command: 'Start-Job', description: 'test command', run_in_background: true }) - const job = ctx.jobs.list()[0] - expect(ctx.jobs.readRecord(job!.id, 0).chunks).toEqual([]) + const jobs = ctx.jobs.visibleTo() + const job = jobs.list()[0] + expect(jobs.readAt(job!.id, 0).chunks).toEqual([]) scripted.finish() - await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) - expect(ctx.jobs.get(job!.id).outputTotal).toBe(0) + await until(() => jobs.get(job!.id).status === 'completed' ? true : undefined) + expect(jobs.get(job!.id).output).toEqual({ total: 0, earliest: 0 }) }) }) -describe('owned background record (pwsh)', () => { - it('keeps an owned background run record under the owning session', async () => { +describe('owned background output (pwsh)', () => { + it('keeps an owned background run under the owning session', async () => { const { ctx, pwsh } = await setup() const owner = { - id: SessionId('pwsh-observe-owner'), - session: { id: SessionId('pwsh-observe-owner'), header: { cwd: process.cwd() } }, + id: SessionId('pwsh-background-owner'), + session: { id: SessionId('pwsh-background-owner'), header: { cwd: process.cwd() } }, status: 'idle', ctx, } as unknown as Agent @@ -200,17 +204,18 @@ describe('owned background record (pwsh)', () => { pwsh.backgroundHandler = () => scripted.proc await ctx.tools.execute({ signal: testToolSignal, - callId: ToolCallId('pwsh-observe-owned'), + callId: ToolCallId('pwsh-background-owned'), name: 'pwsh', arguments: { command: 'Get-Slow', description: 'test command', run_in_background: true }, agent: owner, }) - const job = ctx.jobs.list(owner)[0] + const owned = ctx.jobs.visibleTo(owner.id) + const job = owned.list()[0] expect(job).toBeDefined() - expect(job!.ownerSession).toBe(owner.id) - expect(() => ctx.jobs.readRecord(job!.id, 0)).toThrow(/belongs to another session/) - expect(ctx.jobs.readRecord(job!.id, 0, owner).chunks).toEqual([]) + expect(job!.owner).toBe(owner.id) + expect(() => ctx.jobs.visibleTo().readAt(job!.id, 0)).toThrow(/belongs to another session/) + expect(owned.readAt(job!.id, 0).chunks).toEqual([]) scripted.finish() - await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined) + await until(() => owned.get(job!.id).status === 'completed' ? true : undefined) }) }) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 5e27e3c8e0..0995697129 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -32,9 +32,9 @@ import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' -import type { ShellProcessRead } from '@deepseek-ai/dsh-shell' +import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' import { processOutcome } from '../src/background.ts' -import { renderPwshProcessRead, renderPwshResult } from '../src/render.ts' +import { renderPwshResult } from '../src/render.ts' const testToolSignal = new AbortController().signal @@ -91,20 +91,19 @@ function runResult(stdout: string, overrides?: Partial): ShellRu } } -/** A settled successful background handle; overrides script failure shapes. */ +/** A settled successful background handle whose stdout the registry pump reads by offset (ASCII: offsets are string indexes). */ function fakeProcess(delta = 'bg-ok\n'): ShellProcess { - let consumed = false return { status: 'completed', exitCode: 0, signal: null, done: Promise.resolve(), - readOutput: () => { - if (consumed) return { delta: '', lossy: false } - consumed = true - return { delta, lossy: false } - }, + readOutput: () => ({ delta: '', lossy: false }), kill: () => false, + observed: { + stdout: { readFrom: (fromByte: number) => ({ text: delta.slice(fromByte), nextOffset: delta.length, lossy: false }) }, + stderr: { readFrom: (fromByte: number) => ({ text: '', nextOffset: fromByte, lossy: false }) }, + }, } } @@ -1005,58 +1004,6 @@ describe('renderPwshResult sandbox markers', () => { }) }) -describe('renderPwshProcessRead', () => { - const base: ShellProcessRead = { delta: 'out\n', lossy: false } - - it('returns the delta verbatim for a lossless read', () => { - expect(renderPwshProcessRead(base)).toBe('out\n') - expect(renderPwshProcessRead({ delta: '', lossy: false })).toBe('') - }) - - it('appends the loss notice with the available spill paths', () => { - expect(renderPwshProcessRead({ ...base, lossy: true, stdoutSpillPath: 'C:\\spill\\out.log' })) - .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log]') - expect(renderPwshProcessRead({ - ...base, - lossy: true, - stdoutSpillPath: 'C:\\spill\\out.log', - stderrSpillPath: 'C:\\spill\\err.log', - })) - .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log, C:\\spill\\err.log]') - }) - - it('reports (unavailable) when a lossy read has no safe spill path', () => { - expect(renderPwshProcessRead({ ...base, lossy: true })) - .toBe('out\n[some output was dropped from memory; full output: (unavailable)]') - }) - - it('an empty lossy delta is the notice alone', () => { - expect(renderPwshProcessRead({ delta: '', lossy: true, stderrSpillPath: 'C:\\spill\\err.log' })) - .toBe('[some output was dropped from memory; full output: C:\\spill\\err.log]') - }) - - it('inserts the separating newline only when the delta lacks one', () => { - expect(renderPwshProcessRead({ delta: 'tail', lossy: true })) - .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') - expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true })) - .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') - }) - - it('appends the runner-failed notice (denial outranked)', () => { - expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true, runnerFailed: true })) - .toBe('x\n[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; this is a sandbox problem, not a command failure]') - }) - - it('appends the denial marker and hints only when escalation is advertised', () => { - expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true })) - .toBe('x\n[sandbox: file access denied under read-only mode]') - expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }, ['workspace-write'])) - .toBe('x\n[sandbox: file access denied under read-only mode]\n' - + '[sandbox: escalation available — retry this exact command once with sandbox_permissions ' - + '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]') - }) -}) - describe('processOutcome', () => { function settled(over: Partial): ShellProcess { return { @@ -1089,4 +1036,14 @@ describe('processOutcome', () => { expect(processOutcome(settled({ exitCode: null }))) .toEqual({ status: 'completed', detail: 'exit code: 0' }) }) + + it('appends sandbox facts to the terminal detail', () => { + const denied = processOutcome(settled({ exitCode: 1, sandbox: { mode: 'read-only', denied: true } }), ['workspace-write']) + expect(denied.detail).toBe(`exit code: 1; ${sandboxDenialMarker('read-only')} ${escalationHintMarker('command')}`) + const deniedWithoutEscalation = processOutcome(settled({ exitCode: 1, sandbox: { mode: 'read-only', denied: true } })) + expect(deniedWithoutEscalation.detail).toBe(`exit code: 1; ${sandboxDenialMarker('read-only')}`) + const runnerFailed = processOutcome(settled({ exitCode: 1, sandbox: { mode: 'read-only', denied: false, runnerFailed: true } })) + expect(runnerFailed.detail).toContain('the sandbox runner itself failed under read-only mode') + expect(processOutcome(settled({ sandbox: { mode: 'read-only', denied: false } })).detail).toBe('exit code: 0') + }) }) From 10d76fbd03ee00cd478ef86fbcc3c75c2686ab0a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 16:37:35 +0800 Subject: [PATCH 033/284] refactor(producers): subagent and terminal jobs use session owners, results, and pull sources - tool-subagent registers the parent session id as the owner and settleRun reports the child's final text as the job result, which the first read after settlement carries once - tool-terminal hands the registry one pull source over the backend's consuming send reader (sendSource in background.ts) instead of a readOutput hook; the truncation marker stays in the delivered text - the model's kill reason now shows in a killed job's status line, as the registry records it as the terminal detail - test call sites move from jobs.list(owner) to visibleTo(owner).list() --- .../tests/profiles/headless/tests/ptc.e2e.ts | 10 +++--- .../subagent/subagent/src/run-settlement.ts | 2 +- .../subagent/tests/run-settlement.spec.ts | 4 +-- packages/subagent/tool-subagent/src/index.ts | 4 +-- .../tool-subagent/tests/tool-subagent.spec.ts | 15 ++++----- .../terminal/tool-terminal/src/background.ts | 31 +++++++++++++++++++ packages/terminal/tool-terminal/src/index.ts | 17 +++++----- .../tool-terminal/tests/tools.spec.ts | 22 +++++++++++-- 8 files changed, 79 insertions(+), 26 deletions(-) create mode 100644 packages/terminal/tool-terminal/src/background.ts diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 8ee3affb8a..25a7292e45 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -222,7 +222,7 @@ describe('PTC mode typed values: keyless real-worker contracts', () => { return await tools.bash({ command: 'sleep 10', description: 'Must never start', run_in_background: true }); `, pre.signal) expect(preResult.isError).toBe(true) - expect(ctx.jobs.list()).toEqual([]) + expect(ctx.jobs.visibleTo().list()).toEqual([]) const afterPublication = new AbortController() const running = runCode(ctx, ` @@ -230,14 +230,14 @@ describe('PTC mode typed values: keyless real-worker contracts', () => { console.log(started.jobId); await new Promise(() => {}); `, afterPublication.signal) - for (let attempt = 0; attempt < 100 && ctx.jobs.list().length === 0; attempt++) { + for (let attempt = 0; attempt < 100 && ctx.jobs.visibleTo().list().length === 0; attempt++) { await new Promise(resolve => setTimeout(resolve, 10)) } - const job = ctx.jobs.list()[0] + const job = ctx.jobs.visibleTo().list()[0] expect(job).toMatchObject({ id: 'bash-1', status: 'running' }) afterPublication.abort('outer-call-cancelled') expect((await running).isError).toBe(true) - expect(ctx.jobs.list()[0]).toMatchObject({ id: job!.id, status: 'running' }) + expect(ctx.jobs.visibleTo().list()[0]).toMatchObject({ id: job!.id, status: 'running' }) const killed = completion(await runCode(ctx, ` return await tools.job_kill({ job_id: ${JSON.stringify(job!.id)}, reason: 'test owns cancellation' }); @@ -261,7 +261,7 @@ describe('PTC mode typed values: keyless real-worker contracts', () => { const result = await pending expect(result.isError).toBe(true) expect(Date.now() - startedAt).toBeLessThan(5_000) - expect(ctx.jobs.list()).toEqual([]) + expect(ctx.jobs.visibleTo().list()).toEqual([]) }, 15_000) it('uses versioned Cordis DTO ids directly for running and pending Plugins, then confirms removal', async () => { diff --git a/packages/subagent/subagent/src/run-settlement.ts b/packages/subagent/subagent/src/run-settlement.ts index 02f61a7baf..34baacbcd4 100644 --- a/packages/subagent/subagent/src/run-settlement.ts +++ b/packages/subagent/subagent/src/run-settlement.ts @@ -37,7 +37,7 @@ function failureDetail(result: SubagentResult): string { function runOutcome(result: SubagentResult): JobOutcome { switch (result.stopReason) { case 'completed': - return { status: 'completed', output: finalText(result.output) } + return { status: 'completed', result: finalText(result.output) } case 'aborted': return result.diagnostic === undefined ? { status: 'killed' } diff --git a/packages/subagent/subagent/tests/run-settlement.spec.ts b/packages/subagent/subagent/tests/run-settlement.spec.ts index 146181d43a..20fc514504 100644 --- a/packages/subagent/subagent/tests/run-settlement.spec.ts +++ b/packages/subagent/subagent/tests/run-settlement.spec.ts @@ -9,7 +9,7 @@ const MAX_SUBAGENT_DIAGNOSTIC_BYTES = 4_096 describe('outcome mapping helpers', () => { it.each([ - ['completed', { status: 'completed', output: 'partial' }], + ['completed', { status: 'completed', result: 'partial' }], ['aborted', { status: 'killed' }], ['error', { status: 'failed', detail: 'error' }], ['max-tokens', { status: 'failed', detail: 'max-tokens' }], @@ -34,7 +34,7 @@ describe('outcome mapping helpers', () => { dispose() { order.push('dispose'); return Promise.resolve() }, }) order.push('reported') - expect(completed).toEqual({ status: 'completed', output: 'ok' }) + expect(completed).toEqual({ status: 'completed', result: 'ok' }) expect(order).toEqual(['dispose', 'reported']) // An infrastructure rejection still disposes and reports failed. diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 03b56a3c9d..2c87bbf213 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -537,7 +537,7 @@ export function apply(ctx: Context, config: Config): void { const id = jobs.start({ kind: 'subagent', label: args.description, - owner: parent, + owner: parent.id, run: () => { const controller = new AbortController() const start = runtimeCtx.subagents.start(config.provider, { ...request, signal: controller.signal }) @@ -546,7 +546,7 @@ export function apply(ctx: Context, config: Config): void { controller.abort(reason ?? 'background subagent task killed') }, done: settleStart(start, controller.signal), - // No readOutput: the child session owns intermediate detail. + // No output sources: the child session owns intermediate detail. } }, }) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index e5f9502a43..c1b2046182 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -883,7 +883,7 @@ describe('dsh-tool-subagent background mode', () => { }) expect(text(collected)).toBe('background answer\n[status: completed]') - // Final-output reads are idempotent (not consumed). + // The result rides the first read after settlement; a later read carries only the status. const again = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('collect-2'), @@ -891,7 +891,7 @@ describe('dsh-tool-subagent background mode', () => { arguments: { job_id: 'subagent-1' }, agent: parent, }) - expect(text(again)).toBe('background answer\n[status: completed]') + expect(text(again)).toBe('(no new output)\n[status: completed]') }) it('preserves provider diagnostics in one-shot background failure detail', async () => { @@ -972,7 +972,7 @@ describe('dsh-tool-subagent background mode', () => { const result = await resultPromise expect(result.isError).toBe(true) - expect(ctx.jobs.list(parent)).toEqual([]) + expect(ctx.jobs.visibleTo(parent.id).list()).toEqual([]) }) it('rejects startup when the provider changes during asynchronous route preflight', async () => { @@ -1082,7 +1082,8 @@ describe('dsh-tool-subagent background mode', () => { arguments: { job_id: 'subagent-1', wait: true }, agent: parent, }) - expect(text(output)).toBe('(no new output)\n[status: killed]') + // The registry records the model's kill reason as the terminal detail. + expect(text(output)).toBe('(no new output)\n[status: killed, no longer needed]') }) it('reports startup rollback failure after cancellation as a failed job', async () => { @@ -1169,7 +1170,7 @@ describe('dsh-tool-subagent background mode', () => { // The aborted children settle as killed tasks. const killed = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('w1'), name: 'job_output', arguments: { job_id: 'subagent-1', wait: true }, agent: parent }) - expect(text(killed)).toBe('(no new output)\n[status: killed]') + expect(text(killed)).toBe('(no new output)\n[status: killed, superseded]') }) }) @@ -1240,7 +1241,7 @@ describe('dsh-tool-subagent continuable background mode', () => { expect(match).not.toBeNull() const [, childId] = match! // No Task was created for the continuable child. - expect(ctx.jobs.list(parent)).toEqual([]) + expect(ctx.jobs.visibleTo(parent.id).list()).toEqual([]) await vi.waitFor(() => { expect(ctx.agents.get(SessionId(childId!))).toBeUndefined() @@ -1271,7 +1272,7 @@ describe('dsh-tool-subagent continuable background mode', () => { if (result.isError) throw new Error('expected foreground subagent success') expect(result.value).toMatchObject({ kind: 'foreground' }) expect(text(result)).toBe('continuable answer') - expect(ctx.jobs.list(parent)).toEqual([]) + expect(ctx.jobs.visibleTo(parent.id).list()).toEqual([]) }) it('isolates a cancelled continuable preparation from a concurrent sibling', async () => { diff --git a/packages/terminal/tool-terminal/src/background.ts b/packages/terminal/tool-terminal/src/background.ts new file mode 100644 index 0000000000..47494f7cff --- /dev/null +++ b/packages/terminal/tool-terminal/src/background.ts @@ -0,0 +1,31 @@ +/** + * Generic-job adaptation for background terminal sends: the registry pull + * source over the backend's consuming send reader. + * + * @module @deepseek-ai/dsh-tool-terminal/background + */ + +import type { JobOutputSource } from '@deepseek-ai/dsh-jobs' +import type { TerminalSendOperation } from '@deepseek-ai/dsh-terminal' +import { renderSendRead } from './render.ts' + +/** + * Adapt the backend's consuming send reader to a registry pull source. The + * reader has no offsets of its own — each call hands over what arrived since + * the previous one — so the source counts the bytes it delivered to keep the + * registry's cursor monotonic. It binds lazily because the send starts inside + * the job starter, after the registry admitted the job; a read before that + * point delivers nothing. + * @param operation - the live send, once the starter has begun it. + * @returns one unlabeled source over the send's bounded delta. + */ +export function sendSource(operation: () => TerminalSendOperation | undefined): JobOutputSource { + return { + read: (fromByte) => { + const live = operation() + if (live === undefined) return { text: '', nextOffset: fromByte, lossy: false } + const text = renderSendRead(live.readOutput()) + return { text, nextOffset: fromByte + Buffer.byteLength(text), lossy: false } + }, + } +} diff --git a/packages/terminal/tool-terminal/src/index.ts b/packages/terminal/tool-terminal/src/index.ts index 880cfcd31f..417c1537c4 100644 --- a/packages/terminal/tool-terminal/src/index.ts +++ b/packages/terminal/tool-terminal/src/index.ts @@ -9,11 +9,12 @@ import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { TerminalSessionId } from '@deepseek-ai/dsh-terminal' -import type { TerminalSendResult, TerminalSessionId as TerminalSessionIdType, TerminalSignal } from '@deepseek-ai/dsh-terminal' +import type { TerminalSendOperation, TerminalSendResult, TerminalSessionId as TerminalSessionIdType, TerminalSignal } from '@deepseek-ai/dsh-terminal' import type {} from '@deepseek-ai/dsh-jobs' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' -import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts' +import { sendSource } from './background.ts' +import { boundTerminalText, renderList, renderRead, renderSend, renderSpawn } from './render.ts' declare module '@deepseek-ai/dsh-jobs' { interface JobKindMap { @@ -252,23 +253,25 @@ export function apply(ctx: Context, config: Config = {}): void { const jobs = ctx.get('jobs') if (jobs === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') let cancelRequested = false + let operation: TerminalSendOperation | undefined const jobId = jobs.start({ kind: 'pty-send', label: `${id}: ${args.text || '(input)'}`, - owner, + owner: owner.id, outputLimitBytes: maxResultBytes, + output: [sendSource(() => operation)], run: () => { - const operation = ctx.terminals.startSend(owner, id, request) + const started = ctx.terminals.startSend(owner, id, request) + operation = started return { cancel: () => { cancelRequested = true - operation.cancel() + started.cancel() }, - done: operation.done.then( + done: started.done.then( result => ({ status: cancelRequested ? 'killed' as const : 'completed' as const, detail: sendDetail(result) }), (error: unknown) => ({ status: 'failed' as const, detail: String(error) }), ), - readOutput: () => renderSendRead(operation.readOutput()), } }, }) diff --git a/packages/terminal/tool-terminal/tests/tools.spec.ts b/packages/terminal/tool-terminal/tests/tools.spec.ts index 15151f7bd2..eb80741bf8 100644 --- a/packages/terminal/tool-terminal/tests/tools.spec.ts +++ b/packages/terminal/tool-terminal/tests/tools.spec.ts @@ -8,10 +8,11 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { renderToolsSdk } from '@deepseek-ai/dsh-tools' import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts' import TerminalSessionService, { TerminalSessionId } from '@deepseek-ai/dsh-terminal' -import type { TerminalBackend, TerminalBackendSession, TerminalSendOperation, TerminalSendRequest, TerminalSessionStatus, TerminalSignal } from '@deepseek-ai/dsh-terminal' +import type { TerminalBackend, TerminalBackendSession, TerminalSendOperation, TerminalSendRead, TerminalSendRequest, TerminalSessionStatus, TerminalSignal } from '@deepseek-ai/dsh-terminal' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import * as ToolPty from '@deepseek-ai/dsh-tool-terminal' +import { sendSource } from '../src/background.ts' function fakeAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) @@ -54,7 +55,11 @@ class StubSession implements TerminalBackendSession { })) const operation: TerminalSendOperation = { done, - readOutput: () => ({ delta: this.delta, truncated: this.deltaTruncated }), + readOutput: () => { + const delta = this.delta + this.delta = '' + return { delta, truncated: this.deltaTruncated } + }, cancel: () => { if (cancelled) return false cancelled = true @@ -402,6 +407,19 @@ describe('tool-terminal foreground API', () => { }) }) +describe('sendSource', () => { + it('reads nothing before the send starts and counts delivered bytes afterwards', () => { + const reads: TerminalSendRead[] = [{ delta: '界', truncated: false }, { delta: '', truncated: true }] + const send: { operation?: TerminalSendOperation } = {} + const source = sendSource(() => send.operation) + expect(source.read(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) + send.operation = { readOutput: () => reads.shift()!, cancel: () => false, done: new Promise(() => {}) } + expect(source.read(0)).toEqual({ text: '界', nextOffset: 3, lossy: false }) + // A truncated delta carries the same marker the foreground read shows. + expect(source.read(3)).toEqual({ text: '[output truncated]', nextOffset: 21, lossy: false }) + }) +}) + describe('tool-terminal task integration', () => { it('registers a generic task and exposes incremental output', async () => { const { ctx, agent } = await setup(true) From 2132b7beebc141a371afb2cedcac7020f8393108 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 16:43:15 +0800 Subject: [PATCH 034/284] refactor(session-controller): drop the job roster from the control stream The control baseline and frames carry queues and projections only; the SessionJob row type, the jobs frame, the registry subscription, and the client jobsBySession mirror are gone, and every client fixture drops the empty mirror. The roster moves to dsh-api-job-controller's job.rows stream in the next change. --- packages/api/remotes/src/client/index.ts | 1 - .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 3 +- packages/api/session-controller/README.zh.md | 3 +- packages/api/session-controller/package.json | 5 - .../src/client/sessions/manager.ts | 22 -- .../src/client/sessions/service.ts | 13 +- .../api/session-controller/src/control.ts | 48 +--- packages/api/session-controller/src/types.ts | 21 -- .../tests/control-jobs.host.spec.ts | 248 ------------------ .../tests/manager.client.spec.ts | 65 +---- .../tests/transport.client.spec.ts | 4 +- .../locale/tests/language-row.client.spec.tsx | 2 +- .../ui-chat/tests/chat-view.client.spec.tsx | 2 +- .../tests/gate-branch-tails.client.spec.tsx | 4 +- .../tests/transcript-view-row.client.spec.tsx | 2 +- .../conversation-registry.client.spec.ts | 2 +- .../tests/enter-behavior-row.client.spec.tsx | 2 +- .../tests/input-bar.client.spec.tsx | 2 +- .../tests/input-matrix.client.spec.tsx | 2 +- .../tests/input-scenarios.client.spec.tsx | 2 +- .../tests/skeleton.client.spec.tsx | 2 +- .../tests/ui-session.client.spec.ts | 2 +- .../tests/conversation-ui.client.spec.tsx | 2 +- .../tests/appearance-row.client.spec.tsx | 2 +- .../tests/font-size-row.client.spec.tsx | 2 +- .../tests/coverage-tails.client.spec.tsx | 2 +- .../ui-tool/tests/diff-card.client.spec.tsx | 6 +- .../ui-tool/tests/image-card.client.spec.tsx | 2 +- .../ui-tool/tests/read-card.client.spec.tsx | 6 +- .../ui-tool/tests/search-card.client.spec.tsx | 2 +- .../tests/terminal-card.client.spec.tsx | 8 +- .../ui-tool/tests/web-card.client.spec.tsx | 2 +- .../ui-trajectory/tests/views.client.spec.tsx | 2 +- .../tests/plan-review-panel.client.spec.tsx | 2 +- .../user-questions-composer.client.spec.tsx | 2 +- .../tests/workflow-run.client.spec.tsx | 2 +- .../ui-workspace/tests/apply.client.spec.ts | 2 +- .../ui-workspace/tests/tree.client.spec.ts | 2 +- .../tests/workspace-browser.client.spec.tsx | 2 +- .../tests/workspace-picker.client.spec.tsx | 2 +- .../tests/workspaces-service.client.spec.ts | 2 +- .../client-runtime/src/sessions.ts | 2 +- pnpm-lock.yaml | 3 - 44 files changed, 52 insertions(+), 466 deletions(-) delete mode 100644 packages/api/session-controller/tests/control-jobs.host.spec.ts diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index ce08f3dbe3..e0185c5176 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -34,7 +34,6 @@ export type {} from '@deepseek-ai/dsh-api-job-controller/remote' export type * from '@deepseek-ai/dsh-api-job-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' -export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 9f55558821..ad6ea9d524 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: 4414cd7221b40cc880122415e83b6dc3818df481 -README.zh.md: 8f457d4a94f57c802f67624d9c84ba15c78b1ada +README.md: 941c255d3923b4b51635381b2f45f83780bb60c8 +README.zh.md: 3dfe2a206fa7657e57e9c3b43e40fcd75e8c1bc0 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 4414cd7221..941c255d39 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,7 +29,7 @@ Each endpoint states its activation policy. List, search, attachment, history pa The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. -A `SessionJob` row carries `record: true` exactly when the job declared an observation record; streaming that record is the job controller's `job.observe` (see [`dsh-api-job-controller`](../job-controller/README.md)), while this package keeps the roster on the control stream. +Background-job rows and observation streams belong to [`dsh-api-job-controller`](../job-controller/README.md); the control stream carries queues and projections only. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. @@ -61,7 +61,6 @@ No direct effect; model requests remain owned by the Agent and LLM packages. -- Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. - File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 8f457d4a94..3dfe2a206f 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 -`SessionJob` 行恰在该 job 声明了观测 record 时携带 `record: true`;流式发送该 record 的是 job 控制器的 `job.observe`(见 [`dsh-api-job-controller`](../job-controller/README.zh.md)),本包只在控制流上保留名册。 +后台 job 的名册行与观测流属于 [`dsh-api-job-controller`](../job-controller/README.zh.md);控制流只承载队列与投影。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 @@ -61,7 +61,6 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 -- Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。 - follow 恢复失败会对调用方可见,而不会无限重试。 - 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index e60bfd4517..e836e921ae 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -82,7 +82,6 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", @@ -101,9 +100,6 @@ "@deepseek-ai/dsh-workspace": "workspace:^" }, "peerDependenciesMeta": { - "@deepseek-ai/dsh-jobs": { - "optional": true - }, "@deepseek-ai/dsh-session-persistence": { "optional": true }, @@ -121,7 +117,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index fd39fefedf..0c143d7b6d 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -10,7 +10,6 @@ import type { SessionControlFrame, SessionQueuedItem, SessionSummary, - SessionJob as JobView, } from '../../types.ts' import { mergeOrderedBaseline } from '../ordered-baseline.ts' import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' @@ -56,8 +55,6 @@ export interface SessionListSnapshot { phase: SessionListPhase error: RemoteFailure | null subagentsByParent: Readonly> - /** Background jobs per session; an absent key is an empty set. */ - jobsBySession: Readonly> currentAddress: SubagentAddress | undefined } @@ -126,12 +123,6 @@ export class SessionManager { private readonly catalogStale = new Set() private readonly openCatalogs = new Set() private readonly catalogDebounce = new Map>() - /** - * Background jobs per session, last-wins from Session Controller's control - * stream. An empty set is stored as an absent key, so absence and `[]` are - * one representation. - */ - private readonly jobsBySession = new Map() private selected: SessionId | undefined @@ -669,12 +660,6 @@ export class SessionManager { this.notifier.markDirty() return } - if (frame.type === 'jobs') { - if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId) - else this.jobsBySession.set(frame.sessionId, frame.jobs) - this.notifier.markDirty() - return - } this.queues.set(frame.sessionId, frame.items) this.sessions.get(frame.sessionId)?.handleControlFrame(frame) } @@ -685,11 +670,6 @@ export class SessionManager { this.queues.set(sessionId as SessionId, items) } - this.jobsBySession.clear() - for (const [sessionId, jobs] of Object.entries(baseline.jobs)) { - if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs) - } - for (const [sessionId, block] of Object.entries(baseline.projections)) { const store = this.projectionStore(sessionId as SessionId) const asOfSeq = sessionSeqCursor(block.asOfSeq) @@ -739,7 +719,6 @@ export class SessionManager { if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false) else this.sessions.get(sessionId)?.handleRemoved() this.queues.delete(sessionId) - this.jobsBySession.delete(sessionId) if (!durableSubagent) this.projectionStores.delete(sessionId) const inflightCatalog = this.catalogInflight.get(sessionId) if (inflightCatalog !== undefined) { @@ -951,7 +930,6 @@ export class SessionManager { phase: this.listPhase, error: this.listError, subagentsByParent: Object.fromEntries(this.catalogs), - jobsBySession: Object.fromEntries(this.jobsBySession), currentAddress: current === undefined ? undefined : this.addresses.get(current), } } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index d1d840c513..521752da39 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -20,7 +20,6 @@ import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts' -import type { SessionJob as JobView } from '../../types.ts' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import { createSnapshotStore, type SnapshotStore, @@ -76,12 +75,6 @@ export interface SessionListState { phase: SessionListPhase /** Direct durable catalogs keyed by their selected parent address. */ subagentsByParent: Readonly> - /** - * Background jobs each session can see, mirrored last-wins from Session - * Controller's control baseline and `jobs` frames. A missing key is an empty - * set, so consumers read absence rather than a sentinel. - */ - jobsBySession: Readonly> /** Current session's catalog-derived address, absent on ordinary navigation. */ currentAddress: SubagentAddress | undefined } @@ -233,7 +226,7 @@ export class ClientSessions implements ISessions { ) this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'pending', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, }) // The manager owns wire truth; the store is its projection. Manager // notifications are already microtask-batched. @@ -576,7 +569,7 @@ export class ClientSessions implements ISessions { /** Project the manager's list snapshot into the store (title derivation is display-only). */ private projectList(): void { const { - items, current, phase, subagentsByParent, jobsBySession, currentAddress, + items, current, phase, subagentsByParent, currentAddress, } = this.manager.getListSnapshot() const ids: SessionId[] = [] const byId: Record = {} @@ -642,7 +635,7 @@ export class ClientSessions implements ISessions { ...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }), }) } - this.list.set({ ids, byId, current, phase, subagentsByParent, jobsBySession, currentAddress }) + this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress }) this.pruneScopes() } diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index b96cbb7f9d..ad64f36ec0 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -1,9 +1,8 @@ -/** Live Session queue, jobs, and projection state with reconnect baselines. */ +/** Live Session queue and projection state with reconnect baselines. */ import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { Deque } from '@deepseek-ai/dsh-deque' -import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' import type { Session, SessionEvent, SessionEventMap, SessionId, UserMessage, } from '@deepseek-ai/dsh-session' @@ -11,7 +10,6 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { SessionControlBaseline, SessionControlFrame, - SessionJob, SessionProjectionBaseline, SessionProjectionValues, SessionQueuedItem, @@ -21,7 +19,7 @@ import type { export class SessionControlController { private readonly streams = new Set() - /** @param ctx - Host context carrying live Agent, projection, and jobs services. */ + /** @param ctx - Host context carrying live Agent and projection services. */ constructor(private readonly ctx: Context) { ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) }) ctx.sessionProjections.onChanged((session, key, value, seq) => { @@ -33,13 +31,6 @@ export class SessionControlController { seq, }) }) - ctx.inject(['jobs'], (jobsCtx) => { - jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) }) - }) - ctx.on('session/created', (session) => { - const jobs = this.jobsFor(this.ctx.agents.get(session.id)) - if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs }) - }) ctx.effect(() => () => { for (const stream of this.streams) stream.end() this.streams.clear() @@ -67,15 +58,12 @@ export class SessionControlController { private baseline(): SessionControlBaseline { const sessions = this.ctx.sessions.list() const queues = Object.create(null) as Record - const jobs = Object.create(null) as Record for (const session of sessions) { const agent = this.ctx.agents.get(session.id) queues[session.id] = agent?.session === session ? queueItems(agent) : [] - jobs[session.id] = this.jobsFor(agent) } return { queues, - jobs, projections: this.projectionBaseline(sessions), } } @@ -106,25 +94,6 @@ export class SessionControlController { }) } - private onJobsChanged(owner: Agent | undefined): void { - if (owner !== undefined) { - this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) }) - return - } - for (const session of this.ctx.sessions.list()) { - this.broadcast({ - type: 'jobs', - sessionId: session.id, - jobs: this.jobsFor(this.ctx.agents.get(session.id)), - }) - } - } - - private jobsFor(agent: Agent | undefined): SessionJob[] { - const jobs = this.ctx.get('jobs') - return jobs === undefined ? [] : jobs.list(agent).map(jobView) - } - private broadcast(frame: SessionControlFrame): void { for (const stream of this.streams) stream.push(frame) } @@ -202,16 +171,3 @@ function promptRpcId(message: UserMessage): Pick { const source = message.source return source.kind === 'user' && 'rpcId' in source ? { rpcId: source.rpcId } : {} } - -function jobView(job: JobSnapshot): SessionJob { - return { - id: job.id, - kind: job.kind, - label: job.label, - status: job.status, - ...(job.detail === undefined ? {} : { detail: job.detail }), - startedAt: job.startedAt, - ...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }), - ...(job.outputTotal === undefined ? {} : { record: true as const }), - } -} diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index d3ab795ecb..d65d977e9a 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -9,7 +9,6 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' -import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' @@ -479,28 +478,9 @@ export interface SessionQueuedItem { } } -/** Browser-safe background-job row. */ -export interface SessionJob { - readonly id: JobId - readonly kind: string - readonly label: string - readonly status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' - readonly detail?: string - readonly startedAt: number - readonly finishedAt?: number - /** - * Present exactly when the job declared an observation record: the row is - * observable through the job controller's `job.observe` stream, whose - * `opened` anchor carries the live byte offsets. The roster refreshes on - * lifecycle commits only, so it mirrors no byte count. - */ - readonly record?: true -} - /** Complete live control baseline emitted once per control stream generation. */ export interface SessionControlBaseline { readonly queues: Readonly> - readonly jobs: Readonly> readonly projections: Readonly> } @@ -516,7 +496,6 @@ export interface SessionProjectionUpdate { export type SessionControlFrame = | { readonly type: 'baseline'; readonly value: SessionControlBaseline } | { readonly type: 'queue'; readonly sessionId: SessionId; readonly items: readonly SessionQueuedItem[] } - | { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] } | ({ readonly type: 'projection' } & SessionProjectionUpdate) diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts deleted file mode 100644 index 9d80501966..0000000000 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { JobOutcome } from '@deepseek-ai/dsh-jobs' -import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' -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 { describe, expect, it } from 'vitest' -import { SessionControlController } from '../src/control.ts' -import type { SessionControlFrame } from '../src/types.ts' - -type BaselineFrame = Extract -type JobFrame = Extract - -function producer(label = 'sleep 60') { - let settle!: (outcome: JobOutcome) => void - const reads = { count: 0 } - const spec = { - kind: 'bash' as const, - label, - run: () => ({ - cancel: () => {}, - done: new Promise((resolve) => { settle = resolve }), - readOutput: () => { reads.count += 1; return 'stolen output' }, - }), - } - return { spec, reads, settle: (outcome: JobOutcome) => { settle(outcome) } } -} - -async function harness(withJobs: boolean): Promise<{ - ctx: Context - session: Session - agent: Agent - control: SessionControlController -}> { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) - if (withJobs) { - await ctx.plugin(LocalJobRegistry) - ctx.jobs.attachController('session-controller-test') - } - const session = ctx.sessions.create() - const agent = { - id: session.id, - session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), - status: 'idle', - ctx, - } as Agent - ctx.agents.register(agent) - const control = new SessionControlController(ctx) - await new Promise(resolve => setTimeout(resolve, 0)) - return { ctx, session, agent, control } -} - -async function baseline(control: SessionControlController): Promise { - const abort = new AbortController() - const iterator = control.control(abort.signal)[Symbol.asyncIterator]() - const first = await iterator.next() - abort.abort() - await iterator.next() - if (first.done || first.value.type !== 'baseline') throw new Error('missing control baseline') - return first.value -} - -async function collectJobs( - iterable: AsyncIterable, - count: number, - abort: AbortController, -): Promise { - const jobs: JobFrame[] = [] - for await (const frame of iterable) { - if (frame.type !== 'jobs') continue - jobs.push(frame) - if (jobs.length >= count) abort.abort() - } - return jobs -} - -describe('Session control jobs baseline', () => { - it('represents an attached session with no jobs as an empty set', async () => { - const { session, control } = await harness(true) - const frame = await baseline(control) - expect(frame.value.jobs[session.id]).toEqual([]) - }) - - it('carries the visible set when the stream opens', async () => { - const { ctx, session, agent, control } = await harness(true) - ctx.jobs.start({ ...producer('pnpm run build').spec, owner: agent }) - const frame = await baseline(control) - const jobs = frame.value.jobs[session.id] - expect(jobs).toHaveLength(1) - const [job] = jobs ?? [] - expect(job?.startedAt).toBeTypeOf('number') - expect({ ...job, startedAt: 0 }).toEqual({ - id: 'bash-1', - kind: 'bash', - label: 'pnpm run build', - status: 'running', - startedAt: 0, - }) - }) -}) - -describe('Session control jobs updates', () => { - it('publishes existing unowned jobs when a Session attaches after the stream opens', async () => { - const { ctx, control } = await harness(true) - const abort = new AbortController() - const iterator = control.control(abort.signal)[Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'baseline' } }) - const task = producer('already running') - const id = ctx.jobs.start(task.spec) - await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'jobs' } }) - - const created = ctx.sessions.create(SessionId('late-session')) - await expect(iterator.next()).resolves.toMatchObject({ - value: { - type: 'jobs', - sessionId: created.id, - jobs: [expect.objectContaining({ id, label: 'already running' })], - }, - }) - - task.settle({ status: 'completed' }) - abort.abort() - await iterator.return?.() - }) - - it('pushes the owner whole set on registration, stopping, and settlement', async () => { - const { ctx, session, agent, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 3, abort) - - const task = producer() - const id = ctx.jobs.start({ ...task.spec, owner: agent }) - ctx.jobs.kill(id, agent, 'test') - task.settle({ status: 'killed', detail: 'signal: SIGTERM' }) - - const frames = await collected - expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id]) - expect(frames.map(frame => frame.jobs[0]?.status)).toEqual(['running', 'stopping', 'killed']) - expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM') - expect(frames[2]?.jobs[0]?.finishedAt).toBeTypeOf('number') - }) - - it('drops internal registry fields from the browser view', async () => { - const { ctx, agent, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 1, abort) - ctx.jobs.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 }) - - const [frame] = await collected - expect(Object.keys(frame?.jobs[0] ?? {}).sort()).toEqual([ - 'id', - 'kind', - 'label', - 'startedAt', - 'status', - ]) - }) - - it('flags record-declaring jobs instead of mirroring a byte count the roster never refreshes', async () => { - const { ctx, agent, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 1, abort) - ctx.jobs.start({ - kind: 'bash', - label: 'observable run', - owner: agent, - record: true, - run: (job) => { - job.append('hi') - return { cancel: () => {}, done: new Promise(() => {}) } - }, - }) - - const [frame] = await collected - expect(frame?.jobs[0]).toMatchObject({ record: true }) - expect(frame?.jobs[0]).not.toHaveProperty('outputTotal') - }) - - it('fans an unowned change out to every attached session', async () => { - const { ctx, control } = await harness(true) - const second = ctx.sessions.create() - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 2, abort) - - ctx.jobs.start(producer('open to every caller').spec) - - const frames = await collected - expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2) - expect(frames.some(frame => frame.sessionId === second.id)).toBe(true) - for (const frame of frames) expect(frame.jobs[0]?.label).toBe('open to every caller') - }) - - it('does not resume persisted sessions while projecting an unowned change', async () => { - const { ctx, control } = await harness(true) - const coldId = SessionId('session-cold-tasks') - let loaded = false - ctx.provide('sessionPersistence', { - list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], - locate: () => undefined, - load: () => { loaded = true; throw new Error('job projection must not load a cold log') }, - } as never) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 1, abort) - - ctx.jobs.start(producer().spec) - await collected - expect(loaded).toBe(false) - expect(ctx.agents.get(coldId)).toBeUndefined() - }) - - it('reports empty sets when no jobs registry is composed', async () => { - const { session, control } = await harness(false) - const frame = await baseline(control) - expect(frame.value.jobs[session.id]).toEqual([]) - }) - - it('never consumes model output while projecting a lifecycle', async () => { - const { ctx, agent, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 3, abort) - - const task = producer() - const id = ctx.jobs.start({ ...task.spec, owner: agent }) - ctx.jobs.kill(id, agent, 'test') - task.settle({ status: 'killed', detail: 'signal: SIGTERM' }) - await collected - - expect(task.reads.count).toBe(0) - }) - - it('never consumes model output while producing a baseline', async () => { - const { ctx, agent, control } = await harness(true) - const task = producer() - ctx.jobs.start({ ...task.spec, owner: agent }) - - const frame = await baseline(control) - - expect(frame.value.jobs[agent.id]).toHaveLength(1) - expect(task.reads.count).toBe(0) - }) - -}) diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 14f7c25fca..f59d1a782d 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -187,7 +187,7 @@ describe('list lifecycle', () => { frame({ type: 'baseline', value: { - queues: {}, jobs: {}, + queues: {}, projections: { [S1]: { asOfSeq: 2, values: {} } }, }, }) @@ -200,7 +200,7 @@ describe('list lifecycle', () => { frame({ type: 'baseline', value: { - queues: {}, jobs: {}, + queues: {}, projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } }, }, }) @@ -919,64 +919,3 @@ describe('completed reminder', () => { expect(entry(manager, S2)?.completed).toBe(true) }) }) - -describe('background-job mirror', () => { - const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({ - id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over, - }) - const tasksFrame = ( - sessionId: SessionId, - jobs: unknown[], - ): Extract => ({ - type: 'jobs', sessionId, jobs: jobs as never, - }) - - it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => { - const manager = makeManager() - manager.handleControlFrame(tasksFrame(S1, [view()])) - manager.handleControlFrame(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) - const first = manager.getListSnapshot().jobsBySession - expect(first[S1]).toEqual([view()]) - expect(first[S2]?.[0]?.label).toBe('other') - - // Last-wins: the newer whole set replaces, it does not merge. - manager.handleControlFrame(tasksFrame(S1, [view({ status: 'completed' })])) - expect(manager.getListSnapshot().jobsBySession[S1]).toEqual([view({ status: 'completed' })]) - }) - - it('stores an emptied set as an absent key so absence and [] read alike', () => { - const manager = makeManager() - manager.handleControlFrame(tasksFrame(S1, [view()])) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(true) - manager.handleControlFrame(tasksFrame(S1, [])) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) - }) - - it('clears the mirror when the next control baseline has no jobs', () => { - const manager = makeManager() - manager.handleControlFrame(tasksFrame(S1, [view()])) - manager.handleControlFrame({ - type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, - }) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) - }) - - it('drops the rows when the session is removed, whichever stream lands first', () => { - const manager = makeManager() - manager.handleSessionAdded(summary(S1, { blank: true })) - manager.handleControlFrame(tasksFrame(S1, [view()])) - manager.handleSessionRemoved(S1) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) - }) - - it('notifies list subscribers so an open header re-renders without a poll', async () => { - const manager = makeManager() - const seen = vi.fn() - manager.subscribe(seen) - manager.handleControlFrame(tasksFrame(S1, [view()])) - // The notifier batches on a microtask; the frame itself is already applied. - await Promise.resolve() - expect(seen).toHaveBeenCalled() - }) -}) diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index c5f50ad7de..560874d8d0 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -327,7 +327,7 @@ describe('Session Client stream adapters', () => { it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => { const baseline: SessionControlFrame = { type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, + value: { queues: {}, projections: {} }, } const update: SessionControlFrame = { type: 'queue', sessionId: 'session-1' as never, items: [], @@ -362,7 +362,7 @@ describe('Session Client stream adapters', () => { const baseline: SessionControlFrame = { type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, + value: { queues: {}, projections: {} }, } const carrierFailed = vi.fn() const failed = vi.fn() diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index 44ddb31e7b..198127a703 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -15,7 +15,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 9e0818f37c..f1029a50b8 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -194,7 +194,7 @@ const compaction = (over: Partial = {}): CompactionSummar /** Empty sessions-list hook for the global standard-kit seat. */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index 57afd2abd8..7d42abb682 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -133,7 +133,7 @@ describe('render branch tails', () => { const chat = createChatStore().create() chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget) const emptyList = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) const workspaces = emptyWorkspaces() const view = render( { const chat = createChatStore().create() chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget) const emptyList = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) const workspaces = emptyWorkspaces() const owners: DetailsToolOwnerProps[] = [] const view = render( diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index f946a28f1a..a22c273552 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -13,7 +13,7 @@ afterEach(cleanup) function emptySessions() { return bindSnapshotSelector(createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, })) } diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 9805bf189f..dc8f342c90 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -73,7 +73,7 @@ function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBind current: undefined, phase: 'ready', subagentsByParent: {}, - jobsBySession: {}, + currentAddress: undefined, }) const sessions = { diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 44022cb553..ff8649695a 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -18,7 +18,7 @@ afterEach(() => { function emptySessions() { return bindSnapshotSelector(createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, })) } diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 0edc8b9ef6..72e41bb829 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -162,7 +162,7 @@ function bench(over?: BenchOptions) { useSessionPendingInteraction: bindSnapshotSelector(createSnapshotStore(new Map())), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index bdb878672b..a5367c6304 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -49,7 +49,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, })), useSessionPendingInteraction: bindSnapshotSelector( createSnapshotStore(new Map()), diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index ec7f586683..0babbf8dc1 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -138,7 +138,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo useSession: bindSnapshotSelector(sessionStore), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, })), useSessionPendingInteraction: bindSnapshotSelector( createSnapshotStore(new Map()), diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index b431a51ff8..2e9dacdc12 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -146,7 +146,7 @@ function mount( ...listed && { [SID]: childRow }, }, current: SID, - phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + phase: 'ready', subagentsByParent: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore(workspaceState(workspaceRows)) const session = createSnapshotStore(snapshot) diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 3eca1b7a47..f44320b36d 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -40,7 +40,7 @@ function createSessionsBench(_ctx: Context): SessionsBench { current: undefined, phase: 'ready', subagentsByParent: {}, - jobsBySession: {}, + currentAddress: undefined, }) const bindings = new Map() diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 6839df928c..13ef3391fc 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -63,7 +63,7 @@ function props( }, current: PARENT, phase: 'ready', subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested }, - jobsBySession: {}, + currentAddress: undefined, } satisfies SessionListState function useSessions(select: (snapshot: SessionListState) => T): T { diff --git a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx index c42d6aa3b6..6d1f40366b 100644 --- a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx @@ -21,7 +21,7 @@ const COPY: Record = { function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx index 314576d572..eb33802368 100644 --- a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -23,7 +23,7 @@ const COPY: Record = { /** Empty global standard-kit hooks (the row reads neither). */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index 04e464d968..062a4222bc 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -29,7 +29,7 @@ function listStore() { }, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, }) } diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index ed386b8321..d2906e6ccf 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -212,7 +212,7 @@ describe('FileMutationRow diff card', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, }) @@ -363,13 +363,13 @@ describe('DetailsPanel diff Output section', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, }) const session = createSnapshotStore(sessionSnapshot(SID)) diff --git a/packages/client/ui-tool/tests/image-card.client.spec.tsx b/packages/client/ui-tool/tests/image-card.client.spec.tsx index 53578e25d9..c5ccf03c9e 100644 --- a/packages/client/ui-tool/tests/image-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/image-card.client.spec.tsx @@ -269,7 +269,7 @@ describe('ReadImageRow keyed toolview', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, } as unknown as SessionListState) diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 092a7ce506..9e3f547b3f 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -194,7 +194,7 @@ describe('ReadRow keyed toolview', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, }) @@ -283,13 +283,13 @@ describe('DetailsPanel Output section (read)', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, }) const session = createSnapshotStore(sessionSnapshot(SID)) diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index bc5213d02e..6e62152851 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -375,7 +375,7 @@ describe('DetailsPanel Output section (search)', () => { if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, }) const session = createSnapshotStore(sessionSnapshot(SID)) const conversation = createSnapshotStore(conversationSnapshot()) diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 064c25d4ec..7e036fce66 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -390,7 +390,7 @@ describe('BashRow terminal card', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, }) @@ -499,13 +499,13 @@ describe('DetailsPanel Output section', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, }) const session = createSnapshotStore(sessionSnapshot(SID)) @@ -705,7 +705,7 @@ describe('DetailsPanel Output section', () => { useSessions={bindSnapshotSelector(createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, }))} useSessionPendingInteraction={bindSnapshotSelector(attention)} useWorkspaces={bindSnapshotSelector(workspaces)} diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index f510ae050f..70a7ea5f8b 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -222,7 +222,7 @@ describe('DetailsPanel web Output section', () => { if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, }) const session = createSnapshotStore(sessionSnapshot(SID)) const conversation = createSnapshotStore(conversationSnapshot()) diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index fba1a7b77e..0626f7dc0e 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -167,7 +167,7 @@ function standaloneDuration(): Pick< /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 35575aeb4d..4bb2181469 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -48,7 +48,7 @@ const sessionList = { current: SID, phase: 'ready' as const, subagentsByParent: {}, - jobsBySession: {}, + currentAddress: undefined, } const attentionState: AttentionState = new Map() diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index e44f492b10..97cd206508 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -47,7 +47,7 @@ const sessionList = { current: SID, phase: 'ready' as const, subagentsByParent: {}, - jobsBySession: {}, + currentAddress: undefined, } const attentionState: AttentionState = new Map() diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 772c90a571..4e01c5c61c 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -299,7 +299,7 @@ const listState = (overrides: Partial = {}): SessionListState current: PARENT_ID, phase: 'ready', subagentsByParent: {}, - jobsBySession: {}, + currentAddress: undefined, ...overrides, }) diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index be05aae9df..660396aace 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -47,7 +47,7 @@ async function bench() { list: { getSnapshot: () => ({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, }), subscribe, }, diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index 1451b1450d..1f7dcf2a47 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -20,7 +20,7 @@ const list = (...items: SessionSummary[]): SessionListState => ({ ids: items.map(item => item.id), byId: Object.fromEntries(items.map(item => [item.id, item])), current: undefined, - phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + phase: 'ready', subagentsByParent: {}, currentAddress: undefined, }) const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({ workspaceId: wid(id), path: `/projects/${id}`, title, diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 8ee31d37c9..0349223796 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -32,7 +32,7 @@ const sessionState = (items: readonly SessionSummary[], overrides: Partial [item.id, item])), current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, ...overrides, }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx index 1c76b8b25f..746c6b3259 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -30,7 +30,7 @@ function hook(snapshot: T) { return function select(selector: (state: T) => S): S { return selector(snapshot) } } const sessions: SessionListState = { - ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, } const noPendingInteraction: SessionPendingInteractionSnapshot = new Map() const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({ diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 393619bbb7..7d746b7fa1 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -56,7 +56,7 @@ function sessionState( current, phase, subagentsByParent: {}, - jobsBySession: {}, + currentAddress: undefined, } } diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index 419e155af7..32db7c5eac 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -216,7 +216,7 @@ export class TestSessions implements ISessions { constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) { this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b01a46c7f0..51447df391 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -836,9 +836,6 @@ importers: '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm From b7ce934b29c4aa9e5d5ac8c9e1b2f176008374b2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 17:00:49 +0800 Subject: [PATCH 035/284] refactor(job-controller): own the roster stream and the browser jobs service - job.rows({ sessionId }) streams the jobs one session can see as whole-set frames: once on open, then after each coalesced burst of lifecycle commits; output appends never refresh it - job.observe reads through visibleTo(sessionId) and carries JobView in its opened and status frames; the wire chunk is the registry's JobChunk - the client installs ctx.jobs (ClientJobs over ClientJobsModel): watchRows and observe are reference-counted streams, rows drop with the last watcher or a terminal failure, observations keep their render tail - the controller no longer needs the agent registry --- packages/api/job-controller/README.i18n.yaml | 4 +- packages/api/job-controller/README.md | 18 +- packages/api/job-controller/README.zh.md | 18 +- .../api/job-controller/src/client/index.ts | 36 +- .../api/job-controller/src/client/model.ts | 86 +++-- .../api/job-controller/src/client/service.ts | 172 ++++++--- packages/api/job-controller/src/index.ts | 47 ++- packages/api/job-controller/src/observe.ts | 128 ++----- packages/api/job-controller/src/rows.ts | 54 +++ packages/api/job-controller/src/types.ts | 63 ++-- packages/api/job-controller/src/wake.ts | 60 +++ .../tests/client-apply.client.spec.ts | 28 +- .../api/job-controller/tests/host-harness.ts | 92 +++++ .../job-controller/tests/model.client.spec.ts | 344 ++++++++++++------ .../job-controller/tests/observe.host.spec.ts | 224 +++++------- .../job-controller/tests/rows.host.spec.ts | 89 +++++ .../api/job-controller/tsconfig.host.json | 5 +- 17 files changed, 937 insertions(+), 531 deletions(-) create mode 100644 packages/api/job-controller/src/rows.ts create mode 100644 packages/api/job-controller/src/wake.ts create mode 100644 packages/api/job-controller/tests/host-harness.ts create mode 100644 packages/api/job-controller/tests/rows.host.spec.ts diff --git a/packages/api/job-controller/README.i18n.yaml b/packages/api/job-controller/README.i18n.yaml index 49a565d20f..8ad9d12dc8 100644 --- a/packages/api/job-controller/README.i18n.yaml +++ b/packages/api/job-controller/README.i18n.yaml @@ -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/api/job-controller/README.md -README.md: 6aaab1bff1f13cc21672a039170f811b570230db -README.zh.md: 47e68802346fbc5e41e74171a42afa17289ee156 +README.md: b21327eeea33d84d9218f6869fbe9f9b3d8c6052 +README.zh.md: 32e67646d10f49e2626f0dc2a094e1eb303cc211 diff --git a/packages/api/job-controller/README.md b/packages/api/job-controller/README.md index 6aaab1bff1..b21327eeea 100644 --- a/packages/api/job-controller/README.md +++ b/packages/api/job-controller/README.md @@ -1,5 +1,5 @@ --- -description: "Host and Client job observation: stream one background job's output record to the browser without touching the model's consuming cursor." +description: "Host and Client job streams: the roster one session can see and one job's retained output, mirrored to the browser without touching the model's consuming cursor." kind: "package-reference" --- # Job Controller @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its one Remote stream, `job.observe`, delivers a record-declaring job's retained output from an absolute byte offset; the Client half installs `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views the session-header job list renders. The roster itself is not here: `SessionJob` rows still ride the session control stream owned by [`dsh-api-session-controller`](../session-controller/README.md), and a row's `record` says whether this controller has anything to stream for it. +`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its two Remote streams are projections of `ctx.jobs`: `job.rows` mirrors the jobs one session can see as whole-set frames, and `job.observe` delivers one job's retained output from an absolute byte offset. The Client half installs `ctx.jobs`, the reference-counted service whose rosters and accumulated views the session-header job list renders. Neither stream touches the model's consuming cursor or the completion-notice ledger. ## Table of Contents @@ -22,15 +22,15 @@ English | [中文](README.zh.md) ## Use this package -The Host controller requires the live Agent registry and the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without them. `job.observe({ sessionId?, jobId, from? })` resolves the fenced-read caller from the request's session — an unowned job needs no session — and yields one `opened` anchor, coalesced `output` frames, then one terminal `status` once the job has settled and its record is drained, after which the stream closes normally. Record reads are non-consuming: the model-facing `job_output` cursor and completion-notice state never observe them. A job the session does not own, a job without a record, or an unknown job rejects the stream. Reconnecting callers resume by passing the last frame's `next` as `from`; a `from` below the oldest retained byte gets a `lossy` first frame instead of an error. +The Host controller requires the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without it. `job.rows({ sessionId })` yields the session's visible set — its own jobs plus every unowned job — once on open and again after each coalesced burst of lifecycle commits (registration, progress, stopping, settlement, removal); output appends never refresh the roster, since a settled projection already carries the final byte count. `job.observe({ sessionId?, jobId, from? })` reads through `visibleTo(sessionId)` — an unowned job needs no session — and yields one `opened` anchor carrying the job projection, coalesced `output` frames, then one terminal `status` once the job has settled and its ring is drained, after which the stream closes normally. Both reads are non-consuming: the model's `job_output` cursor and notice state never observe them. -The Client entry provides `ClientJobOutput` (`ctx.jobOutput`) over `ClientJobOutputModel`. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, records the terminal status or a stream failure on the view, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because observation (re)opens run on caller stacks that have not declared `remote.job`. +The Client entry provides `ClientJobs` (`ctx.jobs`) over `ClientJobsModel`. `watchRows(sessionId)` keeps one roster stream open per watched session however many viewers hold it and drops the rows after the last release; a reconnect's first frame is already the whole truth. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, closes the view on the terminal frame or records a stream failure on it, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because stream (re)opens run on caller stacks whose dynamic context has not declared `remote.job`. ### Config | Field | Default | Meaning | |---|---:|---| -| `observeFlushMs` | `100` | Coalescing window after new record output before an observation read, in milliseconds | +| `observeFlushMs` | `100` | Coalescing window after a registry commit before the next rows or output read, in milliseconds | | `observeMaxFrameBytes` | `65,536` | Soft byte budget per observation output frame; one larger chunk ships whole | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-job-controller) is the exhaustive source for accepted fields and their JSDoc. @@ -50,9 +50,9 @@ No direct effect; observation reads never touch model requests. -- The record is a best-effort live preview, not a terminal transcript: producers copy stdout and stderr per poll round, so the two streams' writes inside one poll window reach the record stdout first, and the client concatenates chunks regardless of `channel`. -- The stream is process-local: a Host restart loses every record, and a resumed observation then anchors on an empty registry. -- Per-session fencing is enforced by the registry read; the Remote layer itself serves any connected browser, matching the session control stream's `jobsBySession` broadcast. +- The ring is a best-effort live preview, not a terminal transcript: producers' pull sources are copied per poll round, so two streams' writes inside one poll window land stdout first, and the client concatenates chunks regardless of `channel`. +- Both streams are process-local: a Host restart loses every ring and roster; a resumed observation then anchors on an empty registry, and a re-opened roster starts empty. +- Per-session fencing is enforced by the registry's `visibleTo` read; the Remote layer itself serves any connected browser. ### Dev Note @@ -64,4 +64,4 @@ None. -**Runtime invariant:** No companion is published. The controller is a stateless projection of `ctx.jobs` reads; the registry's own `@deepseek-ai/dsh-jobs/invariant` owns the offset relations this stream forwards. +**Runtime invariant:** No companion is published. The controller is a stateless projection of `ctx.jobs` reads; the registry's own `@deepseek-ai/dsh-jobs/invariant` owns the offset relations these streams forward. diff --git a/packages/api/job-controller/README.zh.md b/packages/api/job-controller/README.zh.md index 47e6880234..32e67646d1 100644 --- a/packages/api/job-controller/README.zh.md +++ b/packages/api/job-controller/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Host 与 Client 的 job 观测:把一个后台任务的输出 record 流式送到浏览器,不触碰模型的消耗型游标。" +description: "Host 与 Client 的 job 流:把一个会话看得到的名册与一个 job 的保留输出镜像到浏览器,不触碰模型的消耗型游标。" kind: "package-reference" --- # Job Controller @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它唯一的 Remote 流 `job.observe` 从绝对字节偏移发送声明了 record 的 job 的保留输出;Client 半侧安装 `ctx.jobOutput`——按 job 引用计数的观测服务,会话头部任务列表渲染其累积视图。名册不在这里:`SessionJob` 行仍随 [`dsh-api-session-controller`](../session-controller/README.zh.md) 拥有的会话控制流到达,行上的 `record` 说明本控制器是否有东西可流。 +`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它的两条 Remote 流都是 `ctx.jobs` 的投影:`job.rows` 以整集帧镜像一个会话看得到的 job;`job.observe` 从绝对字节偏移发送一个 job 的保留输出。Client 半侧安装 `ctx.jobs`——按引用计数的服务,会话头部任务列表渲染其名册与累积视图。两条流都不触碰模型的消耗型游标与完成播报台账。 ## 目录 @@ -22,15 +22,15 @@ kind: "package-reference" ## 使用本包 -Host 控制器要求活体 Agent 注册表与 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少任一则不加载。`job.observe({ sessionId?, jobId, from? })` 由请求的 session 解析围栏读取者——无主 job 不需要 session——先产出一个 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且 record 排干后产出一个终态 `status`,随后流正常关闭。record 读取是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。session 不拥有的 job、没有 record 的 job 或未知 job 会拒绝该流。重连方把上一帧的 `next` 作为 `from` 续读;低于最老保留字节的 `from` 得到带 `lossy` 的首帧而非错误。 +Host 控制器要求 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少则不加载。`job.rows({ sessionId })` 产出该会话可见的集合——自己的 job 加上所有无主 job——打开时一次,之后每一轮聚合过的生命周期提交(注册、进度、停止中、结算、移除)后再一次;输出追加从不刷新名册,因为结算后的投影已经带着最终字节数。`job.observe({ sessionId?, jobId, from? })` 经 `visibleTo(sessionId)` 读取——无主 job 不需要 session——先产出一个携带 job 投影的 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且环排干后产出一个终态 `status`,随后流正常关闭。两种读取都是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。 -Client 入口在 `ClientJobOutputModel` 之上提供 `ClientJobOutput`(`ctx.jobOutput`)。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,把终态或流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为观测的(重)开启跑在未声明 `remote.job` 的调用栈上。 +Client 入口在 `ClientJobsModel` 之上提供 `ClientJobs`(`ctx.jobs`)。`watchRows(sessionId)` 不论多少查看器持有都只为每个被关注的会话开一条名册流,最后一个释放后丢弃行;重连后的首帧已经是完整事实。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,在终态帧上关闭视图或把流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为流的(重)开启跑在未声明 `remote.job` 的调用栈上。 ### 配置 | 字段 | 默认值 | 含义 | |---|---:|---| -| `observeFlushMs` | `100` | 新 record 输出到一次观测读取之间的聚合窗口,毫秒 | +| `observeFlushMs` | `100` | 注册表提交到下一次名册或输出读取之间的聚合窗口,毫秒 | | `observeMaxFrameBytes` | `65,536` | 每个观测输出帧的软字节预算;更大的单块整块发送 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-job-controller)是所有受支持字段及其 JSDoc 的完整来源。 @@ -50,9 +50,9 @@ Client 入口在 `ClientJobOutputModel` 之上提供 `ClientJobOutput`(`ctx.jo -- record 是尽力而为的实时预览,不是终端转录:生产者按轮询轮次复制 stdout 与 stderr,同一轮询窗口内两条流的写入先 stdout 后到达 record,客户端拼接 chunk 时也不区分 `channel`。 -- 流是进程本地的:Host 重启丢失全部 record,续读的观测随后锚定在空注册表上。 -- 按 session 的围栏由注册表读取强制;Remote 层本身服务任何已连接浏览器,与会话控制流对 `jobsBySession` 的广播一致。 +- 环是尽力而为的实时预览,不是终端转录:生产者的拉取源按轮询轮次复制,同一轮询窗口内两条流的写入先 stdout 落地,客户端拼接 chunk 时也不区分 `channel`。 +- 两条流都是进程本地的:Host 重启丢失全部环与名册,续读的观测随后锚定在空注册表上,重开的名册从空开始。 +- 按 session 的围栏由注册表的 `visibleTo` 读取强制;Remote 层本身服务任何已连接浏览器。 ### 开发备注 @@ -64,4 +64,4 @@ Client 入口在 `ClientJobOutputModel` 之上提供 `ClientJobOutput`(`ctx.jo -**运行时不变式:** 不发布伴生入口。控制器是 `ctx.jobs` 读取的无状态投影;本流转发的偏移关系由注册表自己的 `@deepseek-ai/dsh-jobs/invariant` 拥有。 +**运行时不变式:** 不发布伴生入口。控制器是 `ctx.jobs` 读取的无状态投影;这些流转发的偏移关系由注册表自己的 `@deepseek-ai/dsh-jobs/invariant` 拥有。 diff --git a/packages/api/job-controller/src/client/index.ts b/packages/api/job-controller/src/client/index.ts index a76d507332..61c59ce52b 100644 --- a/packages/api/job-controller/src/client/index.ts +++ b/packages/api/job-controller/src/client/index.ts @@ -1,34 +1,36 @@ /** - * Job Controller client half: installs `ctx.jobOutput` over the generated - * `job` Remote namespace. The plugin resolves both Remote faces it drives - * while its own context is current, because observation (re)opens run on - * caller stacks — a React event, a carrier retry — whose dynamic context has - * not declared `remote.job`. + * Job Controller client half: installs `ctx.jobs` over the generated `job` + * Remote namespace. The plugin resolves both Remote faces it drives while its + * own context is current, because stream (re)opens run on caller stacks — a + * React event, a carrier retry — whose dynamic context has not declared + * `remote.job`. * @module @deepseek-ai/dsh-api-job-controller/client */ import type { Context } from '@deepseek-ai/cordis' -import { ClientJobOutputModel } from './model.ts' -import { ClientJobOutput } from './service.ts' -import type { JobObserveRemote } from './service.ts' +import { ClientJobsModel } from './model.ts' +import { ClientJobs } from './service.ts' +import type { JobsRemote } from './service.ts' -export { ClientJobOutputModel } from './model.ts' -export type { JobOutputSnapshot, JobOutputSource, ObservedJob } from './model.ts' -export { ClientJobOutput } from './service.ts' -export type { IJobOutput, JobObserveRemote, JobRemote } from './service.ts' -export type { JobObserveFrame, JobObserveRequest, JobObserveStatus, JobWireChunk } from '../types.ts' +export { ClientJobsModel } from './model.ts' +export type { JobsSnapshot, JobsSource, ObservedJob } from './model.ts' +export { ClientJobs } from './service.ts' +export type { IJobs, JobRemote, JobsRemote } from './service.ts' +export type { + JobChunk, JobObserveFrame, JobObserveRequest, JobRowsFrame, JobRowsRequest, JobView, +} from '../types.ts' /** Required Client Remote services. */ export const inject = ['remote', 'remote.job'] /** - * Install the client job-output service. + * Install the client jobs service. * @param ctx - Client root Context. */ export function apply(ctx: Context): void { - const remotes = ctx.remote as unknown as JobObserveRemote - new ClientJobOutput(ctx, { + const remotes = ctx.remote as unknown as JobsRemote + new ClientJobs(ctx, { $stream: options => remotes.$stream(options), job: remotes.job, - }, new ClientJobOutputModel()) + }, new ClientJobsModel()) } diff --git a/packages/api/job-controller/src/client/model.ts b/packages/api/job-controller/src/client/model.ts index 1c44aa039a..2b58208bb0 100644 --- a/packages/api/job-controller/src/client/model.ts +++ b/packages/api/job-controller/src/client/model.ts @@ -1,13 +1,15 @@ /** - * React-free client job-record observation state: per-job accumulated output - * views fed by `job.observe` frames. Pure data plus subscriptions — transport - * wiring stays in the client service, UI stays in slot components. + * React-free client job state: the roster each watched session can see, fed + * by `job.rows` frames, and per-job accumulated output views fed by + * `job.observe` frames. Pure data plus subscriptions — transport wiring stays + * in the client service, UI stays in slot components. * @module @deepseek-ai/dsh-api-job-controller/client/model */ import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -import type { JobObserveFrame, JobObserveStatus } from '../types.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { JobObserveFrame, JobView } from '../types.ts' /** Bounded per-job render tail, in UTF-16 code units. */ const RENDER_TAIL_LIMIT = 128 * 1024 @@ -19,25 +21,28 @@ export interface ObservedJob { readonly text: string /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ readonly gapBefore: boolean - /** Latest status delivered on the observation stream. */ - readonly status: JobObserveStatus - readonly detail?: string /** True while the observation stream is open and the job has not settled. */ readonly streaming: boolean /** Terminal observation failure, when the stream ended abnormally. */ readonly error?: string } -/** Immutable client job-output state. */ -export interface JobOutputSnapshot { +/** Immutable client job state. */ +export interface JobsSnapshot { + /** + * The jobs each watched session can see, keyed by session id. A session + * nobody watches, or one that sees no job, has no key, so consumers read + * absence rather than a sentinel. + */ + readonly rows: Readonly> /** Live observation state keyed by job id. */ readonly observed: Readonly> } -/** Bare observable source for the client job-output snapshot. */ -export interface JobOutputSource { +/** Bare observable source for the client job snapshot. */ +export interface JobsSource { /** Read the identity-stable current snapshot. */ - getSnapshot(): JobOutputSnapshot + getSnapshot(): JobsSnapshot /** * Subscribe to snapshot changes. * @param listener - invalidation callback. @@ -53,18 +58,21 @@ interface ObservedState { cursor: number | undefined } -/** Owns per-job observation state. */ -export class ClientJobOutputModel implements JobOutputSource { +/** Owns the per-session rosters and per-job observation state. */ +export class ClientJobsModel implements JobsSource { + private readonly rowsBySession = new Map() private readonly observedStates = new Map() private readonly listeners = new Set<() => void>() - private snapshotCache: JobOutputSnapshot = { observed: {} } + private snapshotCache: JobsSnapshot = { rows: {}, observed: {} } private snapshotDirty = false - getSnapshot(): JobOutputSnapshot { + getSnapshot(): JobsSnapshot { if (this.snapshotDirty) { + const rows: Record = {} + for (const [id, jobs] of this.rowsBySession) rows[id] = jobs const observed: Record = {} for (const [id, state] of this.observedStates) observed[id] = state.view - this.snapshotCache = { observed } + this.snapshotCache = { rows, observed } this.snapshotDirty = false } return this.snapshotCache @@ -75,6 +83,31 @@ export class ClientJobOutputModel implements JobOutputSource { return () => { this.listeners.delete(listener) } } + /** + * Replace one session's roster with a `rows` frame's whole set. An empty + * set is stored as an absent key. + * @param sessionId - the watched session. + * @param jobs - the complete visible set. + */ + rowsReplaced(sessionId: SessionId, jobs: readonly JobView[]): void { + const key = String(sessionId) + if (jobs.length === 0) { + if (!this.rowsBySession.delete(key)) return + } else { + this.rowsBySession.set(key, jobs) + } + this.changed() + } + + /** + * Drop one session's roster after its last watcher stops or its stream fails. + * @param sessionId - the no-longer-watched session. + */ + rowsDropped(sessionId: SessionId): void { + if (!this.rowsBySession.delete(String(sessionId))) return + this.changed() + } + /** * The resume offset for one job's next observation generation. * @param id - observed job. @@ -99,9 +132,7 @@ export class ClientJobOutputModel implements JobOutputSource { const view: ObservedJob = { jobId: id, text: existing?.view.text ?? '', - gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead, - status: frame.status, - ...frame.detail !== undefined ? { detail: frame.detail } : {}, + gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.job.output.earliest || freshPastHead, streaming: true, } this.observedStates.set(String(id), { view, cursor: frame.from }) @@ -134,20 +165,15 @@ export class ClientJobOutputModel implements JobOutputSource { } /** - * Record the terminal status delivered at the end of the observation stream. + * Close the live view once the terminal `status` frame arrived: the ring is + * drained and the roster row carries the settled projection. * @param id - observed job. - * @param frame - the terminal `status` frame. */ - observeStatus(id: JobId, frame: Extract): void { + observeSettled(id: JobId): void { const state = this.observedStates.get(String(id)) /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ if (state === undefined) return - state.view = { - ...state.view, - status: frame.status, - ...frame.detail !== undefined ? { detail: frame.detail } : {}, - streaming: false, - } + state.view = { ...state.view, streaming: false } this.changed() } @@ -174,6 +200,6 @@ export class ClientJobOutputModel implements JobOutputSource { private changed(): void { this.snapshotDirty = true - notifySubscribers(this.listeners, 'job output') + notifySubscribers(this.listeners, 'jobs') } } diff --git a/packages/api/job-controller/src/client/service.ts b/packages/api/job-controller/src/client/service.ts index 07f3a00ec2..b339871180 100644 --- a/packages/api/job-controller/src/client/service.ts +++ b/packages/api/job-controller/src/client/service.ts @@ -1,7 +1,9 @@ /** - * The `ctx.jobOutput` client service: reference-counted per-job observation - * streams over `job.observe`, so overlapping viewers of one job share one - * stream and resume from the model's cursor across reconnects. + * The `ctx.jobs` client service: reference-counted streams over the `job` + * namespace — one `job.rows` roster stream per watched session and one + * `job.observe` stream per observed job — so overlapping viewers share a + * stream, rosters resume whole after a reconnect, and observations resume + * from the model's cursor. * @module @deepseek-ai/dsh-api-job-controller/client/service */ @@ -9,11 +11,18 @@ import { Service, type Context } from '@deepseek-ai/cordis' import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { JobObserveFrame, JobObserveRequest } from '../types.ts' -import type { ClientJobOutputModel, JobOutputSource } from './model.ts' +import type { JobObserveFrame, JobObserveRequest, JobRowsFrame, JobRowsRequest } from '../types.ts' +import type { ClientJobsModel, JobsSource } from './model.ts' -/** The generated `job` namespace face the observation runner drives. */ +/** The generated `job` namespace face the stream runners drive. */ export interface JobRemote { + /** + * Open one roster generation. + * @param request - the session whose visible set to mirror. + * @param signal - generation cancellation. + * @returns the whole-set frame sequence of one generation. + */ + rows(request: JobRowsRequest, signal?: AbortSignal): AsyncIterable /** * Open one observation generation. * @param request - target job, owning session, and optional resume offset. @@ -23,18 +32,25 @@ export interface JobRemote { observe(request: JobObserveRequest, signal?: AbortSignal): AsyncIterable } -/** Remote faces the observation runner drives: the Gateway stream factory and the `job` namespace. */ -export interface JobObserveRemote { +/** Remote faces the runners drive: the Gateway stream factory and the `job` namespace. */ +export interface JobsRemote { readonly $stream: ClientRemote['$stream'] readonly job: JobRemote } -/** The job-output client service face. */ -export interface IJobOutput { - /** Per-job observation state. */ - readonly state: JobOutputSource +/** The client jobs service face. */ +export interface IJobs { + /** Rosters and per-job observation state. */ + readonly state: JobsSource /** - * Start observing one job's live record; reference-counted, so two viewers + * Keep one session's roster current; reference-counted, so two watchers of + * the same session share one stream and the rows leave with the last. + * @param sessionId - the session whose visible jobs to mirror. + * @returns stop function releasing this watcher's reference. + */ + watchRows(sessionId: SessionId): () => void + /** + * Start observing one job's live output; reference-counted, so two viewers * of the same job share one stream. * @param sessionId - owning session used for the fenced read; undefined for an unowned job. * @param id - job to observe. @@ -43,8 +59,8 @@ export interface IJobOutput { observe(sessionId: SessionId | undefined, id: JobId): () => void } -/** One reference-counted observation stream. */ -interface ObservationEntry { +/** One reference-counted stream. */ +interface StreamEntry { refs: number stopped: boolean dispose: () => Promise @@ -52,79 +68,139 @@ interface ObservationEntry { declare module '@deepseek-ai/cordis' { interface Context { - /** React-free client job-record observation control. */ - jobOutput: IJobOutput + /** React-free client job rosters and observation control. */ + jobs: IJobs } } -/** Owns the bare job-output snapshot and per-job observation streams. */ -export class ClientJobOutput extends Service implements IJobOutput { - readonly state: JobOutputSource - private readonly entries = new Map() +/** Owns the bare jobs snapshot and the per-session and per-job streams. */ +export class ClientJobs extends Service implements IJobs { + readonly state: JobsSource + private readonly rowsEntries = new Map() + private readonly observations = new Map() /** * @param ctx - client root Context. * @param remote - the Gateway stream factory plus the generated `job` namespace, both resolved by the caller. - * @param model - shared client job-output model. + * @param model - shared client jobs model. */ constructor( ctx: Context, - private readonly remote: JobObserveRemote, - private readonly model: ClientJobOutputModel, + private readonly remote: JobsRemote, + private readonly model: ClientJobsModel, ) { - super(ctx, 'jobOutput') + super(ctx, 'jobs') this.state = model ctx.effect(() => async () => { - const open = [...this.entries.values()] - this.entries.clear() + const open = [...this.rowsEntries.values(), ...this.observations.values()] + this.rowsEntries.clear() + this.observations.clear() for (const entry of open) entry.stopped = true // Cordis awaits an async disposer, so the fiber stays unloading until // every carrier iterator has closed and a successor plugin instance // cannot overlap one. A carrier whose teardown fails is stopped all the // same; its failure has no consumer here. await Promise.allSettled(open.map(entry => entry.dispose())) - }, 'job-controller.client.observations') + }, 'job-controller.client.streams') + } + + watchRows(sessionId: SessionId): () => void { + return this.acquire( + this.rowsEntries, + String(sessionId), + () => this.startRows(sessionId), + () => { this.model.rowsDropped(sessionId) }, + ) } observe(sessionId: SessionId | undefined, id: JobId): () => void { - const key = String(id) - const existing = this.entries.get(key) + return this.acquire( + this.observations, + String(id), + () => this.startObservation(sessionId, id), + () => { this.model.observeStopped(id) }, + ) + } + + /** Share the live entry under `key` or start one, and hand back its release. */ + private acquire( + entries: Map, + key: string, + start: () => StreamEntry, + cleared: () => void, + ): () => void { + const existing = entries.get(key) if (existing !== undefined && !existing.stopped) { existing.refs += 1 - return this.releaser(key, existing) + return this.releaser(entries, key, existing, cleared) } - const entry = this.startObservation(sessionId, id) - this.entries.set(key, entry) - return this.releaser(key, entry) + const entry = start() + entries.set(key, entry) + return this.releaser(entries, key, entry, cleared) } /** * Release closures bind the exact entry they were minted for, never the - * map's current occupant: a later `observe()` on the same id may have - * replaced a stopped entry, and decrementing or disposing through the key - * alone would tear down that newer stream's references. + * map's current occupant: a later acquire on the same key may have replaced + * a stopped entry, and decrementing or disposing through the key alone + * would tear down that newer stream's references. */ - private releaser(key: string, entry: ObservationEntry): () => void { + private releaser(entries: Map, key: string, entry: StreamEntry, cleared: () => void): () => void { let released = false return () => { if (released) return released = true entry.refs -= 1 if (entry.refs > 0) return - if (this.entries.get(key) === entry) this.entries.delete(key) + if (entries.get(key) === entry) entries.delete(key) entry.stopped = true void entry.dispose().then(() => { - // Clear the view only while no successor observation holds the key: - // a re-expand inside the dispose round-trip already re-anchored the - // model, and a stale clear would blank its panel for good. - if (this.entries.has(key)) return - // The key is the stringified branded id this releaser was minted for. - this.model.observeStopped(key as JobId) + // Clear the state only while no successor holds the key: a re-acquire + // inside the dispose round-trip already refilled the model, and a + // stale clear would blank it for good. + if (entries.has(key)) return + cleared() }) } } - private startObservation(sessionId: SessionId | undefined, id: JobId): ObservationEntry { + private startRows(sessionId: SessionId): StreamEntry { + const name = `job rows ${String(sessionId)}` + const stream = this.remote.$stream({ + name, + open: signal => this.remote.job.rows({ sessionId }, signal), + // The roster has no natural end while it is watched: an end after the + // first frame is a carrier interruption (a Host reload closes the + // generation) and the next generation's whole set loses nothing. An end + // before the first frame is terminal. + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${name} ended before release`) + : new Error(`${name} ended before its first frame`), + }) + const entry: StreamEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + } + void (async () => { + try { + for await (const item of stream) { + this.model.rowsReplaced(sessionId, item.value.jobs) + item.accept() + } + } catch { + // A terminal stream failure leaves nothing current to show; the model + // drops the roster rather than keeping a stale set on screen. + if (!entry.stopped) this.model.rowsDropped(sessionId) + } finally { + entry.stopped = true + void entry.dispose() + } + })() + return entry + } + + private startObservation(sessionId: SessionId | undefined, id: JobId): StreamEntry { const name = `job observation ${String(id)}` const stream = this.remote.$stream({ name, @@ -146,7 +222,7 @@ export class ClientJobOutput extends Service implements IJobOutput { ? new RemoteStreamCarrierError(`${name} ended before settlement`) : new Error(`${name} ended before its anchor`), }) - const entry: ObservationEntry = { + const entry: StreamEntry = { refs: 1, stopped: false, dispose: () => stream.dispose(), @@ -166,7 +242,7 @@ export class ClientJobOutput extends Service implements IJobOutput { } // Terminal status: leave the loop before the generation end is // classified, then close the stream for good. - this.model.observeStatus(id, frame) + this.model.observeSettled(id) break } } catch (error) { diff --git a/packages/api/job-controller/src/index.ts b/packages/api/job-controller/src/index.ts index 27e85a0ed0..431c7ce1d2 100644 --- a/packages/api/job-controller/src/index.ts +++ b/packages/api/job-controller/src/index.ts @@ -1,18 +1,18 @@ /** - * Host job Remote owner: streams one background job's observation record to - * browsers over the generated `job` namespace. The roster itself rides the - * session control stream (`jobsBySession`, owned by `dsh-api-session-controller`); - * this controller carries only the non-consuming record output. + * Host job Remote owner: streams the background-job roster one session can + * see and one job's retained output to browsers over the generated `job` + * namespace. Both streams are projections of `ctx.jobs`; the model's + * consuming cursor and notice state never observe them. * @module @deepseek-ai/dsh-api-job-controller */ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type {} from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-jobs' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' -import { observeJobRecord } from './observe.ts' -import type { JobObserveFrame, JobObserveRequest } from './types.ts' +import { observeJobOutput } from './observe.ts' +import { streamJobRows } from './rows.ts' +import type { JobObserveFrame, JobObserveRequest, JobRowsFrame, JobRowsRequest } from './types.ts' export type * from './types.ts' @@ -23,7 +23,7 @@ declare module '@deepseek-ai/cordis' { } } -/** Default coalescing window between record reads, in milliseconds. */ +/** Default coalescing window between reads, in milliseconds. */ const DEFAULT_OBSERVE_FLUSH_MS = 100 /** Default soft byte budget per output frame. */ @@ -31,7 +31,7 @@ const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024 /** Job Controller deployment policy. */ export interface Config { - /** Coalescing window after new record output before an observation read, in milliseconds (default 100). */ + /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */ readonly observeFlushMs?: number /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ readonly observeMaxFrameBytes?: number @@ -39,7 +39,7 @@ export interface Config { /** Host service backing the generated `ctx.remote.job` namespace. */ export class JobController extends TypertRemoteService { - static inject = ['agents', 'jobs', 'typert'] + static inject = ['jobs', 'typert'] static Config: z = z.object({ observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS), @@ -50,7 +50,7 @@ export class JobController extends TypertRemoteService { private readonly observeMaxFrameBytes: number /** - * @param ctx - Host context carrying the live Agent registry and the job registry. + * @param ctx - Host context carrying the job registry. * @param config - observation cadence and framing policy. */ constructor(ctx: Context, config: Config) { @@ -63,22 +63,33 @@ export class JobController extends TypertRemoteService { } /** - * Stream one job's retained record output from an absolute byte offset, - * then its terminal status once settled and drained. Non-consuming: the + * Stream the jobs one session can see — its own plus every unowned job — + * as whole-set frames: one on open, then one after each coalesced burst of + * lifecycle commits. The stream has no natural end; the carrier closes it. + * @param request - the session whose visible set to mirror. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns the roster frames. + */ + @Remote({ mode: 'stream' }) + rows(request: JobRowsRequest, signal: AbortSignal): AsyncIterable { + return streamJobRows(this.ctx.jobs, request, { flushMs: this.observeFlushMs }, signal) + } + + /** + * Stream one job's retained output from an absolute byte offset, then its + * terminal projection once settled and drained. Non-consuming: the * model-facing cursor and notice state never observe these reads. The - * request's session resolves the fenced-read caller; the registry rejects a - * job the session does not own, a job without a record, or an unknown job. + * request's session is the fenced read's caller; the registry rejects a + * job the session cannot see and an unknown job. * @param request - target job, owning session, and optional resume offset. * @param signal - cancellation owned by the Remote stream carrier. * @returns anchor, coalesced output frames, and the terminal status. */ @Remote({ mode: 'stream' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable { - const caller = request.sessionId === undefined ? undefined : this.ctx.agents.get(request.sessionId) - return observeJobRecord(this.ctx.jobs, request, { + return observeJobOutput(this.ctx.jobs, request, { flushMs: this.observeFlushMs, maxFrameBytes: this.observeMaxFrameBytes, - caller, }, signal) } } diff --git a/packages/api/job-controller/src/observe.ts b/packages/api/job-controller/src/observe.ts index 731f40b2e2..8590a704f7 100644 --- a/packages/api/job-controller/src/observe.ts +++ b/packages/api/job-controller/src/observe.ts @@ -1,9 +1,9 @@ -/** Per-job record observation generations: anchor, coalesced output, terminal status. */ +/** Per-job observation generations: anchor, coalesced output, terminal status. */ -import type { Agent } from '@deepseek-ai/dsh-agent' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -import type { JobRegistry, JobSnapshot } from '@deepseek-ai/dsh-jobs' -import type { JobObserveFrame, JobObserveRequest, JobWireChunk } from './types.ts' +import type { JobChunk, JobRegistry, JobStatus } from '@deepseek-ai/dsh-jobs' +import type { JobObserveFrame, JobObserveRequest } from './types.ts' +import { OutputWaiter, sleep } from './wake.ts' /** Cadence and framing bounds for one observation generation. */ export interface ObserveJobOptions { @@ -15,81 +15,28 @@ export interface ObserveJobOptions { * registry's retention cap is the hard bound on any single chunk). */ readonly maxFrameBytes: number - /** The live owning agent for fenced reads, resolved from the request's session. */ - readonly caller: Agent | undefined } -function isTerminal(status: JobSnapshot['status']): boolean { +function isTerminal(status: JobStatus): boolean { return status !== 'running' && status !== 'stopping' } -/** A record job's total offset; the anchor check proved the declaration and it never leaves. */ -function recordTotal(snapshot: JobSnapshot): number { - /* v8 ignore next -- a declared record never loses its offsets. */ - return snapshot.outputTotal ?? 0 -} - -/** Wake-flag waiter: a wake between waits is never lost. */ -class OutputWaiter { - private dirty = false - private resolve: (() => void) | undefined - - wake(): void { - this.dirty = true - this.resolve?.() - } - - /** - * Resolve on the next wake, immediately when one already arrived, or on abort. - * @param signal - generation cancellation. - */ - wait(signal: AbortSignal): Promise { - if (this.dirty || signal.aborted) { - this.dirty = false - return Promise.resolve() - } - return new Promise((resolve) => { - const finish = (): void => { - signal.removeEventListener('abort', finish) - /* v8 ignore next -- one wait owns the sole installed resolver. */ - if (this.resolve === finish) this.resolve = undefined - this.dirty = false - resolve() - } - this.resolve = finish - signal.addEventListener('abort', finish, { once: true }) - }) - } -} - -function sleep(ms: number, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.resolve() - return new Promise((resolve) => { - const timer = setTimeout(done, ms) - function done(): void { - clearTimeout(timer) - signal.removeEventListener('abort', done) - resolve() - } - signal.addEventListener('abort', done, { once: true }) - }) -} - /** - * Stream one job's retained record output from an absolute offset: one - * `opened` anchor, coalesced `output` frames as the record advances, then one - * terminal `status` after the settled job is drained, after which the - * generation closes normally. Reads are non-consuming — the model-facing - * cursor and notice state never observe them; reconnecting callers resume by - * passing the last frame's `next` as `from`. Throws for a job that declared - * no record. + * Stream one job's retained output from an absolute offset: one `opened` + * anchor, coalesced `output` frames as the ring advances, then one terminal + * `status` after the settled job is drained, after which the generation + * closes normally. Reads are non-consuming — the model-facing cursor and + * notice state never observe them; reconnecting callers resume by passing + * the last frame's `next` as `from`. The request's session is the fenced + * read's caller: the registry rejects a job the session cannot see and an + * unknown job. * @param registry - the live job registry. - * @param request - target job and optional resume offset. - * @param options - cadence, framing bounds, and the fenced-read caller. + * @param request - target job, owning session, and optional resume offset. + * @param options - cadence and framing bounds. * @param signal - generation cancellation owned by the Remote stream carrier. * @returns the observation frame sequence for one generation. */ -export async function* observeJobRecord( +export async function* observeJobOutput( registry: JobRegistry, request: JobObserveRequest, options: ObserveJobOptions, @@ -105,41 +52,24 @@ export async function* observeJobRecord( const waiter = new OutputWaiter() // Subscribe before the first read so an append between the anchor read and // the wait cannot be missed. - const unsubscribe = registry.onOutput((changed) => { + const unsubscribe = registry.events.subscribe({ owners: 'all' }, (event) => { + const changed = event.type === 'output' ? event.id : event.job.id if (changed === id) waiter.wake() }) try { - const caller = options.caller - let snapshot = registry.get(id, caller) - if (snapshot.outputTotal === undefined || snapshot.outputEarliest === undefined) { - throw new Error(`job ${id} declared no output record`) - } - let cursor = request.from ?? snapshot.outputEarliest - yield { - type: 'opened', - jobId: id, - from: cursor, - earliest: snapshot.outputEarliest, - total: snapshot.outputTotal, - status: snapshot.status, - ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, - } + const visible = registry.visibleTo(request.sessionId) + let job = visible.get(id) + let cursor = request.from ?? job.output.earliest + yield { type: 'opened', job, from: cursor } while (!signal.aborted) { - const read = registry.readRecord(id, cursor, caller) - if (read.chunks.length > 0) { + const read = visible.readAt(id, cursor) + if (read.chunks.length > 0 || read.lossy) { yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes) } cursor = read.next - snapshot = registry.get(id, caller) - if (isTerminal(snapshot.status) && cursor >= recordTotal(snapshot)) { - yield { - type: 'status', - status: snapshot.status, - ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, - // Settlement always stamps finishedAt; the guard only discharges the optional field type. - /* v8 ignore next */ - ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, - } + job = visible.get(id) + if (isTerminal(job.status) && cursor >= job.output.total) { + yield { type: 'status', job } return } await waiter.wait(signal) @@ -153,12 +83,12 @@ export async function* observeJobRecord( /** Split one read into frames along the soft per-frame byte budget. */ function* outputFrames( - chunks: readonly JobWireChunk[], + chunks: readonly JobChunk[], next: number, lossy: boolean, maxFrameBytes: number, ): Iterable { - let batch: JobWireChunk[] = [] + let batch: JobChunk[] = [] let batchBytes = 0 let flaggedLossy = lossy for (const chunk of chunks) { diff --git a/packages/api/job-controller/src/rows.ts b/packages/api/job-controller/src/rows.ts new file mode 100644 index 0000000000..9076f12424 --- /dev/null +++ b/packages/api/job-controller/src/rows.ts @@ -0,0 +1,54 @@ +/** Per-session roster generations: the caller-visible job set, replaced whole after every lifecycle change. */ + +import type { JobRegistry } from '@deepseek-ai/dsh-jobs' +import type { JobRowsFrame, JobRowsRequest } from './types.ts' +import { OutputWaiter, sleep } from './wake.ts' + +/** Cadence bound for one roster generation. */ +export interface RowsOptions { + /** Coalescing window after a lifecycle event before the next rows read, in milliseconds. */ + readonly flushMs: number +} + +/** + * Stream the jobs one session can see: one frame on open, then one after + * every lifecycle commit that touches a visible job (registration, progress, + * stopping, settlement, removal), coalesced over `flushMs`. Output appends + * never refresh the roster — a settled projection already carries the final + * byte count — so the stream is quiet while a job merely writes. Reads are + * projections; the model's cursor and notice state never observe them. + * @param registry - the live job registry. + * @param request - the session whose visible set to mirror. + * @param options - the coalescing window. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the roster frame sequence for one generation. + */ +export async function* streamJobRows( + registry: JobRegistry, + request: JobRowsRequest, + options: RowsOptions, + signal: AbortSignal, +): AsyncIterable { + signal.throwIfAborted() + const waiter = new OutputWaiter() + // Subscribe before the first read so a commit between the read and the + // wait cannot be missed. + const unsubscribe = registry.events.subscribe({ owners: 'all' }, (event) => { + if (event.type === 'output') return + const owner = event.job.owner + if (owner === undefined || owner === request.sessionId) waiter.wake() + }) + try { + const visible = registry.visibleTo(request.sessionId) + yield { type: 'rows', jobs: visible.list() } + while (true) { + await waiter.wait(signal) + // Let a burst of commits settle into one frame. + await sleep(options.flushMs, signal) + if (signal.aborted) return + yield { type: 'rows', jobs: visible.list() } + } + } finally { + unsubscribe() + } +} diff --git a/packages/api/job-controller/src/types.ts b/packages/api/job-controller/src/types.ts index 98df4d8f65..0afa3ba1e9 100644 --- a/packages/api/job-controller/src/types.ts +++ b/packages/api/job-controller/src/types.ts @@ -1,24 +1,35 @@ /** - * Wire types of the generated `job` Remote namespace: the observation request - * and the frames its stream yields. Client-safe: no Host imports. + * Wire types of the generated `job` Remote namespace: the per-session roster + * stream and the per-job observation stream. Client-safe: no Host imports. * @module @deepseek-ai/dsh-api-job-controller/types */ import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { JobChunk, JobView } from '@deepseek-ai/dsh-jobs/view' import type { SessionId } from '@deepseek-ai/dsh-session/types' +export type { JobChunk, JobView } from '@deepseek-ai/dsh-jobs/view' + +/** Target of one `job.rows` stream: the session whose visible jobs the stream mirrors. */ +export interface JobRowsRequest { + readonly sessionId: SessionId +} + /** - * A job's lifecycle state on the wire. Spelled out rather than imported from - * the registry package so this client-safe face pulls no Host declaration - * merges into browser programs; it is the registry's `JobStatus` union. + * One `job.rows` frame: the complete set the session can see — its own jobs + * plus every unowned job — after a lifecycle change. Whole-set replacement, + * so a reconnect's first frame is already the truth. */ -export type JobObserveStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed' +export interface JobRowsFrame { + readonly type: 'rows' + readonly jobs: readonly JobView[] +} /** Target of one `job.observe` stream: the job, its owning session, and an optional resume offset. */ export interface JobObserveRequest { /** - * Owning session, resolved to the live agent for the fenced record read. - * Omitted for an unowned job, which any caller may observe. + * Owning session for the fenced read. Omitted for an unowned job, which any + * caller may observe. */ readonly sessionId?: SessionId readonly jobId: JobId @@ -29,39 +40,25 @@ export interface JobObserveRequest { readonly from?: number } -/** One retained record chunk on the wire. `channel` widens to string like a job's `kind`. */ -export interface JobWireChunk { - /** Absolute offset of the chunk's first byte. */ - readonly at: number - readonly text: string - readonly channel?: string - /** Bytes immediately before this chunk were lost at the producer or to retention. */ - readonly gapBefore?: true -} - /** - * Job observation stream frames: one `opened` anchor, then coalesced `output` - * batches, then — once the job has settled and its retained record is - * drained — one terminal `status`, after which the stream closes normally. - * Status rides the same stream as output so settlement can never race a - * still-open output channel. + * Job observation stream frames: one `opened` anchor carrying the job's + * projection (its `output.earliest` and `output.total` at open time) and the + * offset the first `output` frame continues from, then coalesced `output` + * batches, then — once the job has settled and its ring is drained — one + * terminal `status` carrying the settled projection, after which the stream + * closes normally. Status rides the same stream as output so settlement can + * never race a still-open output channel. */ export type JobObserveFrame = | { readonly type: 'opened' - readonly jobId: JobId + readonly job: JobView /** Offset the first `output` frame continues from. */ readonly from: number - /** Oldest retained byte at open time; `from < earliest` means the head is gone. */ - readonly earliest: number - /** Total bytes produced at open time. */ - readonly total: number - readonly status: JobObserveStatus - readonly detail?: string } | { readonly type: 'output' - readonly chunks: readonly JobWireChunk[] + readonly chunks: readonly JobChunk[] /** Offset to resume from after this frame. */ readonly next: number /** Bytes between the requested offset and `chunks` were already evicted. */ @@ -69,7 +66,5 @@ export type JobObserveFrame = } | { readonly type: 'status' - readonly status: JobObserveStatus - readonly detail?: string - readonly finishedAt?: number + readonly job: JobView } diff --git a/packages/api/job-controller/src/wake.ts b/packages/api/job-controller/src/wake.ts new file mode 100644 index 0000000000..489699cb3c --- /dev/null +++ b/packages/api/job-controller/src/wake.ts @@ -0,0 +1,60 @@ +/** + * Wake bookkeeping shared by the two observation generators: a wake-flag + * waiter that never loses a wake between waits, and an abortable sleep that + * coalesces bursts into bounded frames. + * @module @deepseek-ai/dsh-api-job-controller/wake + */ + +/** Wake-flag waiter: a wake between waits is never lost. */ +export class OutputWaiter { + private dirty = false + private resolve: (() => void) | undefined + + /** Record one wake; releases a pending wait or arms the next one. */ + wake(): void { + this.dirty = true + this.resolve?.() + } + + /** + * Resolve on the next wake, immediately when one already arrived, or on abort. + * @param signal - generation cancellation. + * @returns settles when woken or aborted. + */ + wait(signal: AbortSignal): Promise { + if (this.dirty || signal.aborted) { + this.dirty = false + return Promise.resolve() + } + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + /* v8 ignore next -- one wait owns the sole installed resolver. */ + if (this.resolve === finish) this.resolve = undefined + this.dirty = false + resolve() + } + this.resolve = finish + signal.addEventListener('abort', finish, { once: true }) + }) + } +} + +/** + * Sleep for the coalescing window, or return at once when aborted. + * @param ms - window in milliseconds. + * @param signal - generation cancellation. + * @returns settles after the window or on abort. + */ +export function sleep(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + const timer = setTimeout(done, ms) + function done(): void { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + signal.addEventListener('abort', done, { once: true }) + }) +} diff --git a/packages/api/job-controller/tests/client-apply.client.spec.ts b/packages/api/job-controller/tests/client-apply.client.spec.ts index ebbe67e98e..37fb4c3048 100644 --- a/packages/api/job-controller/tests/client-apply.client.spec.ts +++ b/packages/api/job-controller/tests/client-apply.client.spec.ts @@ -4,7 +4,7 @@ import { RemoteStream, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gat import type { SessionId } from '@deepseek-ai/dsh-session/types' import { afterEach, describe, expect, it } from 'vitest' import * as JobClient from '../src/client/index.ts' -import type { JobObserveFrame } from '../src/types.ts' +import type { JobObserveFrame, JobRowsFrame } from '../src/types.ts' const contexts = new Set() @@ -14,10 +14,11 @@ afterEach(async () => { }) /** Mount the client half over a captured `remote.job` namespace and a real Gateway stream carrier. */ -async function mount(): Promise<{ ctx: Context; observeCalls: unknown[] }> { +async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCalls: unknown[] }> { const ctx = new Context() contexts.add(ctx) const observeCalls: unknown[] = [] + const rowsCalls: unknown[] = [] const connection: ConnectionHandle = { isLoopback: true, generation: { getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {} }, @@ -28,7 +29,11 @@ async function mount(): Promise<{ ctx: Context; observeCalls: unknown[] }> { start: () => ({ stop: () => {} }), } const job = { - // The fake stream never yields; opening and releasing must leave no view behind. + // The fake streams never yield; opening and releasing must leave no state behind. + rows: (request: unknown) => { + rowsCalls.push(request) + return (async function* (): AsyncGenerator {})() + }, observe: (request: unknown) => { observeCalls.push(request) return (async function* (): AsyncGenerator {})() @@ -40,7 +45,7 @@ async function mount(): Promise<{ ctx: Context; observeCalls: unknown[] }> { }) ctx.reflect.provide('remote.job', job) await ctx.plugin(JobClient) - return { ctx, observeCalls } + return { ctx, observeCalls, rowsCalls } } async function flush(): Promise { @@ -52,14 +57,17 @@ describe('Job Controller Client apply', () => { expect(JobClient.inject).toEqual(['remote', 'remote.job']) }) - it('installs ctx.jobOutput over the captured job namespace', async () => { - const { ctx, observeCalls } = await mount() - expect(ctx.jobOutput).toBeDefined() - const stop = ctx.jobOutput.observe('session-1' as SessionId, 'bash-1' as never) + it('installs ctx.jobs over the captured job namespace', async () => { + const { ctx, observeCalls, rowsCalls } = await mount() + expect(ctx.jobs).toBeDefined() + const stopRows = ctx.jobs.watchRows('session-1' as SessionId) + const stopObserve = ctx.jobs.observe('session-1' as SessionId, 'bash-1' as never) await flush() + expect(rowsCalls).toEqual([{ sessionId: 'session-1' }]) expect(observeCalls).toEqual([{ sessionId: 'session-1', jobId: 'bash-1' }]) - stop() + stopRows() + stopObserve() await flush() - expect(ctx.jobOutput.state.getSnapshot().observed).toEqual({}) + expect(ctx.jobs.state.getSnapshot()).toEqual({ rows: {}, observed: {} }) }) }) diff --git a/packages/api/job-controller/tests/host-harness.ts b/packages/api/job-controller/tests/host-harness.ts new file mode 100644 index 0000000000..0ffb1fd2b8 --- /dev/null +++ b/packages/api/job-controller/tests/host-harness.ts @@ -0,0 +1,92 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { JobAppendOptions, JobHandle, JobOutcome } from '@deepseek-ai/dsh-jobs' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import { Session, SessionId } from '@deepseek-ai/dsh-session' + +/** A registry with a tiny ring so eviction is cheap to reach. */ +export async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry, { retainBytes: 64, settledRetainBytes: 64 }) + ctx.jobs.attachController('job-controller-test') + return ctx +} + +/** Register a live agent for `rawId` under its own lifecycle scope. */ +export function registerAgent(ctx: Context, rawId: string): Agent & { disposeScope: () => Promise } { + const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(rawId) + const session = Session.create(id) + const agent = { + id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scopeFiber.ctx, + disposeScope: () => scopeFiber.dispose(), + } as unknown as Agent & { disposeScope: () => Promise } + ctx.agents.register(agent) + return agent +} + +/** + * Start one push-producer job whose `done` settles on demand; expose the + * handle's writers. A cancel settles the job as killed unless `slowStop` + * keeps it stopping until the test settles it. + */ +export function startJob(ctx: Context, options: { label?: string; owner?: SessionId; slowStop?: boolean } = {}) { + let settle!: (outcome: JobOutcome) => void + let handle!: JobHandle + const id = ctx.jobs.start({ + kind: 'bash', + label: options.label ?? 'echo', + ...options.owner !== undefined ? { owner: options.owner } : {}, + run: (job) => { + handle = job + return { + cancel() { if (options.slowStop !== true) settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + } + }, + }) + return { + id, + append: (text: string, opts?: JobAppendOptions) => { handle.append(text, opts) }, + progress: (line: string) => { handle.updateProgress(line) }, + settle: async (outcome: JobOutcome) => { + settle(outcome) + await new Promise(resolve => setTimeout(resolve, 0)) + }, + } +} + +/** Read frames until `count` arrive or the generator finishes, then abort. */ +export async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const frames: Frame[] = [] + const iterator = iterable[Symbol.asyncIterator]() + try { + while (frames.length < count) { + const step = await iterator.next() + if (step.done) return frames + frames.push(step.value) + } + } finally { + abort.abort() + await iterator.next().catch(() => undefined) + } + return frames +} + +export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +/** The next yielded frame, or undefined once the generator finished. */ +export async function next(iterator: AsyncIterator): Promise { + const step = await iterator.next() + return step.done === true ? undefined : step.value +} diff --git a/packages/api/job-controller/tests/model.client.spec.ts b/packages/api/job-controller/tests/model.client.spec.ts index 5e49771318..a170f1b98f 100644 --- a/packages/api/job-controller/tests/model.client.spec.ts +++ b/packages/api/job-controller/tests/model.client.spec.ts @@ -2,44 +2,75 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { ClientJobOutputModel } from '../src/client/model.ts' -import { ClientJobOutput } from '../src/client/service.ts' -import type { JobObserveFrame } from '../src/types.ts' +import { ClientJobsModel } from '../src/client/model.ts' +import { ClientJobs } from '../src/client/service.ts' +import type { JobObserveFrame, JobRowsFrame, JobView } from '../src/types.ts' const ID = 'bash-1' as JobId +const S1 = 'alice' as SessionId +const S2 = 'bob' as SessionId -function opened(model: ClientJobOutputModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void { +function view(over: Partial = {}): JobView { + return { + id: ID, kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, + output: { total: 0, earliest: 0 }, + ...over, + } +} + +function opened(model: ClientJobsModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void { model.observeOpened(ID, { type: 'opened', - jobId: ID, - from: 0, - earliest: 0, - total: 0, - status: 'running', - ...over, + job: view({ output: { total: over.total ?? 0, earliest: over.earliest ?? 0 } }), + from: over.from ?? 0, }) } -describe('ClientJobOutputModel observation', () => { - it('accumulates output, advances the resume cursor, and settles on status', () => { - const model = new ClientJobOutputModel() +describe('ClientJobsModel rows', () => { + it('replaces a session roster whole, stores an empty set as absence, and drops on release', () => { + const model = new ClientJobsModel() + let notified = 0 + model.subscribe(() => { notified += 1 }) + model.rowsReplaced(S1, [view()]) + model.rowsReplaced(S2, [view({ id: 'pwsh-1' as JobId, label: 'other' })]) + expect(model.getSnapshot().rows[S1]).toEqual([view()]) + expect(model.getSnapshot().rows[S2]?.[0]?.label).toBe('other') + + // Last-wins: the newer whole set replaces, it does not merge. + model.rowsReplaced(S1, [view({ status: 'completed' })]) + expect(model.getSnapshot().rows[S1]).toEqual([view({ status: 'completed' })]) + expect(notified).toBe(3) + + model.rowsReplaced(S1, []) + expect(S1 in model.getSnapshot().rows).toBe(false) + // An empty set over an absent key changes nothing. + model.rowsReplaced(S1, []) + expect(notified).toBe(4) + + model.rowsDropped(S2) + expect(model.getSnapshot().rows).toEqual({}) + model.rowsDropped(S2) + expect(notified).toBe(5) + }) +}) + +describe('ClientJobsModel observation', () => { + it('accumulates output, advances the resume cursor, and closes on settlement', () => { + const model = new ClientJobsModel() opened(model) expect(model.cursorOf(ID)).toBe(0) model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'a' }, { at: 1, text: 'b' }], next: 2 }) model.observeOutput(ID, { type: 'output', chunks: [{ at: 2, text: 'c' }], next: 3 }) expect(model.cursorOf(ID)).toBe(3) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.text).toBe('abc') - expect(view?.streaming).toBe(true) - model.observeStatus(ID, { type: 'status', status: 'completed', detail: 'exit code: 0' }) - const settled = model.getSnapshot().observed[String(ID)] - expect(settled?.streaming).toBe(false) - expect(settled?.status).toBe('completed') - expect(settled?.detail).toBe('exit code: 0') + const live = model.getSnapshot().observed[String(ID)] + expect(live?.text).toBe('abc') + expect(live?.streaming).toBe(true) + model.observeSettled(ID) + expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) }) it('keeps snapshot identity stable between changes and notifies subscribers', () => { - const model = new ClientJobOutputModel() + const model = new ClientJobsModel() let notified = 0 const unsubscribe = model.subscribe(() => { notified += 1 }) const before = model.getSnapshot() @@ -53,16 +84,16 @@ describe('ClientJobOutputModel observation', () => { }) it('marks gaps from lossy frames, gap chunks, and a resume behind the retained head', () => { - const model = new ClientJobOutputModel() + const model = new ClientJobsModel() opened(model, { from: 4, earliest: 8, total: 10 }) expect(model.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - const clean = new ClientJobOutputModel() + const clean = new ClientJobsModel() opened(clean) clean.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'x', gapBefore: true }], next: 1 }) expect(clean.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - const lossy = new ClientJobOutputModel() + const lossy = new ClientJobsModel() opened(lossy) lossy.observeOutput(ID, { type: 'output', chunks: [], next: 5, lossy: true }) expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) @@ -71,12 +102,12 @@ describe('ClientJobOutputModel observation', () => { it('marks a fresh observation anchored past the evicted head', () => { // Fresh observations anchor at the registry's earliest retained byte, so // from === earliest > 0 means the head was already discarded. - const fresh = new ClientJobOutputModel() + const fresh = new ClientJobsModel() opened(fresh, { from: 60_240, earliest: 60_240, total: 321_328 }) expect(fresh.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) // A retry that accumulated no text yet earns the mark the same way. - const retried = new ClientJobOutputModel() + const retried = new ClientJobsModel() opened(retried) expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(false) opened(retried, { from: 6, earliest: 6, total: 6 }) @@ -84,43 +115,40 @@ describe('ClientJobOutputModel observation', () => { }) it('bounds the render tail without splitting a surrogate pair', () => { - const model = new ClientJobOutputModel() + const model = new ClientJobsModel() opened(model) const emoji = '😀'.repeat((64 * 1024) + 8) model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: emoji }], next: emoji.length * 2 }) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.gapBefore).toBe(true) - expect(view!.text.length).toBeLessThanOrEqual(128 * 1024) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.gapBefore).toBe(true) + expect(live!.text.length).toBeLessThanOrEqual(128 * 1024) // The bound landed between pairs: the surviving text still round-trips. - expect(/^(?:😀)+$/u.test(view!.text)).toBe(true) + expect(/^(?:😀)+$/u.test(live!.text)).toBe(true) }) - it('carries the anchor detail and trims a plain-ASCII tail without a boundary shift', () => { - const model = new ClientJobOutputModel() - model.observeOpened(ID, { - type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running', detail: 'exit soon', - }) - expect(model.getSnapshot().observed[String(ID)]?.detail).toBe('exit soon') + it('trims a plain-ASCII tail without a boundary shift', () => { + const model = new ClientJobsModel() + opened(model) const long = 'x'.repeat((128 * 1024) + 5) model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: long }], next: long.length }) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.text.length).toBe(128 * 1024) - expect(view?.gapBefore).toBe(true) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.text.length).toBe(128 * 1024) + expect(live?.gapBefore).toBe(true) }) it('advances the cut past a low surrogate landing exactly on the bound', () => { - const model = new ClientJobOutputModel() + const model = new ClientJobsModel() opened(model) // 'z' + one emoji + odd ASCII tail puts a low surrogate exactly at the cut index. const text = 'z😀' + 'a'.repeat((128 * 1024) - 1) model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text }], next: text.length }) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.text.length).toBe((128 * 1024) - 1) - expect(view?.text.startsWith('a')).toBe(true) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.text.length).toBe((128 * 1024) - 1) + expect(live?.text.startsWith('a')).toBe(true) }) it('preserves accumulated text across a reconnect anchor and clears on stop', () => { - const model = new ClientJobOutputModel() + const model = new ClientJobsModel() opened(model) model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'kept' }], next: 4 }) opened(model, { from: 4, earliest: 0, total: 4 }) @@ -133,19 +161,19 @@ describe('ClientJobOutputModel observation', () => { }) it('records a terminal stream failure on the live view', () => { - const model = new ClientJobOutputModel() + const model = new ClientJobsModel() opened(model) model.observeFailed(ID, new Error('carrier gone')) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.streaming).toBe(false) - expect(view?.error).toContain('carrier gone') + const live = model.getSnapshot().observed[String(ID)] + expect(live?.streaming).toBe(false) + expect(live?.error).toContain('carrier gone') // A failure for an untracked id is inert. model.observeFailed('bash-9' as JobId, new Error('ignored')) }) }) /** One scripted logical stream: frames are pushed by the test, never reopened. */ -class FakeStream { +class FakeStream { disposed = false /** Model a carrier whose disposal surfaces as an iterator throw. */ throwOnDispose = false @@ -153,12 +181,12 @@ class FakeStream { deferDispose = false private disposeRelease: (() => void) | undefined private disposePending: Promise | undefined - private readonly frames: JobObserveFrame[] = [] + private readonly frames: Frame[] = [] private waiter: (() => void) | undefined private failure: Error | undefined readonly accepts: number[] = [] - push(frame: JobObserveFrame): void { + push(frame: Frame): void { this.frames.push(frame) this.waiter?.() } @@ -208,14 +236,21 @@ class FakeStream { } } +interface StreamOptions { + name: string + open: (signal: AbortSignal) => unknown + ended: (accepted: boolean) => Error +} + function bench() { const ctx = new Context() - const model = new ClientJobOutputModel() - const streams: { options: { open: (signal: AbortSignal) => unknown }; stream: FakeStream }[] = [] + const model = new ClientJobsModel() + const streams: { options: StreamOptions; stream: FakeStream & FakeStream }[] = [] const observeCalls: unknown[] = [] + const rowsCalls: unknown[] = [] const remote = { - $stream: (options: { open: (signal: AbortSignal) => unknown }) => { - const stream = new FakeStream() + $stream: (options: StreamOptions) => { + const stream = new FakeStream() streams.push({ options, stream }) return stream }, @@ -224,37 +259,129 @@ function bench() { observeCalls.push(request) return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } }, + rows: (request: unknown) => { + rowsCalls.push(request) + return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } + }, }, } - const output = new ClientJobOutput(ctx, remote as never, model) - return { ctx, model, output, streams, observeCalls } + const jobs = new ClientJobs(ctx, remote as never, model) + return { ctx, model, jobs, streams, observeCalls, rowsCalls } } const tick = () => new Promise(resolve => setTimeout(resolve, 0)) +const anchor = (): JobObserveFrame => ({ type: 'opened', job: view(), from: 0 }) + +describe('ClientJobs roster streams', () => { + it('shares one roster stream per session and drops the rows after the last release', async () => { + const { model, jobs, streams, rowsCalls } = bench() + const first = jobs.watchRows(S1) + const second = jobs.watchRows(S1) + jobs.watchRows(S2) + expect(streams.map(entry => entry.options.name)).toEqual(['job rows alice', 'job rows bob']) + streams[0]!.options.open(new AbortController().signal) + expect(rowsCalls.at(-1)).toEqual({ sessionId: 'alice' }) -describe('ClientJobOutput observation streams', () => { - it('feeds anchor, output, and terminal frames into the model, then closes', async () => { - const { model, output, streams } = bench() - output.observe(undefined, ID) const { stream } = streams[0]! - stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + stream.push({ type: 'rows', jobs: [view()] }) + await tick() + expect(model.getSnapshot().rows[S1]).toEqual([view()]) + expect(stream.accepts).toHaveLength(1) + stream.push({ type: 'rows', jobs: [view({ status: 'completed' })] }) + await tick() + expect(model.getSnapshot().rows[S1]?.[0]?.status).toBe('completed') + + first() + first() + expect(stream.disposed).toBe(false) + second() + await tick() + expect(stream.disposed).toBe(true) + expect(S1 in model.getSnapshot().rows).toBe(false) + expect(streams[1]!.stream.disposed).toBe(false) + }) + + it('classifies a roster end as retryable only after the first frame', () => { + const { jobs, streams } = bench() + jobs.watchRows(S1) + const { options } = streams[0]! + expect(options.ended(true).name).toBe('RemoteStreamCarrierError') + expect(options.ended(false).name).toBe('Error') + }) + + it('drops the roster on a terminal stream failure', async () => { + const { model, jobs, streams } = bench() + jobs.watchRows(S1) + const { stream } = streams[0]! + stream.push({ type: 'rows', jobs: [view()] }) + await tick() + expect(model.getSnapshot().rows[S1]).toBeDefined() + stream.poison(new Error('frame decode broke')) + await tick() + expect(S1 in model.getSnapshot().rows).toBe(false) + }) + + it('stays silent when the carrier teardown itself throws after the last release', async () => { + const { model, jobs, streams } = bench() + const stop = jobs.watchRows(S1) + const { stream } = streams[0]! + stream.throwOnDispose = true + stream.push({ type: 'rows', jobs: [view()] }) + await tick() + // The releaser drops the rows and marks the entry stopped, then its + // dispose surfaces as an iterator throw — which the consumer swallows for + // a stopped entry instead of dropping twice or failing loud. + stop() + await tick() + expect(model.getSnapshot().rows).toEqual({}) + }) + + it('a re-watch inside the dispose round-trip keeps its fresh rows', async () => { + const { model, jobs, streams } = bench() + const stop = jobs.watchRows(S1) + const first = streams[0]!.stream + first.deferDispose = true + first.push({ type: 'rows', jobs: [view()] }) + await tick() + + stop() + // Re-watch while the previous generation's dispose is still in flight. + jobs.watchRows(S1) + const second = streams[1]!.stream + second.push({ type: 'rows', jobs: [view({ label: 'fresh' })] }) + await tick() + + first.releaseDispose() + await tick() + // The stale post-dispose clear must not blank the successor's rows. + expect(model.getSnapshot().rows[S1]?.[0]?.label).toBe('fresh') + }) +}) + +describe('ClientJobs observation streams', () => { + it('feeds anchor, output, and terminal frames into the model, then closes', async () => { + const { model, jobs, streams } = bench() + jobs.observe(undefined, ID) + const { stream } = streams[0]! + expect(streams[0]!.options.name).toBe('job observation bash-1') + stream.push(anchor()) stream.push({ type: 'output', chunks: [{ at: 0, text: 'hi' }], next: 2 }) await tick() expect(model.getSnapshot().observed[String(ID)]?.text).toBe('hi') expect(stream.accepts).toHaveLength(1) - stream.push({ type: 'status', status: 'completed' }) + stream.push({ type: 'status', job: view({ status: 'completed' }) }) await tick() expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) expect(stream.disposed).toBe(true) }) it('shares one stream across observers and stops after the last release', async () => { - const { model, output, streams } = bench() - const first = output.observe(undefined, ID) - const second = output.observe(undefined, ID) + const { model, jobs, streams } = bench() + const first = jobs.observe(undefined, ID) + const second = jobs.observe(undefined, ID) expect(streams).toHaveLength(1) - streams[0]!.stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + streams[0]!.stream.push(anchor()) await tick() first() @@ -267,17 +394,17 @@ describe('ClientJobOutput observation streams', () => { }) it('a late release of a superseded observation neither tears down nor clears its successor', async () => { - const { model, output, streams } = bench() - const firstStop = output.observe(undefined, ID) + const { model, jobs, streams } = bench() + const firstStop = jobs.observe(undefined, ID) const first = streams[0]!.stream - first.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - first.push({ type: 'status', status: 'completed' }) + first.push(anchor()) + first.push({ type: 'status', job: view({ status: 'completed' }) }) await tick() // A successor observation replaces the settled entry while the first // observer still holds its releaser. - const secondStop = output.observe(undefined, ID) + const secondStop = jobs.observe(undefined, ID) const second = streams[1]!.stream - second.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + second.push(anchor()) await tick() firstStop() @@ -292,18 +419,18 @@ describe('ClientJobOutput observation streams', () => { }) it('a re-observation inside the dispose round-trip keeps its fresh view', async () => { - const { model, output, streams } = bench() - const stop = output.observe(undefined, ID) + const { model, jobs, streams } = bench() + const stop = jobs.observe(undefined, ID) const first = streams[0]!.stream first.deferDispose = true - first.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + first.push(anchor()) await tick() stop() // Re-expand while the previous generation's dispose is still in flight. - output.observe(undefined, ID) + jobs.observe(undefined, ID) const second = streams[1]!.stream - second.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + second.push(anchor()) await tick() first.releaseDispose() @@ -316,9 +443,9 @@ describe('ClientJobOutput observation streams', () => { }) it('opens a fresh stream for a re-observed job and resumes from the model cursor', async () => { - const { output, streams, observeCalls, model } = bench() - const stop = output.observe('alice' as SessionId, ID) - streams[0]!.stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + const { jobs, streams, observeCalls, model } = bench() + const stop = jobs.observe(S1, ID) + streams[0]!.stream.push(anchor()) streams[0]!.stream.push({ type: 'output', chunks: [{ at: 0, text: 'abc' }], next: 3 }) await tick() // The generation opener reads the live cursor at open time and carries the @@ -328,7 +455,7 @@ describe('ClientJobOutput observation streams', () => { stop() await tick() - output.observe(undefined, ID) + jobs.observe(undefined, ID) expect(streams).toHaveLength(2) streams[1]!.options.open(new AbortController().signal) // Stopped observation dropped the cursor: the fresh stream starts unanchored. @@ -337,31 +464,31 @@ describe('ClientJobOutput observation streams', () => { }) it('classifies a premature end as retryable only after the anchor', () => { - const { output, streams } = bench() - output.observe(undefined, ID) - const options = streams[0]!.options as unknown as { ended: (accepted: boolean) => Error } + const { jobs, streams } = bench() + jobs.observe(undefined, ID) + const { options } = streams[0]! expect(options.ended(true).name).toBe('RemoteStreamCarrierError') expect(options.ended(false).name).toBe('Error') }) it('records a consumer failure on the live view', async () => { - const { model, output, streams } = bench() - output.observe(undefined, ID) + const { model, jobs, streams } = bench() + jobs.observe(undefined, ID) const { stream } = streams[0]! - stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + stream.push(anchor()) await tick() stream.poison(new Error('frame decode broke')) await tick() - const view = model.getSnapshot().observed[String(ID)] - expect(view?.error).toContain('frame decode broke') - expect(view?.streaming).toBe(false) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.error).toContain('frame decode broke') + expect(live?.streaming).toBe(false) }) it('treats a release after service disposal as inert and stays silent for post-stop failures', async () => { - const { ctx, model, output, streams } = bench() - const stop = output.observe(undefined, ID) + const { ctx, model, jobs, streams } = bench() + const stop = jobs.observe(undefined, ID) const { stream } = streams[0]! - stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + stream.push(anchor()) await tick() // Poison wakes the consumer, but the synchronous stop lands first, so the // failure arrives on an already-stopped entry and stays silent. @@ -370,7 +497,7 @@ describe('ClientJobOutput observation streams', () => { await tick() expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() - const second = output.observe(undefined, ID) + const second = jobs.observe(undefined, ID) await ctx.fiber.dispose() // The disposal path already removed every entry; a live releaser whose // entry is gone is inert. @@ -378,11 +505,11 @@ describe('ClientJobOutput observation streams', () => { }) it('stays silent when the carrier teardown itself throws after a stop', async () => { - const { model, output, streams } = bench() - const stop = output.observe(undefined, ID) + const { model, jobs, streams } = bench() + const stop = jobs.observe(undefined, ID) const { stream } = streams[0]! stream.throwOnDispose = true - stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + stream.push(anchor()) await tick() // The releaser marks the entry stopped, then its dispose surfaces as an // iterator throw — which the consumer swallows for a stopped entry. @@ -392,9 +519,9 @@ describe('ClientJobOutput observation streams', () => { }) it('holds service disposal until every open stream reports quiescence', async () => { - const { ctx, output, streams } = bench() - output.observe(undefined, ID) - output.observe(undefined, 'bash-2' as JobId) + const { ctx, jobs, streams } = bench() + jobs.watchRows(S1) + jobs.observe(undefined, ID) for (const { stream } of streams) stream.deferDispose = true await tick() @@ -416,21 +543,18 @@ describe('ClientJobOutput observation streams', () => { }) it('records a terminal stream failure and service disposal closes live streams', async () => { - const { ctx, model, output, streams } = bench() - output.observe(undefined, ID) + const { ctx, model, jobs, streams } = bench() + jobs.observe(undefined, ID) const failing = streams[0]!.stream - failing.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + failing.push(anchor()) await tick() - const iterator = failing[Symbol.asyncIterator]() - void iterator // Simulate a terminal consumer failure by disposing the underlying stream: // the consumer loop ends without a status frame and cleans up. const spy = vi.spyOn(model, 'observeFailed') await failing.dispose() await tick() - const other = 'bash-2' as JobId - output.observe(undefined, other) + jobs.observe(undefined, 'bash-2' as JobId) await ctx.fiber.dispose() expect(streams.every(entry => entry.stream.disposed)).toBe(true) expect(spy).not.toHaveBeenCalled() diff --git a/packages/api/job-controller/tests/observe.host.spec.ts b/packages/api/job-controller/tests/observe.host.spec.ts index bc6827fdc2..7f36b8df1e 100644 --- a/packages/api/job-controller/tests/observe.host.spec.ts +++ b/packages/api/job-controller/tests/observe.host.spec.ts @@ -3,123 +3,58 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { JobId } from '@deepseek-ai/dsh-jobs' -import type { JobOutcome, RecordingJob } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { JobController } from '../src/index.ts' -import { observeJobRecord } from '../src/observe.ts' +import { observeJobOutput } from '../src/observe.ts' import type { ObserveJobOptions } from '../src/observe.ts' import type { JobObserveFrame } from '../src/types.ts' +import { collect, harness, next, registerAgent, startJob, wait } from './host-harness.ts' -async function harness() { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry, { retainBytes: 64, settledRetainBytes: 64 }) - ctx.jobs.attachController('observe-job-test') - return ctx -} +const OBSERVE: ObserveJobOptions = { flushMs: 5, maxFrameBytes: 1024 } -function registerAgent(ctx: Context, rawId: string): Agent { - const scopeFiber = ctx.plugin(() => {}) - const id = SessionId(rawId) - const session = Session.create(id) - const agent = { - id, - session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), - status: 'idle', - ctx: scopeFiber.ctx, - } as unknown as Agent - ctx.agents.register(agent) - return agent -} - -/** Start one record job whose done settles on demand; expose the producer face. */ -function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent; record?: boolean } = {}) { - let settle!: (outcome: JobOutcome) => void - let face!: RecordingJob - const id = ctx.jobs.start({ - kind: 'bash', - label: options.label ?? 'echo', - ...options.owner !== undefined ? { owner: options.owner } : {}, - ...options.record === false ? {} : { record: true }, - run: (job: RecordingJob) => { - face = job - return { - cancel() {}, - done: new Promise((resolve) => { settle = resolve }), - } - }, - }) - return { - id, - append: (text: string, opts?: Parameters[1]) => { face.append(text, opts) }, - updateDetail: (detail: string) => { face.updateDetail(detail) }, - settle: async (outcome: JobOutcome) => { - settle(outcome) - await new Promise(resolve => setTimeout(resolve, 0)) - }, - } -} - -/** Read frames until `count` arrive or the generator finishes, then abort. */ -async function collect( - iterable: AsyncIterable, - count: number, - abort: AbortController, -): Promise { - const frames: Frame[] = [] - const iterator = iterable[Symbol.asyncIterator]() - try { - while (frames.length < count) { - const step = await iterator.next() - if (step.done) return frames - frames.push(step.value) - } - } finally { - abort.abort() - await iterator.next().catch(() => undefined) - } - return frames -} - -const OBSERVE: Omit = { flushMs: 5, maxFrameBytes: 1024 } - -describe('observeJobRecord', () => { - function observed(ctx: Context, request: { jobId: JobId; from?: number }, caller?: Agent) { +describe('observeJobOutput', () => { + function observed(ctx: Context, request: { jobId: JobId; sessionId?: SessionId; from?: number }) { const abort = new AbortController() const frames: JobObserveFrame[] = [] - const stream = observeJobRecord(ctx.jobs, request, { ...OBSERVE, caller }, abort.signal) + const stream = observeJobOutput(ctx.jobs, request, OBSERVE, abort.signal) const done = (async () => { for await (const frame of stream) frames.push(frame) })() return { abort, frames, done } } - it('anchors, streams coalesced output, and closes with the terminal status', async () => { + it('anchors, streams coalesced output, and closes with the terminal projection', async () => { const ctx = await harness() - const job = startRecordJob(ctx) + const job = startJob(ctx) job.append('early ') const { frames, done } = observed(ctx, { jobId: job.id }) - await new Promise(resolve => setTimeout(resolve, 20)) + await wait(20) job.append('live', { channel: 'stderr' }) - await new Promise(resolve => setTimeout(resolve, 20)) + await wait(20) await job.settle({ status: 'completed', detail: 'exit code: 0' }) await done - expect(frames[0]).toMatchObject({ type: 'opened', from: 0, earliest: 0, total: 6, status: 'running' }) + expect(frames[0]).toMatchObject({ + type: 'opened', + from: 0, + job: { id: job.id, status: 'running', output: { earliest: 0, total: 6 } }, + }) const outputs = frames.filter(frame => frame.type === 'output') const text = outputs.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') expect(text).toBe('early live') expect(outputs.at(-1)?.next).toBe(10) expect(outputs.flatMap(frame => frame.chunks).find(chunk => chunk.text === 'live')?.channel).toBe('stderr') - expect(frames.at(-1)).toMatchObject({ type: 'status', status: 'completed', detail: 'exit code: 0' }) + expect(frames.at(-1)).toMatchObject({ + type: 'status', + job: { status: 'completed', detail: 'exit code: 0', output: { total: 10 } }, + }) }) it('resumes from an explicit offset and flags an evicted resume lossy', async () => { const ctx = await harness() - const job = startRecordJob(ctx, { label: 'spam' }) + const job = startJob(ctx, { label: 'spam' }) job.append('a'.repeat(60)) job.append('b'.repeat(60)) await job.settle({ status: 'completed' }) @@ -138,17 +73,13 @@ describe('observeJobRecord', () => { it('splits oversized reads along the frame budget', async () => { const ctx = await harness() - const job = startRecordJob(ctx, { label: 'wide' }) + const job = startJob(ctx, { label: 'wide' }) job.append('x'.repeat(30)) job.append('y'.repeat(30)) await job.settle({ status: 'completed' }) const abort = new AbortController() const frames = await collect( - observeJobRecord(ctx.jobs, { jobId: job.id }, { - flushMs: 5, - maxFrameBytes: 30, - caller: undefined, - }, abort.signal), + observeJobOutput(ctx.jobs, { jobId: job.id }, { flushMs: 5, maxFrameBytes: 30 }, abort.signal), 5, abort, ) @@ -160,18 +91,14 @@ describe('observeJobRecord', () => { it('carries the lossy flag on the first frame of a split evicted read', async () => { const ctx = await harness() - const job = startRecordJob(ctx, { label: 'evicted split' }) + const job = startJob(ctx, { label: 'evicted split' }) job.append('a'.repeat(40)) job.append('b'.repeat(40)) job.append('c'.repeat(40)) await job.settle({ status: 'completed' }) const abort = new AbortController() const frames = await collect( - observeJobRecord(ctx.jobs, { jobId: job.id, from: 0 }, { - flushMs: 5, - maxFrameBytes: 40, - caller: undefined, - }, abort.signal), + observeJobOutput(ctx.jobs, { jobId: job.id, from: 0 }, { flushMs: 5, maxFrameBytes: 40 }, abort.signal), 5, abort, ) @@ -180,61 +107,49 @@ describe('observeJobRecord', () => { expect(outputs[1]?.lossy).toBeUndefined() }) - it('reads owned records through the resolved caller and rejects foreign, bad, and unknown input', async () => { + it('reads owned output through the request session and rejects foreign, bad, and unknown input', async () => { const ctx = await harness() const owner = registerAgent(ctx, 'alice') - const job = startRecordJob(ctx, { label: 'secret', owner }) + const job = startJob(ctx, { label: 'secret', owner: owner.id }) job.append('classified') await job.settle({ status: 'completed' }) - const { frames, done } = observed(ctx, { jobId: job.id }, owner) + const { frames, done } = observed(ctx, { jobId: job.id, sessionId: owner.id }) await done expect(frames.some(frame => frame.type === 'output' && frame.chunks.some(chunk => chunk.text === 'classified'))).toBe(true) const foreign = new AbortController() await expect(collect( - observeJobRecord(ctx.jobs, { jobId: job.id }, { ...OBSERVE, caller: undefined }, foreign.signal), + observeJobOutput(ctx.jobs, { jobId: job.id }, OBSERVE, foreign.signal), 1, foreign, )).rejects.toThrow(/belongs to another session/) const abort = new AbortController() await expect(collect( - observeJobRecord(ctx.jobs, { jobId: job.id, from: -1 }, { ...OBSERVE, caller: owner }, abort.signal), + observeJobOutput(ctx.jobs, { jobId: job.id, sessionId: owner.id, from: -1 }, OBSERVE, abort.signal), 1, abort, )).rejects.toThrow(/invalid observe offset/) const abortUnknown = new AbortController() await expect(collect( - observeJobRecord(ctx.jobs, { jobId: JobId('bash-999') }, { ...OBSERVE, caller: undefined }, abortUnknown.signal), + observeJobOutput(ctx.jobs, { jobId: JobId('bash-999') }, OBSERVE, abortUnknown.signal), 1, abortUnknown, )).rejects.toThrow(/unknown job/) }) - it('refuses a job that declared no record', async () => { + it('anchors with the live progress and takes the fast wait path for a wake that lands mid-yield', async () => { const ctx = await harness() - const job = startRecordJob(ctx, { record: false }) + const job = startJob(ctx) + job.progress('3/10') const abort = new AbortController() - await expect(collect( - observeJobRecord(ctx.jobs, { jobId: job.id }, { ...OBSERVE, caller: undefined }, abort.signal), - 1, - abort, - )).rejects.toThrow(/declared no output record/) - await job.settle({ status: 'completed' }) - }) - - it('anchors with the live detail and takes the fast wait path for a wake that lands mid-yield', async () => { - const ctx = await harness() - const job = startRecordJob(ctx) - job.updateDetail('3/10') - const abort = new AbortController() - const iterator = observeJobRecord(ctx.jobs, { jobId: job.id }, { ...OBSERVE, caller: undefined }, abort.signal)[Symbol.asyncIterator]() + const iterator = observeJobOutput(ctx.jobs, { jobId: job.id }, OBSERVE, abort.signal)[Symbol.asyncIterator]() const anchor = await iterator.next() - expect(anchor.value).toMatchObject({ type: 'opened', detail: '3/10' }) + expect(anchor.value).toMatchObject({ type: 'opened', job: { progress: '3/10' } }) // Both chunks land while the generator is suspended on a yield, so each // wake precedes the loop's wait(): draining the first chunk must pass // through the already-woken fast path to read the second. @@ -247,18 +162,18 @@ describe('observeJobRecord', () => { const pending = iterator.next() await job.settle({ status: 'completed' }) const status = await pending - expect(status.value).toMatchObject({ type: 'status', status: 'completed' }) + expect(status.value).toMatchObject({ type: 'status', job: { status: 'completed' } }) expect((await iterator.next()).done).toBe(true) }) it('ignores output signals for other jobs while waiting', async () => { const ctx = await harness() - const watched = startRecordJob(ctx, { label: 'watched' }) - const noisy = startRecordJob(ctx, { label: 'noisy' }) + const watched = startJob(ctx, { label: 'watched' }) + const noisy = startJob(ctx, { label: 'noisy' }) const { abort, frames, done } = observed(ctx, { jobId: watched.id }) - await new Promise(resolve => setTimeout(resolve, 15)) + await wait(15) noisy.append('unrelated') - await new Promise(resolve => setTimeout(resolve, 15)) + await wait(15) await watched.settle({ status: 'completed' }) await done abort.abort() @@ -268,9 +183,9 @@ describe('observeJobRecord', () => { it('stops cleanly on abort while waiting for output', async () => { const ctx = await harness() - const job = startRecordJob(ctx, { label: 'idle' }) + const job = startJob(ctx, { label: 'idle' }) const { abort, frames, done } = observed(ctx, { jobId: job.id }) - await new Promise(resolve => setTimeout(resolve, 20)) + await wait(20) abort.abort() await done expect(frames[0]?.type).toBe('opened') @@ -279,18 +194,31 @@ describe('observeJobRecord', () => { }) }) -describe('JobController.observe', () => { +describe('JobController', () => { async function controllerHarness() { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(TypertRegistry) - ctx.jobs.attachController('observe-job-test') + ctx.jobs.attachController('job-controller-test') await ctx.plugin(JobController, {}) return ctx } + function liveOwner(ctx: Context, rawId: string): Agent { + const session = ctx.sessions.create(SessionId(rawId)) + const owner = { + id: session.id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx, + } as unknown as Agent + ctx.agents.register(owner) + return owner + } + it('waits for the job registry instead of serving without one', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -301,24 +229,16 @@ describe('JobController.observe', () => { expect(ctx.get('jobController')).toBeUndefined() }) - it('resolves the fenced-read caller from the request session', async () => { + it('observe reads an owned job through the request session', async () => { const ctx = await controllerHarness() - const session = ctx.sessions.create(SessionId('observing-session')) - const owner = { - id: session.id, - session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), - status: 'idle', - ctx, - } as unknown as Agent - ctx.agents.register(owner) - const job = startRecordJob(ctx, { label: 'owned run', owner }) + const owner = liveOwner(ctx, 'observing-session') + const job = startJob(ctx, { label: 'owned run', owner: owner.id }) job.append('hi') await job.settle({ status: 'completed' }) const abort = new AbortController() const frames = await collect( - ctx.jobController.observe({ sessionId: session.id, jobId: job.id }, abort.signal), + ctx.jobController.observe({ sessionId: owner.id, jobId: job.id }, abort.signal), 3, abort, ) @@ -327,7 +247,7 @@ describe('JobController.observe', () => { it('observes an unowned job without a request session', async () => { const ctx = await controllerHarness() - const job = startRecordJob(ctx, { label: 'unowned run' }) + const job = startJob(ctx, { label: 'unowned run' }) job.append('open access') await job.settle({ status: 'completed' }) @@ -339,4 +259,22 @@ describe('JobController.observe', () => { ) expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) }) + + it('rows mirrors the session-visible set and follows a settlement', async () => { + const ctx = await controllerHarness() + const owner = liveOwner(ctx, 'listing-session') + const owned = startJob(ctx, { label: 'owned', owner: owner.id }) + startJob(ctx, { label: 'shared' }) + + const abort = new AbortController() + const iterator = ctx.jobController.rows({ sessionId: owner.id }, abort.signal)[Symbol.asyncIterator]() + const first = await next(iterator) + expect(first?.jobs.map(job => job.label)).toEqual(['owned', 'shared']) + const pending = next(iterator) + await owned.settle({ status: 'completed', detail: 'exit code: 0' }) + const second = await pending + expect(second?.jobs.find(job => job.id === owned.id)).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) + abort.abort() + expect(await next(iterator)).toBeUndefined() + }) }) diff --git a/packages/api/job-controller/tests/rows.host.spec.ts b/packages/api/job-controller/tests/rows.host.spec.ts new file mode 100644 index 0000000000..f246425605 --- /dev/null +++ b/packages/api/job-controller/tests/rows.host.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { streamJobRows } from '../src/rows.ts' +import { harness, next, registerAgent, startJob, wait } from './host-harness.ts' + +const ROWS = { flushMs: 5 } + +describe('streamJobRows', () => { + it('opens with the visible set and re-sends it after each coalesced lifecycle burst', async () => { + const ctx = await harness() + const alice = registerAgent(ctx, 'alice') + const bob = registerAgent(ctx, 'bob') + startJob(ctx, { label: 'shared' }) + const mine = startJob(ctx, { label: 'mine', owner: alice.id }) + startJob(ctx, { label: 'theirs', owner: bob.id }) + + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: alice.id }, ROWS, abort.signal)[Symbol.asyncIterator]() + const first = await next(iterator) + expect(first?.jobs.map(job => job.label)).toEqual(['shared', 'mine']) + + // Another owner's lifecycle and this job's own output never wake the roster; + // the next frame is the one the settlement earns, and it carries the final + // byte count without a frame per append. + startJob(ctx, { label: 'theirs too', owner: bob.id }) + mine.append('progress bytes') + await wait(20) + const pending = next(iterator) + await mine.settle({ status: 'completed', detail: 'exit code: 0' }) + const second = await pending + expect(second?.jobs.map(job => job.label)).toEqual(['shared', 'mine']) + expect(second?.jobs[1]).toMatchObject({ status: 'completed', detail: 'exit code: 0', output: { total: 14 } }) + abort.abort() + expect(await next(iterator)).toBeUndefined() + }) + + it('follows progress, stopping, and registration commits', async () => { + const ctx = await harness() + const alice = registerAgent(ctx, 'alice') + const job = startJob(ctx, { label: 'first', owner: alice.id, slowStop: true }) + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: alice.id }, ROWS, abort.signal)[Symbol.asyncIterator]() + await next(iterator) + + job.progress('3/10') + expect((await next(iterator))?.jobs[0]).toMatchObject({ progress: '3/10' }) + ctx.jobs.visibleTo(alice.id).kill(job.id, { reason: 'enough' }) + expect((await next(iterator))?.jobs[0]).toMatchObject({ status: 'stopping', progress: '3/10' }) + startJob(ctx, { label: 'second', owner: alice.id }) + expect((await next(iterator))?.jobs.map(entry => entry.label)).toEqual(['first', 'second']) + abort.abort() + expect(await next(iterator)).toBeUndefined() + await job.settle({ status: 'killed' }) + }) + + it('drops an owner\'s rows when its lifecycle ends, keeping the unowned set', async () => { + const ctx = await harness() + const alice = registerAgent(ctx, 'alice') + startJob(ctx, { label: 'shared' }) + startJob(ctx, { label: 'mine', owner: alice.id }) + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: alice.id }, ROWS, abort.signal)[Symbol.asyncIterator]() + expect((await next(iterator))?.jobs).toHaveLength(2) + + await alice.disposeScope() + // Teardown cancels, settles, and removes the owned job; the frames land in + // that order, and the last one holds the unowned job alone. + let frame = await next(iterator) + while (frame !== undefined && frame.jobs.length !== 1) frame = await next(iterator) + expect(frame?.jobs.map(job => job.label)).toEqual(['shared']) + abort.abort() + expect(await next(iterator)).toBeUndefined() + }) + + it('stops cleanly on abort while idle and refuses an already-aborted signal', async () => { + const ctx = await harness() + startJob(ctx, { label: 'idle' }) + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: 'nobody' as never }, ROWS, abort.signal)[Symbol.asyncIterator]() + expect((await next(iterator))?.jobs.map(job => job.label)).toEqual(['idle']) + await wait(10) + abort.abort() + expect(await next(iterator)).toBeUndefined() + + const aborted = new AbortController() + aborted.abort() + const refused = streamJobRows(ctx.jobs, { sessionId: 'nobody' as never }, ROWS, aborted.signal)[Symbol.asyncIterator]() + await expect(refused.next()).rejects.toThrow() + }) +}) diff --git a/packages/api/job-controller/tsconfig.host.json b/packages/api/job-controller/tsconfig.host.json index 96de4dede2..4e5c8c3f01 100644 --- a/packages/api/job-controller/tsconfig.host.json +++ b/packages/api/job-controller/tsconfig.host.json @@ -8,12 +8,13 @@ "files": [ "src/index.ts", "src/observe.ts", - "src/types.ts" + "src/rows.ts", + "src/types.ts", + "src/wake.ts" ], "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, - { "path": "../../core/agent" }, { "path": "../../core/session" }, { "path": "../../jobs/jobs" }, { "path": "../../typert/protocol" } From 84efac2c4297b84b00768eca3973787ed7146d75 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 17:00:51 +0800 Subject: [PATCH 036/284] refactor(ui-jobs): read rows and observations from ctx.jobs The header list watches its session's roster through ctx.jobs.watchRows while mounted and reads rows and observed views from one jobs snapshot. A row is expandable while the job is live or once it left retained output behind; it shows the live progress line or the terminal detail. The web e2e drivers kill through visibleTo(), and the client fixtures the roster removal missed drop their jobs baselines. --- apps/web/tests/background-job-list.e2e.ts | 2 +- apps/web/tests/live-job-stream.e2e.ts | 8 +- .../tests/client-apply.client.spec.ts | 2 +- .../tests/fake-api.client.ts | 1 - .../tests/projection-store.client.spec.ts | 2 +- .../tests/queue-store.client.spec.ts | 1 - .../tests/sessions-service.client.spec.ts | 2 +- packages/client/ui-jobs/README.i18n.yaml | 4 +- packages/client/ui-jobs/README.md | 19 ++- packages/client/ui-jobs/README.zh.md | 19 ++- packages/client/ui-jobs/package.json | 2 - .../ui-jobs/src/client/JobListAction.tsx | 81 +++++++----- packages/client/ui-jobs/src/client/index.ts | 17 ++- .../tests/browser-plugin.client.spec.ts | 25 ++-- .../tests/job-list-action.client.spec.tsx | 125 +++++++++++------- 15 files changed, 178 insertions(+), 132 deletions(-) diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index 96a7f4f833..5d881f59b4 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -111,7 +111,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { expect(tripwire.warnings).toEqual([]) phase = 'settled' - expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') + expect(scaffold.ctx.jobs.visibleTo(agent.id).kill(jobId, { reason: 'web e2e cancellation' })).toBe('requested') const idle = page.getByRole('button', { name: '1 background job', exact: true }) await idle.waitFor({ timeout: 20_000 }) diff --git a/apps/web/tests/live-job-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts index 83736763c6..ccddae0ee2 100644 --- a/apps/web/tests/live-job-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -1,5 +1,5 @@ // Session-header job list driven by a real background bash run: the job row -// arrives over the session control stream, expanding it opens the record +// arrives over the job roster stream, expanding it opens the output // observation stream, and the panel shows the process's real output while it // is still running. No model call is involved. import { readFile, writeFile } from 'node:fs/promises' @@ -99,11 +99,11 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) const jobId = JobId(matched[0]) - // The job row reaches the header over the session control stream. + // The job row reaches the header over the job roster stream. await trigger.waitFor({ timeout: 15_000 }) await trigger.click() - // Expanding the row opens the record observation stream; the panel then renders + // Expanding the row opens the output observation stream; the panel then renders // the command's real stdout while the process is still running. const expand = page.getByRole('button', { name: /Show live output/ }) await expand.waitFor({ timeout: 10_000 }) @@ -116,7 +116,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { expect(tripwire.warnings).toEqual([]) phase = 'settled' - expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') + expect(scaffold.ctx.jobs.visibleTo(agent.id).kill(jobId, { reason: 'web e2e cancellation' })).toBe('requested') // Settlement arrives on the observation stream itself, so the open panel // flips without any further interaction; the roster trigger follows. diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 07f88b1a4f..29b435e3f9 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -150,7 +150,7 @@ describe('Session Controller Client apply', () => { expect(accept).toHaveBeenCalledWith({ type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, + value: { queues: {}, projections: {} }, }) bench.api.failStreams(new RemoteStreamCarrierError('generation lost')) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index e3ea48f783..14db19d4ce 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -155,7 +155,6 @@ export class FakeApiClient { followCursor: number | undefined controlBaseline: SessionControlBaseline = { queues: {}, - jobs: {}, projections: {}, } workspaceBaseline: Extract['value'] = { diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index 74f37c57cd..2dc1b98998 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -172,7 +172,7 @@ describe('manager frame routing', () => { manager.handleControlFrame({ type: 'baseline', value: { - queues: {}, jobs: {}, + queues: {}, projections: { [sid('s1')]: { asOfSeq: 2, values: {} } }, }, }) diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/queue-store.client.spec.ts index 722a06b090..fd0f261c21 100644 --- a/packages/api/session-controller/tests/queue-store.client.spec.ts +++ b/packages/api/session-controller/tests/queue-store.client.spec.ts @@ -279,7 +279,6 @@ describe('manager buffering of queue snapshots', () => { type: 'baseline', value: { queues: { [SID]: nextQueue }, - jobs: {}, projections: {}, }, }) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index c98ff399fb..5d3d040612 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -139,7 +139,7 @@ describe('scope tree', () => { b.svc.handleControlFrame({ type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, + value: { queues: {}, projections: {} }, }) await Promise.resolve() expect(b.svc.resolveAgentScope(sid('s-early'))).toBe(scoped) diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index 7a5e54a34f..acc54648f7 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -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/ui-jobs/README.md -README.md: fe357b5555cdc2f7a94ea0a44e97c499652695d3 -README.zh.md: d23b96becc5b2598197f7720a355a37e5c8bf7cb +README.md: 697bb2dc488e11d3c92c061cc83fabeebeb366dc +README.zh.md: 3b05b5796fe366ce6e2f4cb389b758e8855483b7 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index fe357b5555..697bb2dc48 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -1,5 +1,5 @@ --- -description: "The session-header background-job list: expandable streaming record panels, running/finished sections, and static rows for jobs without a record." +description: "The session-header background-job list: expandable streaming output panels, running/finished sections, and static rows for settled jobs without retained output." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-jobs` puts this session's background jobs in one header control. Every row carries the job's lifecycle, ticking duration, and model-visible `detail` from the `jobsBySession` mirror; a job that declared an observation record additionally offers an expandable panel that streams its real output while it runs. Collapsing stops the stream, so output only flows while someone is watching. Live rows lead with a ticking duration over a kind-and-status second line; settled rows compact behind a section heading; jobs without a record (subagent delegations, PTY sends) render as static rows with no expansion affordance. +`dsh-client-ui-jobs` puts this session's background jobs in one header control. Every row carries the job's lifecycle, ticking duration, and the model-visible progress line or terminal detail from the roster `ctx.jobs` keeps current; a live job, or a settled one that left retained output behind, additionally offers an expandable panel that streams its real output. Collapsing stops the stream, so output only flows while someone is watching. Live rows lead with the ticking duration and carry kind and status on a second line; settled rows fold after a section heading; a settled job that retained no output (a subagent, whose answer went to the model) renders as a static row. ## Table of Contents @@ -29,11 +29,11 @@ Load the plugin through the web-app manifest; it renders nothing until the sessi ### One row per job -The session control stream's `jobsBySession` mirror is the single roster: each `SessionJob` row carries lifecycle, duration, and the model-visible `detail`, and its `record` flag is present exactly when the job declared a record — that flag is what makes a row expandable. There is no second roster to join. +The `job.rows` stream `ctx.jobs` mirrors is the single roster: each `JobView` row carries lifecycle, duration, the live `progress` line or the terminal `detail`, and its retained byte count — a live job, or a settled one with retained output, is what makes a row expandable. There is no second roster to join. ### The expanded panel -Expanding an observable row opens that job's record observation stream from `ctx.jobOutput` (installed by `dsh-api-job-controller`) into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream failures render as notices above the panel. +Expanding an observable row opens that job's output observation stream from `ctx.jobs` (installed by `dsh-api-job-controller`) into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream interruptions render as notices above the panel. ----- @@ -43,7 +43,7 @@ Expanding an observable row opens that job's record observation stream from `ctx
        Implementation internals — click to expand -One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobOutput` and the standard `useSessions` hook — the component holds no transport state. Observation follows visibility: one `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover dismissal. +One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.rows` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close. | File | Role | |---|---| @@ -58,9 +58,8 @@ One slot entry in the header actions band (after the preset label, before the su ## Further Exploration -- [`dsh-api-job-controller`](../../api/job-controller/README.md) — the `job.observe` stream and the `ctx.jobOutput` service behind the panel. -- [`dsh-api-session-controller`](../../api/session-controller/README.md) — the `jobsBySession` roster mirror the rows come from. -- [`dsh-jobs`](../../jobs/jobs/README.md) — the registry contract that owns the record semantics. +- [`dsh-api-job-controller`](../../api/job-controller/README.md) — the `job.rows` and `job.observe` streams and the `ctx.jobs` service behind the rows and the panel. +- [`dsh-jobs`](../../jobs/jobs/README.md) — the registry contract that owns the ring and projection semantics. - [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures. ----- @@ -80,7 +79,7 @@ None; the package never assembles or sends provider requests. These limits define current package constraints, not a task backlog. -- **No kill control** — cancellation stays with the model's `job_kill`; a human kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). +- **No kill control** — cancellation stays with the model's `job_kill`. - **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up. @@ -93,4 +92,4 @@ None.
        -**Runtime invariant:** No companion is published. This package is a read-only projection of the `jobsBySession` mirror onto one header slot entry. It emits no cordis events, owns no cross-plugin mutable state, and its single slot registration proves disposal through the HMR-safety spec. +**Runtime invariant:** No companion is published. This package is a read-only projection of the `ctx.jobs` rosters and views onto one header slot entry. It emits no cordis events, owns no cross-plugin mutable state, and its single slot registration proves disposal through the HMR-safety spec. diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index d23b96becc..3b05b5796f 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -1,5 +1,5 @@ --- -description: "会话头部后台任务列表:可展开的流式 record 面板、进行中/已结束分组,以及无 record 任务的静态行。" +description: "会话头部后台任务列表:可展开的流式输出面板、进行中/已结束分组,以及无保留输出的已结束任务的静态行。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-jobs` 把本会话的后台任务放进一个头部控件。每一行都携带 `jobsBySession` 镜像里该 job 的生命周期、跳动时长与模型可见的 `detail`;声明了观测 record 的 job 额外提供可展开的面板,在运行期间流式呈现其真实输出。收起即停流——只有有人在看时输出才流动。进行中的行以跳动时长为主行、kind 与状态为副行;已结束的行折叠在分组标题之后;没有 record 的 job(subagent 委托、PTY 发送)渲染为无展开操作的静态行。 +`dsh-client-ui-jobs` 把本会话的后台任务放进一个头部控件。每一行都携带 `ctx.jobs` 保持最新的名册里该 job 的生命周期、跳动时长与模型可见的进度行或终态 detail;进行中的 job,或结算后仍留有保留输出的 job,额外提供可展开的面板,流式呈现其真实输出。收起即停流——只有有人在看时输出才流动。进行中的行以跳动时长为主行、kind 与状态为副行;已结束的行折叠在分组标题之后;没有保留输出的已结束 job(subagent,其回答已交给模型)渲染为静态行。 ## 目录 @@ -29,11 +29,11 @@ kind: "package-reference" ### 一 job 一行 -session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJob` 行携带生命周期、时长与模型可见的 `detail`,其 `record` 标志恰在 job 声明了 record 时存在——这个标志就是行可展开的依据。不存在需要 join 的第二份名册。 +`ctx.jobs` 镜像的 `job.rows` 流是唯一名册:每个 `JobView` 行携带生命周期、时长、实时 `progress` 行或终态 `detail`,以及其保留字节数——进行中的 job,或留有保留输出的已结束 job,就是行可展开的依据。不存在需要 join 的第二份名册。 ### 展开的面板 -展开可观察的行会从 `ctx.jobOutput`(由 `dsh-api-job-controller` 安装)打开该 job 的 record 观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。 +展开可观察的行会从 `ctx.jobs`(由 `dsh-api-job-controller` 安装)打开该 job 的输出观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。 ----- @@ -43,7 +43,7 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo
        实现内幕——点击展开 -头部操作带里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobOutput` 与标准 `useSessions` hook 到达——组件不持有任何传输状态。观测跟随可见性:一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 +头部操作带里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.rows` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 | 文件 | 角色 | |---|---| @@ -58,9 +58,8 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo ## 进一步探索 -- [`dsh-api-job-controller`](../../api/job-controller/README.zh.md) —— 面板背后的 `job.observe` 流与 `ctx.jobOutput` 服务。 -- [`dsh-api-session-controller`](../../api/session-controller/README.zh.md) —— 行所来自的 `jobsBySession` 名册镜像。 -- [`dsh-jobs`](../../jobs/jobs/README.zh.md) —— 拥有 record 语义的注册表契约。 +- [`dsh-api-job-controller`](../../api/job-controller/README.zh.md) —— 行与面板背后的 `job.rows`、`job.observe` 流与 `ctx.jobs` 服务。 +- [`dsh-jobs`](../../jobs/jobs/README.zh.md) —— 拥有环与投影语义的注册表契约。 - [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md) —— 面板所配置的 `TerminalBlock` 表面。 ----- @@ -80,7 +79,7 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo 这些限制界定当前包约束,不是任务清单。 -- **没有 kill 控件**——取消仍归模型的 `job_kill`;人工 kill 卡在 [web 任务显示 Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题上。 +- **没有 kill 控件**——取消仍归模型的 `job_kill`。 - **不渲染 channel 标签**——stdout 与 stderr 块拼接为一条流;按 channel 着色是展示层的后续工作。 @@ -93,4 +92,4 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo
        -**运行时不变式:** 不发布伴生入口。本包只把 `jobsBySession` mirror 只读投影到一个 header slot,不发出 Cordis 事件,也不持有跨插件可变状态。 +**运行时不变式:** 不发布伴生入口。本包只把 `ctx.jobs` 的名册与视图只读投影到一个 header slot,不发出 Cordis 事件,也不持有跨插件可变状态。 diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 27ab997467..92f29f2db1 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -36,7 +36,6 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-job-controller": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -57,7 +56,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-job-controller", - "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives" diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index bf369c9949..c7af7fe160 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -1,6 +1,5 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' -import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' -import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' +import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconChevronDownOutline14, StateDot, TerminalBlock, useDismissOnOutsidePointer, @@ -14,17 +13,22 @@ import css from './JobListAction.module.css' /** Registration-side business face for the job list. */ export interface JobListInjected { hooks: { - /** Client job-output snapshot bound by the renderer as useJobOutput. */ - jobOutput: { - getSnapshot(): JobOutputSnapshot + /** Client jobs snapshot (rosters and observations) bound by the renderer as useJobs. */ + jobs: { + getSnapshot(): JobsSnapshot subscribe(listener: () => void): () => void } } /** - * Start observing one job's live record; returns the stop function. + * Keep one session's roster current while the list is mounted; returns the + * stop function. Reference-counted by the client service. + */ + watchRows: (sessionId: SessionId) => () => void + /** + * Start observing one job's live output; returns the stop function. * Reference-counted by the client service, so panels can overlap safely. */ - observe: (sessionId: SessionId | undefined, id: SessionJob['id']) => () => void + observe: (sessionId: SessionId | undefined, id: JobView['id']) => () => void } /** Full props for the session-header job-list action. */ @@ -34,19 +38,27 @@ export type JobListActionProps = & InjectFace /** Stable empty list so a session with no jobs keeps one array identity. */ -const NO_JOBS: readonly SessionJob[] = [] +const NO_JOBS: readonly JobView[] = [] /** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */ const VIEWPORT_MARGIN = 12 -function isLive(job: SessionJob): boolean { +function isLive(job: JobView): boolean { return job.status === 'running' || job.status === 'stopping' } -/** True exactly when the job declared an observation record. */ -function isObservable(job: SessionJob): boolean { - return job.record === true +/** + * Whether the row offers an output panel: every live job (its output may + * still arrive) and a settled one that left retained output behind. + */ +function isObservable(job: JobView): boolean { + return isLive(job) || job.output.total > 0 +} + +/** The one-line qualifier beside the status: live progress while running, the terminal reason once settled. */ +function jobDetail(job: JobView): string | undefined { + return job.progress ?? job.detail } /** Closed-union exhaustiveness fence for the wire status set. */ @@ -59,7 +71,7 @@ function assertNever(value: never): never { * Status marker semantics. `stopping` and `killed` share the attention color: * both mean the work ended (or is ending) on request rather than on its own. */ -function dotState(status: SessionJob['status']): StateDotState { +function dotState(status: JobView['status']): StateDotState { switch (status) { case 'running': return 'ongoing' case 'stopping': return 'warning' @@ -71,7 +83,7 @@ function dotState(status: SessionJob['status']): StateDotState { } } -function statusLabel(status: SessionJob['status'], t: TranslateNS): string { +function statusLabel(status: JobView['status'], t: TranslateNS): string { switch (status) { case 'running': return t('status.running') case 'stopping': return t('status.stopping') @@ -130,7 +142,7 @@ function terminalLabels(t: TranslateNS): TerminalBlockLabels { * that settled in the same millisecond fall back to start order, so the sort * never depends on the host's map iteration. */ -function ordered(jobs: readonly SessionJob[]): SessionJob[] { +function ordered(jobs: readonly JobView[]): JobView[] { return [...jobs].sort((left, right) => { const liveLeft = isLive(left) if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 @@ -142,7 +154,7 @@ function ordered(jobs: readonly SessionJob[]): SessionJob[] { /** One job row plus, when observable and expanded, its live output panel. */ function JobItem({ job, view, expanded, now, onToggle, t }: { - job: SessionJob + job: JobView view: ObservedJob | undefined expanded: boolean /** Clock sample live rows derive their running duration from. */ @@ -152,6 +164,7 @@ function JobItem({ job, view, expanded, now, onToggle, t }: { }) { const live = isLive(job) const status = statusLabel(job.status, t) + const detail = jobDetail(job) const observable = isObservable(job) const labels = useMemo(() => terminalLabels(t), [t]) const elapsed = live ? now - job.startedAt : (job.finishedAt ?? job.startedAt) - job.startedAt @@ -175,10 +188,11 @@ function JobItem({ job, view, expanded, now, onToggle, t }: {
        {job.kind} - {job.detail ?? status} + {detail ?? status} - {observable ? : null} + {/* A live row is always observable: its output may still arrive. */} + ) : ( @@ -186,7 +200,7 @@ function JobItem({ job, view, expanded, now, onToggle, t }: { {job.kind} {job.label} - {job.detail ?? status} + {detail ?? status} {durationCell} {observable ? : null} @@ -206,7 +220,7 @@ function JobItem({ job, view, expanded, now, onToggle, t }: { ) : ( - + {body} )} @@ -237,18 +251,19 @@ function JobItem({ job, view, expanded, now, onToggle, t }: { } /** - * Session-header entry point for this session's background jobs. It renders - * nothing at all until the session can see at least one job; expanding an - * observable row (a job that declared a record) starts its observation - * stream, and collapsing (or closing the popover) stops it — output only - * flows while someone is watching. - * @param props - runtime slot currency, the session and job-output snapshot - * hooks, the observation control, and the namespace translator. + * Session-header entry point for this session's background jobs. Mounting it + * keeps the session's roster stream open; it renders nothing at all until the + * session can see at least one job. Expanding an observable row (a live job, + * or a settled one with retained output) starts its observation stream, and + * collapsing (or closing the popover) stops it — output only flows while + * someone is watching. + * @param props - runtime slot currency, the jobs snapshot hook, the roster + * and observation controls, and the namespace translator. * @returns the trigger and its popover list, or null when there is nothing to show. */ -export function JobListAction({ sessionId, useSessions, useJobOutput, observe, t }: JobListActionProps) { - const jobs = useSessions(state => state.jobsBySession[sessionId]) ?? NO_JOBS - const observedViews = useJobOutput(state => state.observed) +export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: JobListActionProps) { + const jobs = useJobs(state => state.rows[sessionId]) ?? NO_JOBS + const observedViews = useJobs(state => state.observed) const [open, setOpen] = useState(false) const [expandedKey, setExpandedKey] = useState(undefined) const [now, setNow] = useState(() => Date.now()) @@ -265,6 +280,10 @@ export function JobListAction({ sessionId, useSessions, useJobOutput, observe, t useDismissOnOutsidePointer(rootRef, open, setOpen) + // The roster follows the mounted session: one stream while this control + // lives, released with it. + useEffect(() => watchRows(sessionId), [sessionId, watchRows]) + // The clock only runs while an open list is showing something that moves. useEffect(() => { if (!open || liveRows.length === 0) return @@ -337,7 +356,7 @@ export function JobListAction({ sessionId, useSessions, useJobOutput, observe, t triggerRef.current?.focus() } - const item = (job: SessionJob) => ( + const item = (job: JobView) => ( ({ - hooks: { jobOutput: ctx.jobOutput.state }, - observe: (sessionId, id) => ctx.jobOutput.observe(sessionId, id), + hooks: { jobs: ctx.jobs.state }, + watchRows: sessionId => ctx.jobs.watchRows(sessionId), + observe: (sessionId, id) => ctx.jobs.observe(sessionId, id), }), }, JobListAction), ) diff --git a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts index 91a9521ca7..2c70dfe40f 100644 --- a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts @@ -19,8 +19,10 @@ function headerEntryIds(ctx: Context): (string | undefined)[] { .map(entry => entry.options.id) } -/** Observation requests handed to the stubbed job-output control. */ +/** Observation requests handed to the stubbed jobs service. */ const observed: [string | undefined, string][] = [] +/** Roster watches handed to the stubbed jobs service. */ +const watched: string[] = [] /** Boot the browser half over a real slot tree that declares the header list. */ async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { @@ -32,9 +34,12 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null) - ctx.provide('sessions', {} as never) - ctx.provide('jobOutput', { - state: { getSnapshot: () => ({ observed: {} }), subscribe: () => () => {} }, + ctx.provide('jobs', { + state: { getSnapshot: () => ({ rows: {}, observed: {} }), subscribe: () => () => {} }, + watchRows: (sessionId: string) => { + watched.push(sessionId) + return () => {} + }, observe: (sessionId: string | undefined, id: string) => { observed.push([sessionId, id]) return () => {} @@ -57,21 +62,25 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType { it('declares the services it binds', () => { - expect(inject).toEqual(['sessions', 'jobOutput', 'slots', 'locale']) + expect(inject).toEqual(['jobs', 'slots', 'locale']) }) - it('exposes the output source and observation control through the inject face', async () => { + it('exposes the jobs source and the roster and observation controls through the inject face', async () => { const { ctx } = await bench() const entry = ctx.slots .entries('conversation.session.header.actions') .find(candidate => candidate.options.id === 'job-list') const face = (entry as unknown as { inject: () => { - hooks: { jobOutput: unknown } + hooks: { jobs: unknown } + watchRows: (sessionId: string) => () => void observe: (sessionId: string | undefined, id: string) => () => void } }).inject() - expect(face.hooks.jobOutput).toBeDefined() + expect(face.hooks.jobs).toBeDefined() + const release = face.watchRows('session') + expect(watched).toEqual(['session']) + release() const stopper = face.observe('session', 'bash-1') expect(observed).toEqual([['session', 'bash-1']]) stopper() diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index 9a8b9a1edc..a16a00c76f 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -2,8 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' -import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' +import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' import { zh } from '../src/client/locales.ts' @@ -17,36 +16,35 @@ afterEach(() => { const SESSION = 'session' as SessionId const t: JobListActionProps['t'] = makeTranslate(zh) -function job(over: Partial = {}): SessionJob { +/** A running job with nothing retained yet. */ +function job(over: Partial = {}): JobView { return { - id: 'bash-1' as SessionJob['id'], + id: 'bash-1' as JobView['id'], kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 1_700_000_000_000, + output: { total: 0, earliest: 0 }, ...over, } } -/** A job that declared an observation record, so its row offers a panel. */ -function recordJob(over: Partial = {}): SessionJob { - return job({ record: true, ...over }) +/** A job that left retained output behind, so its row offers a panel even once settled. */ +function outputJob(over: Partial = {}): JobView { + return job({ output: { total: 12, earliest: 0 }, ...over }) } function props( - jobs: readonly SessionJob[], + jobs: readonly JobView[], observe: JobListActionProps['observe'] = () => () => {}, observed: Readonly> = {}, + watchRows: JobListActionProps['watchRows'] = () => () => {}, ): JobListActionProps { - const outputState: JobOutputSnapshot = { observed } - function useJobOutput(select: (value: JobOutputSnapshot) => T): T { - return select(outputState) + const jobsState: JobsSnapshot = { rows: jobs.length > 0 ? { [SESSION]: jobs } : {}, observed } + function useJobs(select: (value: JobsSnapshot) => T): T { + return select(jobsState) } - const sessionsState = { jobsBySession: jobs.length > 0 ? { [SESSION]: jobs } : {} } - function useSessions(select: (value: typeof sessionsState) => T): T { - return select(sessionsState) - } - return { sessionId: SESSION, useSessions, useJobOutput, observe, t } as unknown as JobListActionProps + return { sessionId: SESSION, useJobs, watchRows, observe, t } as unknown as JobListActionProps } function openList(): void { @@ -59,10 +57,20 @@ describe('JobListAction visibility', () => { expect(container.innerHTML).toBe('') }) + it('watches the session roster while mounted and releases it on unmount', () => { + const stop = vi.fn() + const watchRows = vi.fn(() => stop) + const { unmount } = render() + expect(watchRows).toHaveBeenCalledWith(SESSION) + expect(stop).not.toHaveBeenCalled() + unmount() + expect(stop).toHaveBeenCalledTimes(1) + }) + it('counts live jobs on the trigger', () => { render() expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined() }) @@ -76,18 +84,24 @@ describe('JobListAction visibility', () => { }) describe('JobListAction rows', () => { - it('renders a record-less job row without expansion affordances', () => { + it('renders a settled job without retained output as a static row', () => { render() - expect(screen.getByRole('button', { name: '1 个后台任务运行中' })).toBeDefined() + expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined() openList() expect(screen.getByText('explore the repo')).toBeDefined() expect(screen.queryByRole('button', { name: zh['row.expandAria'].replace('{label}', 'explore the repo') })).toBeNull() }) - it('shows the live detail line in place of the status word', () => { - render() + it('offers a panel on a live row before any output arrived', () => { + render() + openList() + expect(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'warming up') })).toBeDefined() + }) + + it('shows the live progress line in place of the status word', () => { + render() openList() const list = screen.getByRole('list', { name: zh['list.aria'] }) expect(within(list).getAllByRole('listitem')).toHaveLength(1) @@ -96,7 +110,7 @@ describe('JobListAction rows', () => { }) it('labels each non-empty section and drops the heading of an empty one', () => { - const settled = [job({ id: 'bash-2' as SessionJob['id'], status: 'completed', finishedAt: 1_700_000_012_000 })] + const settled = [job({ id: 'bash-2' as JobView['id'], status: 'completed', finishedAt: 1_700_000_012_000 })] const { rerender } = render() openList() expect(screen.getByText(zh['section.live'])).toBeDefined() @@ -111,9 +125,9 @@ describe('JobListAction rows', () => { vi.useFakeTimers({ now: 1_700_000_020_000 }) render() openList() expect(screen.getByText('5秒')).toBeDefined() @@ -124,10 +138,10 @@ describe('JobListAction rows', () => { expect(screen.getByText('6秒')).toBeDefined() }) - it('lists rows with kind, label, and detail-or-status', () => { + it('lists rows with kind, label, and progress, terminal detail, or status', () => { render() openList() const list = screen.getByRole('list', { name: zh['list.aria'] }) @@ -139,9 +153,9 @@ describe('JobListAction rows', () => { }) it('orders live rows first by start and settled rows newest-first with tie-breaks', () => { - const settled = (id: string, label: string, startedAt: number, finishedAt?: number): SessionJob => + const settled = (id: string, label: string, startedAt: number, finishedAt?: number): JobView => job({ - id: id as SessionJob['id'], + id: id as JobView['id'], label, status: 'completed', startedAt, @@ -153,8 +167,8 @@ describe('JobListAction rows', () => { settled('bash-b', 'settled-b', 90), settled('bash-e', 'settled-e', 95, 50), settled('bash-d', 'settled-d', 10, 200), - job({ id: 'bash-l1' as SessionJob['id'], label: 'live-1', startedAt: 5 }), - job({ id: 'bash-l2' as SessionJob['id'], label: 'live-2', startedAt: 3 }), + job({ id: 'bash-l1' as JobView['id'], label: 'live-1', startedAt: 5 }), + job({ id: 'bash-l2' as JobView['id'], label: 'live-2', startedAt: 3 }), ])} />) openList() const labels = within(screen.getByRole('list', { name: zh['list.aria'] })) @@ -173,14 +187,13 @@ describe('JobListAction observation', () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) const lines = Array.from({ length: 24 }, (_value, index) => `line ${index + 1}`).join('\n') const view: ObservedJob = { - jobId: 'bash-1' as SessionJob['id'], + jobId: 'bash-1' as JobView['id'], text: lines, gapBefore: false, - status: 'killed', streaming: false, } render() @@ -204,7 +217,7 @@ describe('JobListAction observation', () => { const originalWidth = window.innerWidth Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) try { - render() + render() openList() const menu = screen.getByRole('list', { name: zh['list.aria'] }) // 700 - 12 - 440 - 344 = -96: the popover moves left to keep the margin. @@ -225,7 +238,7 @@ describe('JobListAction observation', () => { const originalWidth = window.innerWidth Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) try { - render() + render() openList() const menu = screen.getByRole('list', { name: zh['list.aria'] }) // max(12 - 4, min(0, 700 - 12 - 800 - 4)) = 8: clamped at the left margin. @@ -237,8 +250,8 @@ describe('JobListAction observation', () => { it('ignores other keys while open and closes once the roster empties', () => { const settledPair = [ - recordJob({ id: 'bash-1' as SessionJob['id'], status: 'completed', finishedAt: 2 }), - recordJob({ id: 'bash-2' as SessionJob['id'], status: 'completed', finishedAt: 3 }), + outputJob({ id: 'bash-1' as JobView['id'], status: 'completed', finishedAt: 2 }), + outputJob({ id: 'bash-2' as JobView['id'], status: 'completed', finishedAt: 3 }), ] const { rerender } = render() expect(screen.getByRole('button', { name: '2 个后台任务' })).toBeDefined() @@ -255,13 +268,12 @@ describe('JobListAction observation', () => { const stop = vi.fn() const observe = vi.fn(() => stop) const view: ObservedJob = { - jobId: 'bash-1' as SessionJob['id'], + jobId: 'bash-1' as JobView['id'], text: 'compiling…\n', gapBefore: false, - status: 'running', streaming: true, } - render() + render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) expect(observe).toHaveBeenCalledWith(SESSION, 'bash-1') @@ -271,16 +283,29 @@ describe('JobListAction observation', () => { expect(stop).toHaveBeenCalledTimes(1) }) + it('renders a running panel with the prompt alone for a live row whose output has not arrived', () => { + const view: ObservedJob = { + jobId: 'bash-1' as JobView['id'], + text: '', + gapBefore: false, + streaming: true, + } + const { container } = render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + expect(container.querySelector('[data-running]')).not.toBeNull() + expect(screen.queryByText(zh['terminal.noOutput'])).toBeNull() + }) + it('surfaces retention gaps and stream failures above the panel', () => { const view: ObservedJob = { - jobId: 'bash-1' as SessionJob['id'], + jobId: 'bash-1' as JobView['id'], text: 'tail only', gapBefore: true, - status: 'running', streaming: false, error: 'connection lost', } - render() + render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) expect(screen.getByText(zh['output.gap'])).toBeDefined() @@ -289,7 +314,7 @@ describe('JobListAction observation', () => { it('stops observation when the popover closes via Escape', () => { const stop = vi.fn() - render( stop)} />) + render( stop)} />) openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) fireEvent.keyDown(screen.getByRole('list', { name: zh['list.aria'] }), { key: 'Escape' }) @@ -301,10 +326,10 @@ describe('JobListAction observation', () => { const stop = vi.fn() // One stable observe identity across renders, as the inject face provides. const observe = () => stop - const { rerender } = render() + const { rerender } = render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - rerender() + rerender() expect(stop).toHaveBeenCalledTimes(1) expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'other') })).toBeNull() }) From cbb0ec0296a0b6597aef81b5e8de8a846d4e144a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 17:15:07 +0800 Subject: [PATCH 037/284] docs(jobs): describe the consolidated seam - docs/subsystems/jobs.md (+zh) records JobSpec, JobHandle, JobHooks, JobOutcome, the pull-source types, JobChunk, JobOutputRead, JobView, JobRead, VisibleJobs, and the event types as type-equivalence blocks; the manifest and the cordis catalog link map follow - the jobs, jobs-local, and tool-jobs READMEs (+zh) describe the ring, the caller-bound view, the event stream, and the notice ledger; the architecture table and the adding-a-tool cookbook drop record: true and readOutput - Agent Note 2026-09-03-jobs-seam-consolidation owns the decision; the 2026-09-01 record note and the 2026-08-08 web display note carry Superseded pointers and the display note links the rows spec - regenerated catalogs (cordis, api, inspect, client, tool, config, persistence, doc graphs, module graph) with their Chinese mirrors; the cordis catalog drops the jobOutput exemption and the tool catalog mounts the agent registry for tool-jobs; the client model imports JobView from its leaf so the typert analyzer resolves it --- ...9-01-jobs-absorb-activity-record.i18n.yaml | 4 +- .../2026-09-01-jobs-absorb-activity-record.md | 2 + ...26-09-01-jobs-absorb-activity-record.zh.md | 2 + ...26-09-03-jobs-seam-consolidation.i18n.yaml | 6 + .../2026-09-03-jobs-seam-consolidation.md | 33 ++ .../2026-09-03-jobs-seam-consolidation.zh.md | 33 ++ ...08-08-web-background-job-display.i18n.yaml | 4 +- .../2026-08-08-web-background-job-display.md | 4 +- ...026-08-08-web-background-job-display.zh.md | 4 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 28 +- docs/config-catalog.zh.md | 28 +- docs/cookbook/adding-a-tool.i18n.yaml | 4 +- docs/cookbook/adding-a-tool.md | 2 +- docs/cookbook/adding-a-tool.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 10 +- docs/event-producer-consumer.zh.md | 10 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/subsystems/jobs.i18n.yaml | 4 +- docs/subsystems/jobs.md | 552 +++++++++--------- docs/subsystems/jobs.zh.md | 552 +++++++++--------- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 2 +- docs/tool-catalog.zh.md | 2 +- .../api/job-controller/src/client/model.ts | 3 +- .../extensions/tool-cordis/src/api-catalog.ts | 179 +++--- packages/jobs/jobs-local/README.i18n.yaml | 4 +- packages/jobs/jobs-local/README.md | 28 +- packages/jobs/jobs-local/README.zh.md | 28 +- packages/jobs/jobs-local/src/events.ts | 2 + packages/jobs/jobs/README.i18n.yaml | 4 +- packages/jobs/jobs/README.md | 25 +- packages/jobs/jobs/README.zh.md | 25 +- packages/jobs/tool-jobs/README.i18n.yaml | 4 +- packages/jobs/tool-jobs/README.md | 17 +- packages/jobs/tool-jobs/README.zh.md | 17 +- pnpm-lock.yaml | 3 - scripts/gen-cordis-catalog.ts | 31 +- scripts/gen-tool-catalog.ts | 3 +- scripts/type-equiv.manifest.json | 50 +- 49 files changed, 929 insertions(+), 820 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md create mode 100644 .agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml index e895f7c65b..491a988b1f 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md -2026-09-01-jobs-absorb-activity-record.md: 2f2bf7a65b5c26c80ec7ddc307c0f31a87f4de54 -2026-09-01-jobs-absorb-activity-record.zh.md: ed62a04e8f9da9de35bd721cceee3a2e56644cdb +2026-09-01-jobs-absorb-activity-record.md: a1da1ba6f47dd0f5d557542d6b9668d6723fd683 +2026-09-01-jobs-absorb-activity-record.zh.md: fd735f1d9764dc83c0469209e241d47176584f5a diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md index 2f2bf7a65b..a1da1ba6f4 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -2,6 +2,8 @@ Status: implemented +Superseded: the record declaration (`JobStart.record`, `RecordingJob`, `readRecord`, `pumpJobOutput`) and the split wire (`SessionJob.record` on the session control stream, `ctx.jobOutput`) described below were consolidated into one output ring, `VisibleJobs`, and `ctx.jobs` — see [the jobs seam consolidation](2026-09-03-jobs-seam-consolidation.md). The argument for absorbing the standalone activity seam into `ctx.jobs` still holds. + English | [中文](2026-09-01-jobs-absorb-activity-record.zh.md) ## Problem diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md index ed62a04e8f..fd735f1d97 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -2,6 +2,8 @@ Status: implemented +Superseded:下文描述的 record 声明(`JobStart.record`、`RecordingJob`、`readRecord`、`pumpJobOutput`)与拆分的线路(会话控制流上的 `SessionJob.record`、`ctx.jobOutput`)已收敛为一个输出环、`VisibleJobs` 与 `ctx.jobs`——见 [jobs seam 收敛](2026-09-03-jobs-seam-consolidation.zh.md)。把独立 activity seam 吸收进 `ctx.jobs` 的论证仍然成立。 + [English](2026-09-01-jobs-absorb-activity-record.md) | 中文 ## 问题 diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml new file mode 100644 index 0000000000..c8e9b6a942 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md +2026-09-03-jobs-seam-consolidation.md: cae6463cee7a2e2a07f66baf759247aaef2afef9 +2026-09-03-jobs-seam-consolidation.zh.md: e87cf61d32e49f74b33e53bf246ccbc64b403baa diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md new file mode 100644 index 0000000000..cae6463cee --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md @@ -0,0 +1,33 @@ +# Agent Note: One output ring, one projection, one event stream for background jobs + +Status: implemented + +English | [中文](2026-09-03-jobs-seam-consolidation.zh.md) + +## Problem + +The job seam had grown by accretion. Producers declared their output twice — a consuming `readOutput` hook the model read and an optional `record: true` ring observers read — and the registry kept two vocabularies for one job (`JobSnapshot` for the model, `SessionJob` on the session control stream, `JobWireChunk` on the observation wire). Three listener families (`onJobDone`, `onJobsChanged`, `onOutput`) delivered overlapping facts with different owner filters, and the registry itself tracked `reported`, a bit that only the model-facing tool could interpret. Review of the merged design called the interface layer the weakest part: a reader could not tell which of `read`, `readRecord`, `JobStart`, `RecordingJob`, and `updateDetail` was the contract and which was an accident of history. + +## Decision + +- **One ring per job.** `JobSpec` replaces `JobStart`: a spec names optional pull `output` sources (`JobOutputSource`, the subprocess `readFrom` family) that the registry pumps at its own cadence (`pumpPollMs` on `dsh-jobs-local`), and every producer receives a `JobHandle` whose `append` pushes chunks into the same ring. The model's consuming read and every observer's absolute-offset read serve the same bytes; `readOutput` and the `record` discriminator are gone. Shell producers hand the registry their `observed` offset readers and fold sandbox facts into the terminal `detail`; the terminal producer adapts its consuming send reader through a byte-counting source; the subagent returns its answer as `JobOutcome.result`, which the first read after settlement carries once. +- **Two cursors, one storage.** `VisibleJobs.read(id)` is the stateful model cursor; `VisibleJobs.readAt(id, from)` is the stateless observer read. Model reads exclude `log` chunks (producer narration for observers) and render stdout, then one `[stderr]` section, exactly as the shell tools always did. +- **`updateProgress` replaces `updateDetail`.** `JobView.progress` is the live line and is cleared at settlement; `JobView.detail` is the terminal reason, with the model's `job_kill` reason merged in. +- **One projection.** `JobView` (client-safe leaf `@deepseek-ai/dsh-jobs/view`) is what the model tools, the browser roster, and the observation frames consume; `owner` is the session id, never the `Agent`. +- **One caller-bound view.** `JobRegistry.visibleTo(caller?)` binds a session once and returns `list`, `get`, `read`, `readAt`, `kill`, and `wait`; every method throws for an id outside the caller's set without distinguishing unknown from foreign. +- **One event stream.** `events.subscribe(filter, listener)` with `{ owner }`, `{ owners: 'scope' }`, or `{ owners: 'all' }` replaces the three listener families. `settled` names its cause (`producer`, `kill`, `teardown`); `output` carries only the id and the new total. +- **The notice ledger moves to `dsh-tool-jobs`.** The registry no longer tracks `reported`. The tool claims a job when a wait starts (and withdraws on timeout or abort) and when `job_kill` is accepted; the settlement event drops the entry, teardown settlements and unowned jobs are skipped, and the destination is the agent registered for the owner session at settlement. +- **The roster leaves the session control stream.** `dsh-api-job-controller` owns `job.rows({ sessionId })` — whole-set frames after each coalesced burst of lifecycle commits, never per append — beside `job.observe`, and its client installs `ctx.jobs` (`watchRows`, `observe`, one snapshot). `dsh-api-session-controller` carries queues and projections only; `ui-jobs` watches its session's roster while mounted, and a row is expandable while live or once it left retained output behind. + +`attachController` and the owner-relative `servesOwner` gate stay as they were; whether a producer may offer `run_in_background` without a mounted controller is deferred, since the gap predates this change. + +## Alternatives considered + +- **Keep three listeners with a shared owner filter.** It preserves the accreted names but not the property reviewers asked for — one place to learn what the registry announces — and `onJobDone`'s exact-owner delivery is what forced the registry to keep an `Agent` in every projection. +- **Keep `reported` in the registry.** Only the tool knows which deliveries reach the model (a wait's tool result, a kill's acknowledgement); the registry could only approximate it, and the approximation was the source of the double-notice and missed-notice bugs the review listed. +- **Keep the roster on the session control stream.** It saved one Remote stream but coupled the session controller to the job registry and mirrored a `record` flag the ring's byte count now answers directly (`output.total`). +- **Serve `log` chunks to the model too.** Producer narration is for observers; the model already receives the same facts through `progress` and the terminal `detail`. + +## Consequences + +The model-visible text is unchanged: status lines, tool descriptions and schemas, and notice wording are byte-identical, and the only new model-visible fact is the kill reason a `killed` status line now carries. A producer that needs to stream output picks pull sources or `append`; it never formats a consuming delta. The Web client renders every live job as expandable, so a job that never writes shows an empty running panel instead of a static row. `JobKindMap` merges are host-side, so a browser program types `JobView.kind` as the kinds it knows and treats the rest as opaque strings, which is all the roster does with it. diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md new file mode 100644 index 0000000000..e87cf61d32 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md @@ -0,0 +1,33 @@ +# Agent Note:后台 job 的一个输出环、一个投影、一条事件流 + +Status: implemented + +[English](2026-09-03-jobs-seam-consolidation.md) | 中文 + +## 问题 + +job seam 是逐步堆出来的。生产者要把输出声明两次——模型读取的消耗式 `readOutput` 钩子,以及观察者读取的可选 `record: true` 环——注册表也为同一个 job 保留了两套词汇(模型侧的 `JobSnapshot`、会话控制流上的 `SessionJob`、观测线路上的 `JobWireChunk`)。三个监听器家族(`onJobDone`、`onJobsChanged`、`onOutput`)用不同的拥有者过滤投递彼此重叠的事实,注册表自己还跟踪着只有模型侧工具才解释得了的 `reported` 位。合并后设计的评审把接口层称作最薄弱的部分:读者分不清 `read`、`readRecord`、`JobStart`、`RecordingJob` 与 `updateDetail` 里哪些是约定、哪些是历史遗留。 + +## 决策 + +- **每个 job 一个环。** `JobSpec` 取代 `JobStart`:spec 声明可选的拉取式 `output` 源(`JobOutputSource`,子进程的 `readFrom` 家族),由注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)泵送;每个生产者都收到一个 `JobHandle`,其 `append` 把块推进同一个环。模型的消耗式读取与每个观察者的绝对偏移读取服务同一批字节;`readOutput` 与 `record` 判别字段被移除。shell 生产者把它们的 `observed` 偏移读取器交给注册表,并把沙箱事实折进终态 `detail`;terminal 生产者通过一个计字节的源适配其消耗式发送读取器;subagent 把回答作为 `JobOutcome.result` 返回,结算后的第一次读取携带它一次。 +- **两个游标,一份存储。** `VisibleJobs.read(id)` 是有状态的模型游标;`VisibleJobs.readAt(id, from)` 是无状态的观察者读取。模型读取排除 `log` 块(给观察者的生产者叙述),并渲染 stdout,再接一段 `[stderr]`,与 shell 工具一贯的做法完全一致。 +- **`updateProgress` 取代 `updateDetail`。** `JobView.progress` 是实时行,结算时清除;`JobView.detail` 是终态原因,合并了模型 `job_kill` 给出的原因。 +- **一个投影。** `JobView`(客户端安全叶子 `@deepseek-ai/dsh-jobs/view`)是模型工具、浏览器名册与观测帧共同消费的形状;`owner` 是会话 id,从不是 `Agent`。 +- **一个绑定调用方的视图。** `JobRegistry.visibleTo(caller?)` 绑定一次会话并返回 `list`、`get`、`read`、`readAt`、`kill` 与 `wait`;每个方法对调用方集合之外的 id 抛出,不区分未知与他人。 +- **一条事件流。** 带 `{ owner }`、`{ owners: 'scope' }` 或 `{ owners: 'all' }` 过滤的 `events.subscribe(filter, listener)` 取代三个监听器家族。`settled` 标出原因(`producer`、`kill`、`teardown`);`output` 只携带 id 与新的 total。 +- **播报台账移到 `dsh-tool-jobs`。** 注册表不再跟踪 `reported`。工具在等待开始时认领 job(超时或中止则撤回),在 `job_kill` 被接受时也认领;结算事件删除条目,teardown 结算与无主 job 被跳过,投递目标是结算时登记在拥有者会话下的 agent。 +- **名册离开会话控制流。** `dsh-api-job-controller` 在 `job.observe` 旁拥有 `job.rows({ sessionId })`——每轮聚合过的生命周期提交之后发一次整集帧,从不按追加发——其客户端安装 `ctx.jobs`(`watchRows`、`observe`、一份快照)。`dsh-api-session-controller` 只承载队列与投影;`ui-jobs` 在挂载期间关注其会话的名册,行在 job 进行中或留有保留输出时可展开。 + +`attachController` 与相对拥有者的 `servesOwner` 门保持原样;没有挂载控制器的生产者是否可以提供 `run_in_background`,因为这个缺口早于本次变更,暂不处理。 + +## 考虑过的替代方案 + +- **保留三个监听器并共享一个拥有者过滤。** 它保住了堆出来的名字,却保不住评审要的性质——一个地方就能学会注册表宣布什么——而且 `onJobDone` 的精确拥有者投递正是迫使注册表在每个投影里保留 `Agent` 的原因。 +- **把 `reported`留在注册表。** 只有工具知道哪些投递到达了模型(等待的工具结果、kill 的确认);注册表只能近似,而这个近似正是评审列出的重复播报与漏播报 bug 的来源。 +- **把名册留在会话控制流上。** 省了一条 Remote 流,却把会话控制器耦合到 job 注册表,还镜像了一个环的字节数如今直接回答的 `record` 标志(`output.total`)。 +- **也把 `log` 块交给模型。** 生产者叙述是给观察者的;模型已经通过 `progress` 与终态 `detail` 收到同样的事实。 + +## 后果 + +模型可见文本保持不变:状态行、工具描述与 schema、播报措辞逐字节相同,唯一新增的模型可见事实是 `killed` 状态行现在携带的 kill 原因。需要流式输出的生产者选择拉取源或 `append`,从不格式化消耗式增量。Web 客户端把每个进行中的 job 渲染为可展开,因此从不写输出的 job 显示为空的运行中面板而非静态行。`JobKindMap` 的合并发生在宿主侧,浏览器程序把 `JobView.kind` 视为它认识的 kind 集合,其余当作不透明字符串——名册对它也只做这些。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index ee733d059e..8e24662bc9 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 7787466154d65ba0970010c3fdaf964ae6d8507f -2026-08-08-web-background-job-display.zh.md: 478acdf00936baed1700d18db2f63b1da1749e37 +2026-08-08-web-background-job-display.md: d2440ea63fc1d9c7f1829fa18b3054a8ea3768a5 +2026-08-08-web-background-job-display.zh.md: e5e55c95ce94f211ee6d1ac7f3391ab9a453c1a6 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 7787466154..d2440ea63f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -2,6 +2,8 @@ Status: implemented +Superseded: the roster this note put on the session control stream (`jobsBySession`, `onJobsChanged`) now streams from the job controller's `job.rows` into `ctx.jobs` — see [the jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md). The display decisions below (one roster, sections, durations, no kill control) still hold. + Update: the output phase this note deferred now ships as the per-job observation record on `ctx.jobs` — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). English | [中文](2026-08-08-web-background-job-display.zh.md) @@ -119,7 +121,7 @@ A running one-shot background subagent therefore appears both there and in the s The [web e2e scenario](../../../../apps/web/tests/background-job-list.e2e.ts) is the end-to-end proof and runs keyless: a real `run_in_background` bash call registers with `ctx.jobs`, the header count and row appear with no user interaction, and killing the task through the registry flips the open list to its producer detail. It asserts the whole delivery path rather than any single layer. -Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) pins the complete baseline, three change pushes, dropped internal fields, unowned fan-out, no-resume guarantee, registry-absent composition, and the prohibition on consuming model output; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior. +Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`rows`](../../../../packages/api/job-controller/tests/rows.host.spec.ts) pins the roster stream that replaced the control-stream fan-out: the complete visible set on open, a refresh after each lifecycle commit and none per append, owner removal, and a clean abort; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 478acdf009..e5e55c95ce 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -2,6 +2,8 @@ Status: implemented +Superseded:本文放在会话控制流上的名册(`jobsBySession`、`onJobsChanged`)现在从 job 控制器的 `job.rows` 流入 `ctx.jobs`——见 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)。下文的展示决策(单一名册、分组、时长、没有 kill 控件)仍然成立。 + Update:本文推迟的输出阶段现已作为 `ctx.jobs` 上的逐 job 观测 record 交付——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 [English](2026-08-08-web-background-job-display.md) | 中文 @@ -119,7 +121,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void [web e2e 场景](../../../../apps/web/tests/background-job-list.e2e.ts)是端到端的证据,且无需密钥:一次真实的 `run_in_background` bash 调用注册进 `ctx.jobs`,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。 -在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) 钉住完整 baseline、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证、没有注册表的组合,以及不得消费模型输出;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。 +在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`rows`](../../../../packages/api/job-controller/tests/rows.host.spec.ts) 钉住取代控制流扇出的名册流:打开时的完整可见集、每次生命周期提交后刷新一次且追加不刷新、拥有者移除,以及干净的中止;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。 ## 影响 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 01617f6564..da0b495dee 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 docs/architecture.md -architecture.md: 825dac9cf44bd39f7297d2323b63b9f2bca6aa46 -architecture.zh.md: ca929d52cb20b97b522bf1ca04ff8046da208583 +architecture.md: 07bb03abca22bcef018aefcde9515d42df9f1b7e +architecture.zh.md: 214927d0ebcf8e76a98fc10911b097827e86c7a4 diff --git a/docs/architecture.md b/docs/architecture.md index 825dac9cf4..07bb03abca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -129,7 +129,7 @@ New behavior attaches to a documented extension point. Changing the loop itself | Add persistent terminal execution | register a `ctx.terminals` backend plus `dsh-tool-terminal` | | Add a human command | register on `ctx.commands`; it dispatches without a model turn | | Add background work | register on `ctx.jobs`; `job_*` tools collect or stop it | -| Stream live output to observers | declare `record: true` on the `ctx.jobs` start; the Web client renders it | +| Stream live output to observers | name pull `output` sources on the `ctx.jobs` spec or push through its `JobHandle`; the Web client renders the ring | | Start a Session from an external webhook | register a trusted rule on `ctx.webhookRuntime` and mount a provider adapter | | Add filesystem access or policy | register a `ctx.fs` provider or listen to `fs/*` events | | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index ca929d52cb..214927d0eb 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -133,7 +133,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 添加持久化终端执行 | 注册 `ctx.terminals` 后端和 `dsh-tool-terminal` | | 添加用户命令 | 在 `ctx.commands` 上注册;它无需模型轮次即可分派 | | 添加后台工作 | 在 `ctx.jobs` 上注册;`job_*` 工具负责收集或停止 | -| 向观察者流式输出 | 在 `ctx.jobs` 启动时声明 `record: true`;Web 客户端负责渲染 | +| 向观察者流式输出 | 在 `ctx.jobs` 的 spec 上声明拉取式 `output` 源,或经其 `JobHandle` 推送;Web 客户端负责渲染输出环 | | 从外部 webhook 启动 Session | 在 `ctx.webhookRuntime` 上注册可信规则,并挂载提供方适配器 | | 添加文件系统访问或策略 | 注册 `ctx.fs` 提供方,或监听 `fs/*` 事件 | | 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 45eda97034..e1becf5027 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: bac3a232db4daaf8f3d251e8bf47e3add1e3e640 -config-catalog.zh.md: 8ad8906e2b558eec8e092971f4ab2aa1d3aec020 +config-catalog.md: 9b6f869107a7a04e282f45af757cdacf50e729b7 +config-catalog.zh.md: e57226dd1ed37f070078894d10c277d914acf437 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index bac3a232db..9b6f869107 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -203,12 +203,12 @@ Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/in ## `@deepseek-ai/dsh-api-job-controller` -Requires: `agents` · `jobs` · `typert` +Requires: `jobs` · `typert` ```ts config-catalog /** Job Controller deployment policy. */ export interface Config { - /** Coalescing window after new record output before an observation read, in milliseconds (default 100). */ + /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */ readonly observeFlushMs?: number /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ readonly observeMaxFrameBytes?: number @@ -948,14 +948,16 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number - /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */ + /** Live ring retention per job in UTF-8 bytes; omission defaults to 262144. */ retainBytes?: number - /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ + /** Ring retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ settledRetainBytes?: number + /** Poll interval for a job's pull sources, in milliseconds; omission defaults to 150. */ + pumpPollMs?: number } ``` -Source: [`packages/jobs/jobs-local/src/index.ts:38`](../packages/jobs/jobs-local/src/index.ts) +Source: [`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts) @@ -2645,12 +2647,10 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv` export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ - recordPollMs?: number } ``` -Source: [`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) +Source: [`packages/shell/tool-bash/src/index.ts:33`](../packages/shell/tool-bash/src/index.ts) @@ -2751,7 +2751,7 @@ Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/s ## `@deepseek-ai/dsh-tool-jobs` -Requires: `tools` · `jobs` · `systemPrompt` +Requires: `tools` · `jobs` · `agents` · `systemPrompt` ```ts config-catalog /** Configures bounded `job_output` waits and completion-notice delivery. */ @@ -2772,14 +2772,14 @@ export interface Config { } /** - * How an unreported completion reaches an owner that is already idle: `wakeup` + * How an uncollected completion reaches an owner that is already idle: `wakeup` * opens a turn for it, `quiet` leaves it pending until something else wakes the * owner. A busy owner is injected either way. */ export type CompletionDelivery = 'quiet' | 'wakeup' ``` -Source: [`packages/jobs/tool-jobs/src/index.ts:31`](../packages/jobs/tool-jobs/src/index.ts) +Source: [`packages/jobs/tool-jobs/src/index.ts:37`](../packages/jobs/tool-jobs/src/index.ts) @@ -2812,12 +2812,10 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv` export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ - recordPollMs?: number } ``` -Source: [`packages/shell/tool-pwsh/src/index.ts:53`](../packages/shell/tool-pwsh/src/index.ts) +Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) @@ -3001,7 +2999,7 @@ export interface Config { } ``` -Source: [`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal/tool-terminal/src/index.ts) +Source: [`packages/terminal/tool-terminal/src/index.ts:36`](../packages/terminal/tool-terminal/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8ad8906e2b..e57226dd1e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -205,12 +205,12 @@ export interface Config { ## `@deepseek-ai/dsh-api-job-controller` -需要:`agents` · `jobs` · `typert` +需要:`jobs` · `typert` ```ts config-catalog /** Job Controller deployment policy. */ export interface Config { - /** Coalescing window after new record output before an observation read, in milliseconds (default 100). */ + /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */ readonly observeFlushMs?: number /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ readonly observeMaxFrameBytes?: number @@ -952,14 +952,16 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number - /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */ + /** Live ring retention per job in UTF-8 bytes; omission defaults to 262144. */ retainBytes?: number - /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ + /** Ring retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ settledRetainBytes?: number + /** Poll interval for a job's pull sources, in milliseconds; omission defaults to 150. */ + pumpPollMs?: number } ``` -来源:[`packages/jobs/jobs-local/src/index.ts:38`](../packages/jobs/jobs-local/src/index.ts) +来源:[`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts) @@ -2649,12 +2651,10 @@ export type TokenMeterConfig = Record export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ - recordPollMs?: number } ``` -来源:[`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) +来源:[`packages/shell/tool-bash/src/index.ts:33`](../packages/shell/tool-bash/src/index.ts) @@ -2755,7 +2755,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-jobs` -需要:`tools` · `jobs` · `systemPrompt` +需要:`tools` · `jobs` · `agents` · `systemPrompt` ```ts config-catalog /** Configures bounded `job_output` waits and completion-notice delivery. */ @@ -2776,14 +2776,14 @@ export interface Config { } /** - * How an unreported completion reaches an owner that is already idle: `wakeup` + * How an uncollected completion reaches an owner that is already idle: `wakeup` * opens a turn for it, `quiet` leaves it pending until something else wakes the * owner. A busy owner is injected either way. */ export type CompletionDelivery = 'quiet' | 'wakeup' ``` -来源:[`packages/jobs/tool-jobs/src/index.ts:31`](../packages/jobs/tool-jobs/src/index.ts) +来源:[`packages/jobs/tool-jobs/src/index.ts:37`](../packages/jobs/tool-jobs/src/index.ts) @@ -2816,12 +2816,10 @@ export interface Config { export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ - recordPollMs?: number } ``` -来源:[`packages/shell/tool-pwsh/src/index.ts:53`](../packages/shell/tool-pwsh/src/index.ts) +来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) @@ -3005,7 +3003,7 @@ export interface Config { } ``` -来源:[`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal/tool-terminal/src/index.ts) +来源:[`packages/terminal/tool-terminal/src/index.ts:36`](../packages/terminal/tool-terminal/src/index.ts) diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 9d02664a90..b7d90c82c1 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -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 docs/cookbook/adding-a-tool.md -adding-a-tool.md: f6b2703f3db69a6abc04dbd0fd9893555eed0602 -adding-a-tool.zh.md: d4831e2be061c2565e88004062cbb1558491bb9d +adding-a-tool.md: e6d1a9702d21420eeb42d743bd26f7738be2c217 +adding-a-tool.zh.md: a0d35f0b2c821333d82170945aa14f913fa7e034 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index f6b2703f3d..e6d1a9702d 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -52,7 +52,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool. S Gate `run_in_background` with producer config, then register through `ctx.jobs.start({ kind, label, owner: exec.agent, run })`. The registry rejects a pre-aborted invocation before the producer body; the runtime validates ownership and task-controller availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. A successful background branch returns a typed canonical handle such as `{ kind: 'background', jobId }`; its Native renderer may keep human prose such as `started background job bash-1`, but PTC mode must never parse that prose to recover the id. -The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.jobs.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `job_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background job runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. +The spec supplies synchronous `cancel`, a non-rejecting `done` that settles after resource cleanup, and either pull `output` sources the registry pumps into the job's output ring or pushes through the `JobHandle` its starter receives; `dsh-tool-jobs` renders the model's consuming read from that ring. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.jobs.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `job_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background job runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. ## Execution policy and observation diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index d4831e2be0..a0d35f0b2c 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -52,7 +52,7 @@ export function apply(ctx: Context) { 通过 producer 配置控制 `run_in_background`,然后使用 `ctx.jobs.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前将已预先中止的调用判为失败;运行时会在 `run()` 启动工作前校验 owner 和任务控制器是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。成功的后台分支会返回类型化的规范句柄,如 `{ kind: 'background', jobId }`;其 Native 渲染器可以保留 `started background job bash-1` 这类供人阅读的自然语言,但 PTC mode 绝不能通过解析该文本取得 id。 -producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.jobs.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `job_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整约定见[后台任务运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与 `dsh-tool-bash`。 +spec 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及由注册表泵入 job 输出环的拉取式 `output` 源或经 starter 收到的 `JobHandle` 推送的追加;模型的消费式读取由 `dsh-tool-jobs` 从该环渲染。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.jobs.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `job_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整约定见[后台任务运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与 `dsh-tool-bash`。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 9d71df140c..fc09aa63be 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: e0b4709875723bd96f9910b1bf518600612f0a12 -event-producer-consumer.zh.md: 0f0fa1317eb5a017daf435648f5c3e36208626e0 +event-producer-consumer.md: 53f524875794bd6f088adc093e68d47816512098 +event-producer-consumer.zh.md: b07570110077da6c3c775d6c1381cf69ef534c70 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e0b4709875..53f5248757 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:551`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:510`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 0f0fa1317e..b075701100 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:551`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:510`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 77ffa1744e..294e9032a4 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: 7d3db9fbf2c078bfcf8ad542cc02cd459c303730 -module-graph.zh.md: 726326b378dccc3b5208b67bf94117fb2f2e3d57 +module-graph.md: ad0a401fe90a7eb7f58b0ca0d81ff311667b0f61 +module-graph.zh.md: a16f710ea2476ef28467e5f6e0a1417c095b80df diff --git a/docs/module-graph.md b/docs/module-graph.md index 7d3db9fbf2..ad0a401fe9 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1048,7 +1048,6 @@ flowchart TD pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_client_connection pkg_api_session_controller --> pkg_file_reference - pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command pkg_api_session_controller --> pkg_scope @@ -1383,7 +1382,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 726326b378..a16f710ea2 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1050,7 +1050,6 @@ flowchart TD pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_client_connection pkg_api_session_controller --> pkg_file_reference - pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command pkg_api_session_controller --> pkg_scope @@ -1385,7 +1384,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index b6e2f8ac7f..32aec02eef 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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 docs/persistence-catalog.md -persistence-catalog.md: 1c0c6919987c691b82c4639aff0f779c95dca83c -persistence-catalog.zh.md: 4cc8ba5b7fc76708a80285013ebcbb03fe3e8e4a +persistence-catalog.md: 55a5596b3f37f31824eac77141abd0f6662ffa33 +persistence-catalog.zh.md: 2971549e08886fe7e39e77daf0785ea9060f9139 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 1c0c691998..55a5596b3f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -512,7 +512,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src 'model/selection': ModelSelection ``` -Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) +Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) ### `permission/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4cc8ba5b7f..2971549e08 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -514,7 +514,7 @@ export type SessionEvent = { 'model/selection': ModelSelection ``` -来源:[`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) ### `permission/*` diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 8e4c26b4bb..f71f76aa0e 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -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 docs/subsystems/jobs.md -jobs.md: 639faa8da3836cb3efc865c494cd8ca8c60f49fb -jobs.zh.md: 3d008671657847520eccd41b8a3bd75014777680 +jobs.md: cdbeb6df0c0f69458bfae89981c8f2b6d3dfc381 +jobs.zh.md: a502fff316a0c2ebccf7888d4eb5aee10de07576 diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 639faa8da3..cdbeb6df0c 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -2,7 +2,7 @@ English | [中文](jobs.zh.md) -Types shared by long-running producers, `ctx.jobs`, and job controls. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the exact fields and variants from [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts). +Types shared by long-running producers, `ctx.jobs`, and job controls. The [seam consolidation Agent Note](../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md) owns the current design and the [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) its origin; this page records the exact fields and variants from [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) and the client-safe [`view.ts`](../../packages/jobs/jobs/src/view.ts) leaf. ## Ids and status @@ -19,63 +19,83 @@ interface JobKindMap { } ``` -`JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobSnapshot.detail`. +`JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobView.progress` while the job runs and in `JobView.detail` once it settled. ## Producer contract -`JobStart` declares identity and a starter, discriminated by `record`: a `RecordingJobStart` receives the `RecordingJob` face, a `PlainJobStart` the `RunningJob` face without `append`. The runtime finishes preflight before calling `run()` with that face and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. +A `JobSpec` declares identity, the owning session, optional pull `output` sources, and a starter. The runtime finishes preflight before calling `run()` with the job's `JobHandle` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, lifecycle state, and the output ring. ```ts type-equiv /** - * A start with the model-facing surfaces only. Its producer face carries no - * `append`, so output the registry would have nowhere to keep is - * unrepresentable rather than dropped. + * Producer declaration passed to {@link JobRegistry.start}. The runtime + * preflights access and cleanup before invoking {@link run}; the producer owns + * execution resources while the runtime owns identity, lifecycle state, and + * the output ring. */ -interface PlainJobStart extends JobStartBase { - record?: undefined +interface JobSpec { + /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ + kind: JobKind + /** One-line model-facing label (the command; the delegation description). */ + label: string + /** + * Owning session. Access is fenced by it, and the owner's live Agent must be + * the one currently registered under that id: its disposal cancels and + * awaits the job. Omitting the owner creates an unowned job, open to any + * caller until service disposal. + */ + owner?: SessionId + /** + * Optional UTF-8 byte cap for each complete model-facing completion notice or + * output read, including controller status metadata. Independent of ring + * retention: it bounds the consuming model surface, never observers. + */ + outputLimitBytes?: number + /** + * Pull sources the registry pumps into the ring at its own cadence. + * Producers that narrate their own progress use {@link JobHandle.append} + * instead; a job may use both. + */ + output?: readonly JobOutputSource[] /** * Start the work after preflight and synchronously return its hooks. Called * once with the job's producer face; a throw leaves nothing registered (the * spent ordinal is skipped), and the producer must clean up any partially * started resources. - * @param job - the issued id plus the live-detail writer. + * @param job - the issued id plus the ring append and progress writers. */ - run(job: RunningJob): JobHooks + run(job: JobHandle): JobHooks } ``` ```ts type-equiv /** - * A start that declares an observable output record beside the model-facing - * surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that - * any number of observers read at absolute byte offsets through - * {@link JobRegistry.readRecord}, and snapshots carry - * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + * Producer face of one registered job, handed to {@link JobSpec.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — writes log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. */ -interface RecordingJobStart extends JobStartBase { - record: true +interface JobHandle { + /** The registry-issued id (`-N`). */ + readonly id: JobId /** - * Start the work after preflight and synchronously return its hooks. Called - * once with the job's producer face; a throw leaves nothing registered (the - * spent ordinal is skipped), and the producer must clean up any partially - * started resources. - * @param job - the issued id plus the record append and live-detail writers. + * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 + * byte length; an empty chunk is dropped without waking observers. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. */ - run(job: RecordingJob): JobHooks + append(text: string, options?: JobAppendOptions): void + /** + * Replace the live progress line (`3/10`, the current phase). Settlement + * clears it; the terminal reason travels in {@link JobOutcome.detail}. + * @param line - the new progress line. + */ + updateProgress(line: string): void } ``` -```ts type-equiv -/** - * Producer declaration passed to {@link JobRegistry.start}, discriminated by - * `record`. The runtime preflights access and cleanup before invoking `run`; - * the producer owns execution resources while the runtime owns identity and - * lifecycle state. - */ -type JobStart = PlainJobStart | RecordingJobStart -``` - -`JobHooks.done` resolves after the producer releases its resources, not merely when work finishes. Optional `readOutput` distinguishes consuming stream jobs from final-output-only jobs. +`JobHooks.done` resolves after the producer releases its resources, not merely when work finishes. A job whose result is a value rather than a stream — a subagent's report, a workflow's rendered result — returns it as `JobOutcome.result`; the model's first read after settlement carries it once. ```ts type-equiv /** Hooks through which the runtime controls and observes producer work. */ @@ -89,18 +109,9 @@ interface JobHooks { * Resolves after the producer releases its resources, not merely when work * finishes. Must not reject; the runtime converts a rejection to `failed`. * If teardown cancellation throws, the runtime may force-fail only the - * registry record without claiming that the work stopped. Settlement also - * ends the record: fold any record-filling pump into this promise so the - * final drain lands before the registry trims and closes the stream. + * registry record without claiming that the work stopped. */ done: Promise - /** - * Consume output produced since the previous call. The producer formats - * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. Independent of the record: this is the - * model-facing projection, {@link JobRegistry.readRecord} the observer one. - */ - readOutput?(): string } ``` @@ -109,74 +120,82 @@ interface JobHooks { interface JobOutcome { /** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ status: 'completed' | 'killed' | 'failed' - /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */ + /** + * Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`). + * When the job settles `killed` after a {@link VisibleJobs.kill} with a + * reason, the registry appends that reason. + */ detail?: string - /** Final output for jobs without `readOutput`; stream jobs leave it unset. */ - output?: string + /** + * Return value for jobs whose result is a value rather than a stream (a + * workflow's rendered result, a subagent's report). The output ring carries + * the stream; this is handed out once by the model's next {@link VisibleJobs.read}. + */ + result?: string } ``` -## Observation record - -A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RecordingJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. Browsers reach the record through `job.observe`, the Remote stream of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below. +A pull source hands the registry a non-consuming offset reader — the subprocess `readFrom` family. The registry pumps every source at its own cadence (`pumpPollMs` on `dsh-jobs-local`) and drains it once more before settlement closes the ring, so producers fold nothing into `done`. ```ts type-equiv /** - * Producer face of one registered job, handed to `run` and valid for the - * job's whole life. All methods are synchronous. Writes staged inside the - * starter call are retained and become visible with the registration commit; - * after settlement — the producer's own outcome, a kill, or a registry-forced - * teardown end — writes log and drop instead of throwing, so a producer's - * trailing flush cannot break its own teardown path. + * A pull source the registry pumps into the job's output ring — the subprocess + * `readFrom` family. The registry owns the cadence and drains every source one + * last time before settlement closes the ring, so a producer folds nothing + * into its `done`. */ -interface RunningJob { - /** The registry-issued id (`-N`). */ - readonly id: JobId - /** - * Replace the snapshot's status detail with a live progress line (`3/10`). - * @param detail - the new detail line. - */ - updateDetail(detail: string): void -} -``` - -```ts type-equiv -/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */ -interface RecordingJob extends RunningJob { - /** - * Append one record chunk. Offsets advance by the chunk's UTF-8 byte - * length; an empty chunk is dropped without waking observers. - * @param text - the chunk text, exactly as produced. - * @param options - stream label and gap marker. - */ - append(text: string, options?: JobAppendOptions): void -} -``` - -```ts type-equiv -/** One retained record chunk returned by {@link JobRegistry.readRecord}. */ -interface JobRecordChunk { - /** Absolute offset of the chunk's first byte. */ - at: number - /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ - text: string - /** Stream label, when the producer supplied one. */ +interface JobOutputSource { + /** Stream label attached to every chunk this source yields. */ channel?: JobChannel - /** Producer-reported loss immediately before this chunk. */ - gapBefore?: true + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, and the lossy flag. + */ + read(fromByte: number): JobSourceRead } ``` ```ts type-equiv -/** Result of one non-consuming {@link JobRegistry.readRecord}. */ -interface JobRecordRead { +/** One incremental read from a {@link JobOutputSource}. */ +interface JobSourceRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean +} +``` + +## The output ring + +Every job owns one bounded ring. Pull sources are pumped into it and `JobHandle.append` pushes land whole; the model consumes the ring through a registry-kept cursor (`VisibleJobs.read`), any number of observers read it at absolute byte offsets (`VisibleJobs.readAt`), and neither disturbs the other. `JobChannel` labels `stdout`, `stderr`, and `log`; `log` is producer narration that reaches observers only, never the model's consuming read. Settlement ends the stream and trims retention to the settled cap — the ring has no separate lifecycle. Browsers reach the roster and the ring through `job.rows` and `job.observe`, the Remote streams of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below. + +```ts type-equiv +/** One chunk of a job's output ring: absolute offset, text, and its provenance. */ +interface JobChunk { + /** Absolute offset of the chunk's first byte; offsets never move once assigned. */ + readonly at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + readonly text: string + /** Stream label, when the producer supplied one. */ + readonly channel?: JobChannel + /** Bytes immediately before this chunk were lost, at the producer or to retention. */ + readonly gapBefore?: true +} +``` + +```ts type-equiv +/** Result of one non-consuming {@link VisibleJobs.readAt}. */ +interface JobOutputRead { /** Retained chunks overlapping `[from, total)`, in offset order. */ - chunks: readonly JobRecordChunk[] + chunks: readonly JobChunk[] /** - * Offset to resume from — the record's current `outputTotal`. Always a - * chunk boundary: appends land whole and trimming only advances chunk - * starts, and consumers concatenate `chunks` under that assumption, so a - * provider serving partial chunks would silently duplicate text. + * Offset to resume from — the ring's current `total`. Always a chunk + * boundary: appends land whole and trimming only advances chunk starts, and + * consumers concatenate `chunks` under that assumption, so a provider + * serving partial chunks would silently duplicate text. */ next: number /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ @@ -186,78 +205,167 @@ interface JobRecordRead { ## Consumer views -Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another reporter has delivered or committed to deliver the terminal state, including the teardown cancel that drains an owner or the service. +`JobView` is the one projection every reader consumes: the model tools, the browser roster, and the observation stream. `owner` carries the session id that fences access; the registry resolves the live `Agent` behind it for lifecycle cleanup and never hands the object out. `progress` is the producer's live line and is cleared at settlement; `detail` is the terminal reason, with a recorded kill reason merged in. ```ts type-equiv /** - * A read-only projection of one job, safe to hand to listeners and tools — - * a fresh object per call, never live registry state. + * Read-only projection of one job — a fresh object per call, never live + * registry state. The model tools, the browser roster, and the observation + * stream all consume this one shape. */ -interface JobSnapshot { +interface JobView { /** The registry-issued id (`-N`). */ - id: JobId + readonly id: JobId /** The producer kind the job was registered with. */ - kind: JobKind + readonly kind: JobKind /** The producer-supplied one-line label. */ - label: string - /** Producer-owned cap for complete model-facing notices and output reads. */ - outputLimitBytes?: number - /** - * Owner session id used for authorization and correlation; absent for - * unowned jobs. Completion listeners receive the exact {@link Agent} - * separately through {@link JobDoneListener}. - */ - ownerSession?: SessionId + readonly label: string + /** Owning session; absent for an unowned job, which every caller can see. */ + readonly owner?: SessionId + /** Producer-owned cap for complete model-facing notices and reads, in UTF-8 bytes. */ + readonly outputLimitBytes?: number /** Current lifecycle state. */ - status: JobStatus - /** - * Kind-specific status detail: live progress while the producer updates it - * through {@link RunningJob.updateDetail}, the terminal detail once settled. - */ - detail?: string + readonly status: JobStatus + /** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */ + readonly progress?: string + /** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */ + readonly detail?: string /** Epoch ms when the job was registered. */ - startedAt: number - /** Epoch ms when the job settled; absent while `running`/`stopping`. */ - finishedAt?: number + readonly startedAt: number + /** Epoch ms when the job settled; absent while live. */ + readonly finishedAt?: number /** - * True when a kill, read, wait, or teardown cancel has reported or committed - * to report the terminal state. Completion reporters suppress redundant - * notices when set. Teardown claims it because the owner or service being - * destroyed leaves no reader: a reporter that opens a turn on notice would - * otherwise spend a model request per teardown layer. + * The output ring's absolute coordinates. `total` is the offset the next + * chunk starts at (0 while nothing was written); `earliest` is the oldest + * retained byte, greater than zero exactly when retention dropped the head. */ - reported: boolean - /** - * Total UTF-8 bytes ever appended to the record — the offset the next chunk - * starts at. Present exactly when the job declared {@link JobStart.record}. - */ - outputTotal?: number - /** - * Offset of the oldest retained record byte. Greater than zero exactly when - * retention dropped the head; a reader starting below it gets a lossy read. - * Present exactly when the job declared {@link JobStart.record}. - */ - outputEarliest?: number + readonly output: { readonly total: number; readonly earliest: number } +} +``` + +`JobRegistry.visibleTo(caller)` binds one caller's identity and returns its operations; a caller sees its own jobs and every unowned job, and every operation throws for an id outside that set. + +```ts type-equiv +/** Output and post-read state returned by the consuming {@link VisibleJobs.read}. */ +interface JobRead { + /** Ring chunks appended since the model cursor, in offset order; every channel included. */ + chunks: readonly JobChunk[] + /** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean + /** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */ + result?: string + /** The job's state at read time. */ + job: JobView } ``` ```ts type-equiv -/** Output and post-read state returned by {@link JobRegistry.read}. */ -interface JobRead { +/** + * The operations one caller may perform, bound by {@link JobRegistry.visibleTo}. + * A caller sees its own jobs and every unowned job; every method throws for + * an id outside that set, without distinguishing unknown from foreign. + */ +interface VisibleJobs { /** - * Stream kinds: the consuming delta since the previous read. Final-output - * kinds: empty while live, the terminal {@link JobOutcome.output} (or - * empty) once settled — idempotent, never consumed. + * List the visible jobs in registration order. + * @returns fresh projections. */ - text: string - /** The job's state at read time. */ - snapshot: JobSnapshot + list(): JobView[] + /** + * Project one job without touching its cursor. + * @param id - job to look up. + * @returns a fresh projection. + */ + get(id: JobId): JobView + /** + * Consume the ring from the model cursor and advance it to the current + * total. After settlement the first read also carries the producer's + * result; later reads return only what was appended since. + * @param id - job to read. + * @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection. + */ + read(id: JobId): JobRead + /** + * Read retained ring output from an absolute byte offset without consuming + * it. Resume by passing a previous read's `next`; an offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Never moves the model cursor. Throws for a negative or non-integer offset. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ + readAt(id: JobId, from: number): JobOutputRead + /** + * Request cancellation, then mark the job stopping. A recorded + * {@link JobKillOptions.reason} merges into the terminal `detail` when the + * job settles `killed`. A producer throw propagates without changing job + * state. + * @param id - job to cancel. + * @param options - cancellation reason. + * @returns `requested` for live work, otherwise `already-finished`. + */ + kill(id: JobId, options?: JobKillOptions): 'requested' | 'already-finished' + /** + * Wait for settlement or timeout without cancelling the job. Caller abort + * rejects only while the job is live; after settlement the terminal + * projection wins. Throws for an invalid timeout. + * @param id - job to wait for. + * @param timeoutMs - positive finite wait bound in milliseconds. + * @param signal - optional cancellation of the wait itself. + * @returns projection at settlement or timeout. + */ + wait(id: JobId, timeoutMs: number, signal?: AbortSignal): Promise } ``` +## Events + +The registry announces every commit through one filtered stream. Lifecycle events carry the projection after the commit they announce; `settled` names its cause so a completion reporter can skip a teardown; `output` carries only the id and the new total, so observers read from their own cursor and the registry never pushes payloads. + +```ts type-equiv +/** + * One lifecycle or output event. Lifecycle events carry the job's projection + * after the commit they announce; `output` carries only the id and the new + * total, so an observer schedules a {@link VisibleJobs.readAt} from its own + * cursor and the registry never pushes payloads. + */ +type JobEvent = + | { + /** Registration commit, progress line change, stopping transition, or removal from the visible set. */ + readonly type: 'registered' | 'progress' | 'stopping' | 'removed' + readonly job: JobView + } + | { + readonly type: 'settled' + readonly job: JobView + readonly cause: JobSettleCause + } + | { + readonly type: 'output' + readonly id: JobId + /** Owning session, absent for an unowned job. */ + readonly owner?: SessionId + /** The ring's total after the append (or at settlement, which ends the stream). */ + readonly total: number + } +``` + +```ts type-equiv +/** + * Who a subscription hears about. `{ owner }` delivers that session's jobs + * plus every unowned job (the set that session can see). `{ owners: 'scope' }` + * delivers the owners composed under the subscribing context — one registry + * serves every composition in the process, and a mount under one preset must + * not hear another preset's agents. `{ owners: 'all' }` delivers everything. + */ +type JobEventFilter = + | { readonly owner: SessionId } + | { readonly owners: 'all' | 'scope' } +``` + ## Service behavior -The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, non-consuming `readRecord`, `kill`, bounded `wait`, failure-isolated `onJobDone`, `onJobsChanged`, and `onOutput` listeners, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity, and `retainBytes` (default 262144) with `settledRetainBytes` (default 16384) bound each declared record's live and settled retention. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer. +The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-bound `visibleTo` (`list`, `get`, the consuming `read`, the non-consuming `readAt`, `kill`, and bounded `wait`), the filtered `events` stream, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the live `Agent` registered under the owner session when the job starts. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity; `retainBytes` (default 262144) and `settledRetainBytes` (default 16384) bound each ring's live and settled retention, and `pumpPollMs` (default 150) is the pull cadence. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer. @@ -275,11 +383,21 @@ Host service backing the generated `ctx.remote.job` namespace. ```ts cordis-catalog /** - * Stream one job's retained record output from an absolute byte offset, - * then its terminal status once settled and drained. Non-consuming: the + * Stream the jobs one session can see — its own plus every unowned job — + * as whole-set frames: one on open, then one after each coalesced burst of + * lifecycle commits. The stream has no natural end; the carrier closes it. + * @param request - the session whose visible set to mirror. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns the roster frames. + */ +@Remote({ mode: 'stream' }) rows(request: JobRowsRequest, signal: AbortSignal): AsyncIterable + +/** + * Stream one job's retained output from an absolute byte offset, then its + * terminal projection once settled and drained. Non-consuming: the * model-facing cursor and notice state never observe these reads. The - * request's session resolves the fenced-read caller; the registry rejects a - * job the session does not own, a job without a record, or an unknown job. + * request's session is the fenced read's caller; the registry rejects a + * job the session cannot see and an unknown job. * @param request - target job, owning session, and optional resume offset. * @param signal - cancellation owned by the Remote stream carrier. * @returns anchor, coalesced output frames, and the terminal status. @@ -293,16 +411,16 @@ Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-cont ### `ctx.jobs` — `JobRegistry` (abstract seam) -Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). +Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). Implementations must honor these semantics: -- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left. +- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left. - Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. -- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously. -- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. -- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected. -- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle. +- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event. +- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: 'scope' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other. +- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle. ```ts cordis-catalog /** @@ -310,120 +428,20 @@ Implementations must honor these semantics: * admission before starting and atomically registering work. Any preflight * rejection leaves no job id or execution resource. A throwing starter * leaves nothing registered; after it returns, registration cannot fail. - * Settlement records the outcome, notifies listeners, and releases waiters. - * @param spec - job identity, owner, and synchronous starter. + * @param spec - job identity, owner, output sources, and synchronous starter. * @returns the registry-issued `-N` id. */ -abstract start(spec: JobStart): JobId +abstract start(spec: JobSpec): JobId /** - * List caller-owned and unowned jobs in registration order without exposing - * another session's labels. - * @param caller - reading agent; a non-agent caller sees only unowned jobs. - * @returns fresh snapshots. + * Bind the caller's identity once and return its operations. A caller sees + * its own jobs and every unowned job; a caller-less view sees unowned jobs + * only. The view resolves visibility on every call, so it stays valid as + * jobs come and go. + * @param caller - the reading session, or undefined for an anonymous caller. + * @returns the operations available to that caller. */ -abstract list(caller?: Agent): JobSnapshot[] - -/** - * Return a non-consuming snapshot without changing its read cursor or notice - * state. Throws for an unknown or foreign job. - * @param id - job to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ -abstract get(id: JobId, caller?: Agent): JobSnapshot - -/** - * Read the next stream delta, or the idempotent final output after settlement. - * A terminal read marks the job reported. Throws for an unknown or foreign - * job. - * @param id - job to read. - * @param caller - reading agent checked against the owner. - * @returns output text and the post-read snapshot. - */ -abstract read(id: JobId, caller?: Agent): JobRead - -/** - * Request cancellation, then mark the job stopping and reported. A producer - * throw propagates without changing job state. Throws for an unknown or - * foreign job. - * @param id - job to cancel. - * @param caller - killing agent checked against the owner. - * @param reason - logged reason forwarded to the producer. - * @returns `requested` for live work, otherwise `already-finished`. - */ -abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished' - -/** - * Wait for settlement or timeout without cancelling the job. Caller abort - * rejects only while the job is live; after settlement the terminal - * snapshot wins so a notice suppressed for this waiter is still delivered. - * Throws for invalid, unknown, or foreign input. - * @param id - job to wait for. - * @param timeoutMs - positive finite wait bound in milliseconds. - * @param caller - waiting agent checked against the owner. - * @param signal - optional cancellation of the wait itself. - * @returns snapshot at settlement or timeout. - */ -abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise - -/** - * Register an effect-scoped completion listener. It receives the settlements - * of the owners its registering context's scope covers; each listener is - * contained; returned promises are observed but not awaited. No listener runs - * after service disposal. - * @param listener - receives each terminal snapshot and its exact owner. - * @returns disposer that unregisters the listener. - */ -abstract onJobDone(listener: JobDoneListener): () => void - -/** - * Register an effect-scoped observer of visible-set changes. It fires after - * every commit that changes what {@link list} returns for that owner — - * registration, every stopping transition (including the one teardown - * performs before it awaits a slow producer), settlement, owner-disposal - * removal, and the emptying that service disposal commits — so an observer - * re-reads rather than accumulating deltas. - * - * Delivery is owner-relative on the same terms as {@link onJobDone}: an - * observer registered from an unscoped context — a host composition's own - * carrier — sees every owner, while one registered under an agent - * composition's scope sees exactly the agents composed under it. - * - * This is not a superset of {@link onJobDone}: that one delivers the terminal - * record under first-wins semantics a job controller couples to notice - * delivery, while this one carries no delivery meaning and marks nothing - * reported. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned job changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ -abstract onJobsChanged(listener: JobsChangedListener): () => void - -/** - * Read retained record output from an absolute byte offset without consuming - * it. Resume by passing a previous read's `next`; a foreign offset inside a - * retained chunk returns that whole chunk (its `at` may precede `from`). - * Never marks the job reported. Throws for an unknown or foreign job, a job - * without a {@link JobStart.record} declaration, or a negative or - * non-integer offset. - * @param id - job to read. - * @param from - absolute byte offset to read from (0 for the retained head). - * @param caller - reading agent checked against the owner. - * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. - */ -abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead - -/** - * Register an effect-scoped observer of record advancement — one signal per - * committed append and one at settlement, carrying only the job id. A - * consumer schedules a {@link readRecord} from its own cursor; the registry - * never pushes payloads. Delivery is owner-relative on the same terms as - * {@link onJobsChanged}. Listeners are contained and never awaited. - * @param listener - receives the id whose record advanced. - * @returns disposer that unregisters the listener. - */ -abstract onOutput(listener: JobOutputListener): () => void +abstract visibleTo(caller?: SessionId): VisibleJobs /** * Attach an effect-scoped controller that can read and stop jobs. It serves the @@ -435,7 +453,7 @@ abstract onOutput(listener: JobOutputListener): () => void abstract attachController(name: string): () => void ``` -Types: [Agent](core.md) +Types: [SessionId](core.md) Source: [`packages/jobs/jobs/src/index.ts`](../../packages/jobs/jobs/src/index.ts) diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 3d00867165..a502fff316 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -2,7 +2,7 @@ [English](jobs.md) | 中文 -长时间运行的生产方、`ctx.jobs` 与任务控制命令共用的类型。[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md) 负责设计;本页记录 [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) 中的确切字段和变体。 +长时间运行的生产方、`ctx.jobs` 与任务控制命令共用的类型。[seam 收敛 Agent Note](../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md) 负责当前设计,[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md) 记录其起源;本页记录 [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) 与客户端安全叶子 [`view.ts`](../../packages/jobs/jobs/src/view.ts) 中的确切字段与变体。 ## ID 与状态 @@ -19,63 +19,83 @@ interface JobKindMap { } ``` -`JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实归入 `JobSnapshot.detail`。 +`JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实在运行期间归入 `JobView.progress`,结算后归入 `JobView.detail`。 ## 生产方约定 -`JobStart` 声明身份和启动器,并按 `record` 判别:`RecordingJobStart` 收到 `RecordingJob` 面,`PlainJobStart` 收到没有 `append` 的 `RunningJob` 面。运行时会在完成预检后携带该面调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。 +`JobSpec` 声明身份、拥有者会话、可选的拉取式 `output` 源与启动器。运行时会在完成预检后携带该 job 的 `JobHandle` 调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问、生命周期状态与输出环。 ```ts type-equiv /** - * A start with the model-facing surfaces only. Its producer face carries no - * `append`, so output the registry would have nowhere to keep is - * unrepresentable rather than dropped. + * Producer declaration passed to {@link JobRegistry.start}. The runtime + * preflights access and cleanup before invoking {@link run}; the producer owns + * execution resources while the runtime owns identity, lifecycle state, and + * the output ring. */ -interface PlainJobStart extends JobStartBase { - record?: undefined +interface JobSpec { + /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ + kind: JobKind + /** One-line model-facing label (the command; the delegation description). */ + label: string + /** + * Owning session. Access is fenced by it, and the owner's live Agent must be + * the one currently registered under that id: its disposal cancels and + * awaits the job. Omitting the owner creates an unowned job, open to any + * caller until service disposal. + */ + owner?: SessionId + /** + * Optional UTF-8 byte cap for each complete model-facing completion notice or + * output read, including controller status metadata. Independent of ring + * retention: it bounds the consuming model surface, never observers. + */ + outputLimitBytes?: number + /** + * Pull sources the registry pumps into the ring at its own cadence. + * Producers that narrate their own progress use {@link JobHandle.append} + * instead; a job may use both. + */ + output?: readonly JobOutputSource[] /** * Start the work after preflight and synchronously return its hooks. Called * once with the job's producer face; a throw leaves nothing registered (the * spent ordinal is skipped), and the producer must clean up any partially * started resources. - * @param job - the issued id plus the live-detail writer. + * @param job - the issued id plus the ring append and progress writers. */ - run(job: RunningJob): JobHooks + run(job: JobHandle): JobHooks } ``` ```ts type-equiv /** - * A start that declares an observable output record beside the model-facing - * surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that - * any number of observers read at absolute byte offsets through - * {@link JobRegistry.readRecord}, and snapshots carry - * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + * Producer face of one registered job, handed to {@link JobSpec.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — writes log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. */ -interface RecordingJobStart extends JobStartBase { - record: true +interface JobHandle { + /** The registry-issued id (`-N`). */ + readonly id: JobId /** - * Start the work after preflight and synchronously return its hooks. Called - * once with the job's producer face; a throw leaves nothing registered (the - * spent ordinal is skipped), and the producer must clean up any partially - * started resources. - * @param job - the issued id plus the record append and live-detail writers. + * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 + * byte length; an empty chunk is dropped without waking observers. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. */ - run(job: RecordingJob): JobHooks + append(text: string, options?: JobAppendOptions): void + /** + * Replace the live progress line (`3/10`, the current phase). Settlement + * clears it; the terminal reason travels in {@link JobOutcome.detail}. + * @param line - the new progress line. + */ + updateProgress(line: string): void } ``` -```ts type-equiv -/** - * Producer declaration passed to {@link JobRegistry.start}, discriminated by - * `record`. The runtime preflights access and cleanup before invoking `run`; - * the producer owns execution resources while the runtime owns identity and - * lifecycle state. - */ -type JobStart = PlainJobStart | RecordingJobStart -``` - -`JobHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。 +`JobHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。结果是一个值而非流的 job——subagent 的报告、workflow 渲染出的结果——把它作为 `JobOutcome.result` 返回;模型在结算后的第一次读取携带它一次。 ```ts type-equiv /** Hooks through which the runtime controls and observes producer work. */ @@ -89,18 +109,9 @@ interface JobHooks { * Resolves after the producer releases its resources, not merely when work * finishes. Must not reject; the runtime converts a rejection to `failed`. * If teardown cancellation throws, the runtime may force-fail only the - * registry record without claiming that the work stopped. Settlement also - * ends the record: fold any record-filling pump into this promise so the - * final drain lands before the registry trims and closes the stream. + * registry record without claiming that the work stopped. */ done: Promise - /** - * Consume output produced since the previous call. The producer formats - * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. Independent of the record: this is the - * model-facing projection, {@link JobRegistry.readRecord} the observer one. - */ - readOutput?(): string } ``` @@ -109,74 +120,82 @@ interface JobHooks { interface JobOutcome { /** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ status: 'completed' | 'killed' | 'failed' - /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */ + /** + * Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`). + * When the job settles `killed` after a {@link VisibleJobs.kill} with a + * reason, the registry appends that reason. + */ detail?: string - /** Final output for jobs without `readOutput`; stream jobs leave it unset. */ - output?: string + /** + * Return value for jobs whose result is a value rather than a stream (a + * workflow's rendered result, a subagent's report). The output ring carries + * the stream; this is handed out once by the model's next {@link VisibleJobs.read}. + */ + result?: string } ``` -## 观测 record - -声明 `record: true` 的生产方通过 starter 收到的 `RecordingJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。浏览器经 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.observe` 读取 record,其帧类型列在下方该控制器的 Cordis API 一节。 +拉取源把一个非消耗的偏移读取器——子进程的 `readFrom` 家族——交给注册表。注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)泵送每个源,并在结算封环之前再排干一次,因此生产方不需要把任何东西折进 `done`。 ```ts type-equiv /** - * Producer face of one registered job, handed to `run` and valid for the - * job's whole life. All methods are synchronous. Writes staged inside the - * starter call are retained and become visible with the registration commit; - * after settlement — the producer's own outcome, a kill, or a registry-forced - * teardown end — writes log and drop instead of throwing, so a producer's - * trailing flush cannot break its own teardown path. + * A pull source the registry pumps into the job's output ring — the subprocess + * `readFrom` family. The registry owns the cadence and drains every source one + * last time before settlement closes the ring, so a producer folds nothing + * into its `done`. */ -interface RunningJob { - /** The registry-issued id (`-N`). */ - readonly id: JobId - /** - * Replace the snapshot's status detail with a live progress line (`3/10`). - * @param detail - the new detail line. - */ - updateDetail(detail: string): void -} -``` - -```ts type-equiv -/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */ -interface RecordingJob extends RunningJob { - /** - * Append one record chunk. Offsets advance by the chunk's UTF-8 byte - * length; an empty chunk is dropped without waking observers. - * @param text - the chunk text, exactly as produced. - * @param options - stream label and gap marker. - */ - append(text: string, options?: JobAppendOptions): void -} -``` - -```ts type-equiv -/** One retained record chunk returned by {@link JobRegistry.readRecord}. */ -interface JobRecordChunk { - /** Absolute offset of the chunk's first byte. */ - at: number - /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ - text: string - /** Stream label, when the producer supplied one. */ +interface JobOutputSource { + /** Stream label attached to every chunk this source yields. */ channel?: JobChannel - /** Producer-reported loss immediately before this chunk. */ - gapBefore?: true + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, and the lossy flag. + */ + read(fromByte: number): JobSourceRead } ``` ```ts type-equiv -/** Result of one non-consuming {@link JobRegistry.readRecord}. */ -interface JobRecordRead { +/** One incremental read from a {@link JobOutputSource}. */ +interface JobSourceRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean +} +``` + +## 输出环 + +每个 job 拥有一个有界的环。拉取源被泵入其中,`JobHandle.append` 的推送整块落地;模型通过注册表保管的游标(`VisibleJobs.read`)消耗该环,任意数量的观察者按绝对字节偏移读取它(`VisibleJobs.readAt`),二者互不干扰。`JobChannel` 标记 `stdout`、`stderr` 与 `log`;`log` 是只到达观察者、从不进入模型消耗式读取的生产方叙述。结算即封流并把保留量裁剪到结算上限——环没有独立的生命周期。浏览器通过 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.rows` 与 `job.observe` 触达名册与环,其帧列于下文的 Cordis API 一节。 + +```ts type-equiv +/** One chunk of a job's output ring: absolute offset, text, and its provenance. */ +interface JobChunk { + /** Absolute offset of the chunk's first byte; offsets never move once assigned. */ + readonly at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + readonly text: string + /** Stream label, when the producer supplied one. */ + readonly channel?: JobChannel + /** Bytes immediately before this chunk were lost, at the producer or to retention. */ + readonly gapBefore?: true +} +``` + +```ts type-equiv +/** Result of one non-consuming {@link VisibleJobs.readAt}. */ +interface JobOutputRead { /** Retained chunks overlapping `[from, total)`, in offset order. */ - chunks: readonly JobRecordChunk[] + chunks: readonly JobChunk[] /** - * Offset to resume from — the record's current `outputTotal`. Always a - * chunk boundary: appends land whole and trimming only advances chunk - * starts, and consumers concatenate `chunks` under that assumption, so a - * provider serving partial chunks would silently duplicate text. + * Offset to resume from — the ring's current `total`. Always a chunk + * boundary: appends land whole and trimming only advances chunk starts, and + * consumers concatenate `chunks` under that assumption, so a provider + * serving partial chunks would silently duplicate text. */ next: number /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ @@ -186,78 +205,167 @@ interface JobRecordRead { ## 消费方视图 -快照是每次新建的只读投影。`ownerSession` 携带用于授权的共享 `SessionId`;完成监听器则会另行收到用于生命周期清理的确切拥有者对象。另一个接口已经交付终止状态或承诺交付时,`reported` 会抑制完成通知;排空 owner 或服务的 teardown 取消同样计入。 +`JobView` 是每个读者都消费的唯一投影:模型工具、浏览器名册与观测流。`owner` 携带划定访问边界的会话 id;注册表为生命周期清理解析其背后的活体 `Agent`,从不把该对象交出去。`progress` 是生产方的实时行,结算时清除;`detail` 是终态原因,合并了记录下来的 kill 原因。 ```ts type-equiv /** - * A read-only projection of one job, safe to hand to listeners and tools — - * a fresh object per call, never live registry state. + * Read-only projection of one job — a fresh object per call, never live + * registry state. The model tools, the browser roster, and the observation + * stream all consume this one shape. */ -interface JobSnapshot { +interface JobView { /** The registry-issued id (`-N`). */ - id: JobId + readonly id: JobId /** The producer kind the job was registered with. */ - kind: JobKind + readonly kind: JobKind /** The producer-supplied one-line label. */ - label: string - /** Producer-owned cap for complete model-facing notices and output reads. */ - outputLimitBytes?: number - /** - * Owner session id used for authorization and correlation; absent for - * unowned jobs. Completion listeners receive the exact {@link Agent} - * separately through {@link JobDoneListener}. - */ - ownerSession?: SessionId + readonly label: string + /** Owning session; absent for an unowned job, which every caller can see. */ + readonly owner?: SessionId + /** Producer-owned cap for complete model-facing notices and reads, in UTF-8 bytes. */ + readonly outputLimitBytes?: number /** Current lifecycle state. */ - status: JobStatus - /** - * Kind-specific status detail: live progress while the producer updates it - * through {@link RunningJob.updateDetail}, the terminal detail once settled. - */ - detail?: string + readonly status: JobStatus + /** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */ + readonly progress?: string + /** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */ + readonly detail?: string /** Epoch ms when the job was registered. */ - startedAt: number - /** Epoch ms when the job settled; absent while `running`/`stopping`. */ - finishedAt?: number + readonly startedAt: number + /** Epoch ms when the job settled; absent while live. */ + readonly finishedAt?: number /** - * True when a kill, read, wait, or teardown cancel has reported or committed - * to report the terminal state. Completion reporters suppress redundant - * notices when set. Teardown claims it because the owner or service being - * destroyed leaves no reader: a reporter that opens a turn on notice would - * otherwise spend a model request per teardown layer. + * The output ring's absolute coordinates. `total` is the offset the next + * chunk starts at (0 while nothing was written); `earliest` is the oldest + * retained byte, greater than zero exactly when retention dropped the head. */ - reported: boolean - /** - * Total UTF-8 bytes ever appended to the record — the offset the next chunk - * starts at. Present exactly when the job declared {@link JobStart.record}. - */ - outputTotal?: number - /** - * Offset of the oldest retained record byte. Greater than zero exactly when - * retention dropped the head; a reader starting below it gets a lossy read. - * Present exactly when the job declared {@link JobStart.record}. - */ - outputEarliest?: number + readonly output: { readonly total: number; readonly earliest: number } +} +``` + +`JobRegistry.visibleTo(caller)` 绑定一个调用方的身份并返回其操作;调用方看得到自己的 job 与所有无主 job,每个操作对该集合之外的 id 抛出。 + +```ts type-equiv +/** Output and post-read state returned by the consuming {@link VisibleJobs.read}. */ +interface JobRead { + /** Ring chunks appended since the model cursor, in offset order; every channel included. */ + chunks: readonly JobChunk[] + /** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean + /** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */ + result?: string + /** The job's state at read time. */ + job: JobView } ``` ```ts type-equiv -/** Output and post-read state returned by {@link JobRegistry.read}. */ -interface JobRead { +/** + * The operations one caller may perform, bound by {@link JobRegistry.visibleTo}. + * A caller sees its own jobs and every unowned job; every method throws for + * an id outside that set, without distinguishing unknown from foreign. + */ +interface VisibleJobs { /** - * Stream kinds: the consuming delta since the previous read. Final-output - * kinds: empty while live, the terminal {@link JobOutcome.output} (or - * empty) once settled — idempotent, never consumed. + * List the visible jobs in registration order. + * @returns fresh projections. */ - text: string - /** The job's state at read time. */ - snapshot: JobSnapshot + list(): JobView[] + /** + * Project one job without touching its cursor. + * @param id - job to look up. + * @returns a fresh projection. + */ + get(id: JobId): JobView + /** + * Consume the ring from the model cursor and advance it to the current + * total. After settlement the first read also carries the producer's + * result; later reads return only what was appended since. + * @param id - job to read. + * @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection. + */ + read(id: JobId): JobRead + /** + * Read retained ring output from an absolute byte offset without consuming + * it. Resume by passing a previous read's `next`; an offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Never moves the model cursor. Throws for a negative or non-integer offset. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ + readAt(id: JobId, from: number): JobOutputRead + /** + * Request cancellation, then mark the job stopping. A recorded + * {@link JobKillOptions.reason} merges into the terminal `detail` when the + * job settles `killed`. A producer throw propagates without changing job + * state. + * @param id - job to cancel. + * @param options - cancellation reason. + * @returns `requested` for live work, otherwise `already-finished`. + */ + kill(id: JobId, options?: JobKillOptions): 'requested' | 'already-finished' + /** + * Wait for settlement or timeout without cancelling the job. Caller abort + * rejects only while the job is live; after settlement the terminal + * projection wins. Throws for an invalid timeout. + * @param id - job to wait for. + * @param timeoutMs - positive finite wait bound in milliseconds. + * @param signal - optional cancellation of the wait itself. + * @returns projection at settlement or timeout. + */ + wait(id: JobId, timeoutMs: number, signal?: AbortSignal): Promise } ``` +## 事件 + +注册表通过一条带过滤的流宣布每次提交。生命周期事件携带其所宣布的提交之后的投影;`settled` 标出原因,完成播报方据此跳过 teardown;`output` 只携带 id 与新的 total,观察者从自己的游标读取,注册表从不推送负载。 + +```ts type-equiv +/** + * One lifecycle or output event. Lifecycle events carry the job's projection + * after the commit they announce; `output` carries only the id and the new + * total, so an observer schedules a {@link VisibleJobs.readAt} from its own + * cursor and the registry never pushes payloads. + */ +type JobEvent = + | { + /** Registration commit, progress line change, stopping transition, or removal from the visible set. */ + readonly type: 'registered' | 'progress' | 'stopping' | 'removed' + readonly job: JobView + } + | { + readonly type: 'settled' + readonly job: JobView + readonly cause: JobSettleCause + } + | { + readonly type: 'output' + readonly id: JobId + /** Owning session, absent for an unowned job. */ + readonly owner?: SessionId + /** The ring's total after the append (or at settlement, which ends the stream). */ + readonly total: number + } +``` + +```ts type-equiv +/** + * Who a subscription hears about. `{ owner }` delivers that session's jobs + * plus every unowned job (the set that session can see). `{ owners: 'scope' }` + * delivers the owners composed under the subscribing context — one registry + * serves every composition in the process, and a mount under one preset must + * not hear another preset's agents. `{ owners: 'all' }` delivers everything. + */ +type JobEventFilter = + | { readonly owner: SessionId } + | { readonly owners: 'all' | 'scope' } +``` + ## 服务行为 -抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定了原子化的 `start`、按调用方划定的 `get` 与 `list`、`read`、非消耗的 `readRecord`、`kill`、有界的 `wait`、故障隔离的 `onJobDone`、`onJobsChanged` 与 `onOutput` 监听器,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是进程本地的 Service Provider。授权比较 owner 会话;owner 清理与准入使用注册在案的确切 `Agent` 实例。本地 provider 的正安全整数配置 `maxConcurrentJobsPerOwner` 默认 `10`,按确切 owner 统计 `running` 加 `stopping` 记录,无主任务共享一个桶;生产方终态结算释放容量,`retainBytes`(默认 262144)与 `settledRetainBytes`(默认 16384)约束每个已声明 record 的运行期与结算后保留量。参见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md)(Service Definition 契约)、[`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md)(注册表生命周期与准入策略)与 [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md)(模型侧 Consumer)。 +抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定了原子化的 `start`、绑定调用方的 `visibleTo`(`list`、`get`、消耗式 `read`、非消耗的 `readAt`、`kill` 与有界的 `wait`)、带过滤的 `events` 流,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是进程本地的 Service Provider。授权比较拥有者会话;拥有者清理与准入使用 job 启动时登记在该拥有者会话下的活体 `Agent`。本地提供方的正安全整数配置 `maxConcurrentJobsPerOwner` 默认为 `10`,按精确拥有者统计 `running` 加 `stopping` 记录,无主任务共享一个桶;生产方的终态结算释放容量;`retainBytes`(默认 262144)与 `settledRetainBytes`(默认 16384)约束每个环的运行期与结算后保留量,`pumpPollMs`(默认 150)是拉取节奏。参见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md) 了解 Service Definition 约定,[`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md) 了解注册表生命周期与准入策略,[`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md) 了解面向模型的 Consumer。 @@ -275,11 +383,21 @@ Host service backing the generated `ctx.remote.job` namespace. ```ts cordis-catalog /** - * Stream one job's retained record output from an absolute byte offset, - * then its terminal status once settled and drained. Non-consuming: the + * Stream the jobs one session can see — its own plus every unowned job — + * as whole-set frames: one on open, then one after each coalesced burst of + * lifecycle commits. The stream has no natural end; the carrier closes it. + * @param request - the session whose visible set to mirror. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns the roster frames. + */ +@Remote({ mode: 'stream' }) rows(request: JobRowsRequest, signal: AbortSignal): AsyncIterable + +/** + * Stream one job's retained output from an absolute byte offset, then its + * terminal projection once settled and drained. Non-consuming: the * model-facing cursor and notice state never observe these reads. The - * request's session resolves the fenced-read caller; the registry rejects a - * job the session does not own, a job without a record, or an unknown job. + * request's session is the fenced read's caller; the registry rejects a + * job the session cannot see and an unknown job. * @param request - target job, owning session, and optional resume offset. * @param signal - cancellation owned by the Remote stream carrier. * @returns anchor, coalesced output frames, and the terminal status. @@ -293,16 +411,16 @@ Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-cont ### `ctx.jobs` — `JobRegistry` (abstract seam) -Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). +Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). Implementations must honor these semantics: -- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left. +- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left. - Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. -- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously. -- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. -- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected. -- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle. +- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event. +- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: 'scope' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other. +- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle. ```ts cordis-catalog /** @@ -310,120 +428,20 @@ Implementations must honor these semantics: * admission before starting and atomically registering work. Any preflight * rejection leaves no job id or execution resource. A throwing starter * leaves nothing registered; after it returns, registration cannot fail. - * Settlement records the outcome, notifies listeners, and releases waiters. - * @param spec - job identity, owner, and synchronous starter. + * @param spec - job identity, owner, output sources, and synchronous starter. * @returns the registry-issued `-N` id. */ -abstract start(spec: JobStart): JobId +abstract start(spec: JobSpec): JobId /** - * List caller-owned and unowned jobs in registration order without exposing - * another session's labels. - * @param caller - reading agent; a non-agent caller sees only unowned jobs. - * @returns fresh snapshots. + * Bind the caller's identity once and return its operations. A caller sees + * its own jobs and every unowned job; a caller-less view sees unowned jobs + * only. The view resolves visibility on every call, so it stays valid as + * jobs come and go. + * @param caller - the reading session, or undefined for an anonymous caller. + * @returns the operations available to that caller. */ -abstract list(caller?: Agent): JobSnapshot[] - -/** - * Return a non-consuming snapshot without changing its read cursor or notice - * state. Throws for an unknown or foreign job. - * @param id - job to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ -abstract get(id: JobId, caller?: Agent): JobSnapshot - -/** - * Read the next stream delta, or the idempotent final output after settlement. - * A terminal read marks the job reported. Throws for an unknown or foreign - * job. - * @param id - job to read. - * @param caller - reading agent checked against the owner. - * @returns output text and the post-read snapshot. - */ -abstract read(id: JobId, caller?: Agent): JobRead - -/** - * Request cancellation, then mark the job stopping and reported. A producer - * throw propagates without changing job state. Throws for an unknown or - * foreign job. - * @param id - job to cancel. - * @param caller - killing agent checked against the owner. - * @param reason - logged reason forwarded to the producer. - * @returns `requested` for live work, otherwise `already-finished`. - */ -abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished' - -/** - * Wait for settlement or timeout without cancelling the job. Caller abort - * rejects only while the job is live; after settlement the terminal - * snapshot wins so a notice suppressed for this waiter is still delivered. - * Throws for invalid, unknown, or foreign input. - * @param id - job to wait for. - * @param timeoutMs - positive finite wait bound in milliseconds. - * @param caller - waiting agent checked against the owner. - * @param signal - optional cancellation of the wait itself. - * @returns snapshot at settlement or timeout. - */ -abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise - -/** - * Register an effect-scoped completion listener. It receives the settlements - * of the owners its registering context's scope covers; each listener is - * contained; returned promises are observed but not awaited. No listener runs - * after service disposal. - * @param listener - receives each terminal snapshot and its exact owner. - * @returns disposer that unregisters the listener. - */ -abstract onJobDone(listener: JobDoneListener): () => void - -/** - * Register an effect-scoped observer of visible-set changes. It fires after - * every commit that changes what {@link list} returns for that owner — - * registration, every stopping transition (including the one teardown - * performs before it awaits a slow producer), settlement, owner-disposal - * removal, and the emptying that service disposal commits — so an observer - * re-reads rather than accumulating deltas. - * - * Delivery is owner-relative on the same terms as {@link onJobDone}: an - * observer registered from an unscoped context — a host composition's own - * carrier — sees every owner, while one registered under an agent - * composition's scope sees exactly the agents composed under it. - * - * This is not a superset of {@link onJobDone}: that one delivers the terminal - * record under first-wins semantics a job controller couples to notice - * delivery, while this one carries no delivery meaning and marks nothing - * reported. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned job changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ -abstract onJobsChanged(listener: JobsChangedListener): () => void - -/** - * Read retained record output from an absolute byte offset without consuming - * it. Resume by passing a previous read's `next`; a foreign offset inside a - * retained chunk returns that whole chunk (its `at` may precede `from`). - * Never marks the job reported. Throws for an unknown or foreign job, a job - * without a {@link JobStart.record} declaration, or a negative or - * non-integer offset. - * @param id - job to read. - * @param from - absolute byte offset to read from (0 for the retained head). - * @param caller - reading agent checked against the owner. - * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. - */ -abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead - -/** - * Register an effect-scoped observer of record advancement — one signal per - * committed append and one at settlement, carrying only the job id. A - * consumer schedules a {@link readRecord} from its own cursor; the registry - * never pushes payloads. Delivery is owner-relative on the same terms as - * {@link onJobsChanged}. Listeners are contained and never awaited. - * @param listener - receives the id whose record advanced. - * @returns disposer that unregisters the listener. - */ -abstract onOutput(listener: JobOutputListener): () => void +abstract visibleTo(caller?: SessionId): VisibleJobs /** * Attach an effect-scoped controller that can read and stop jobs. It serves the @@ -435,7 +453,7 @@ abstract onOutput(listener: JobOutputListener): () => void abstract attachController(name: string): () => void ``` -Types: [Agent](core.zh.md) +Types: [SessionId](core.zh.md) Source: [`packages/jobs/jobs/src/index.ts`](../../packages/jobs/jobs/src/index.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index a70f3d6e35..d862a8995b 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -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 docs/tool-catalog.md -tool-catalog.md: 416585106de28296a3105fafbbe6e4edc0d0f16b -tool-catalog.zh.md: 89d2e467863c09b78805fd9db8ec785ad7936a9d +tool-catalog.md: 5bc212bd17256443ba98141ba5952e17abcfd2fe +tool-catalog.zh.md: fbbc265ac30e461a93fcf2a948797dc0c8ca67ad diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 416585106d..5bc212bd17 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -35,7 +35,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | -| `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | +| `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.agents`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 89d2e46786..fbbc265ac3 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -39,7 +39,7 @@ | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | -| `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | +| `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.agents`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - | diff --git a/packages/api/job-controller/src/client/model.ts b/packages/api/job-controller/src/client/model.ts index 2b58208bb0..cdcc884c9d 100644 --- a/packages/api/job-controller/src/client/model.ts +++ b/packages/api/job-controller/src/client/model.ts @@ -8,8 +8,9 @@ import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { JobView } from '@deepseek-ai/dsh-jobs/view' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { JobObserveFrame, JobView } from '../types.ts' +import type { JobObserveFrame } from '../types.ts' /** Bounded per-job render tail, in UTF-16 code units. */ const RENDER_TAIL_LIMIT = 128 * 1024 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index ff56058008..3537921fee 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1038,9 +1038,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Host service backing the generated `ctx.remote.job` namespace.', description: 'Host service backing the generated `ctx.remote.job` namespace.', methods: [ + { + signature: '@Remote({ mode: \'stream\' }) rows(request: JobRowsRequest, signal: AbortSignal): AsyncIterable', + description: 'Stream the jobs one session can see — its own plus every unowned job — as whole-set frames: one on open, then one after each coalesced burst of lifecycle commits. The stream has no natural end; the carrier closes it.', + parameters: [{ name: 'request', description: 'the session whose visible set to mirror.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], + returns: 'the roster frames.', + }, { signature: '@Remote({ mode: \'stream\' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable', - description: 'Stream one job\'s retained record output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session resolves the fenced-read caller; the registry rejects a job the session does not own, a job without a record, or an unknown job.', + description: 'Stream one job\'s retained output from an absolute byte offset, then its terminal projection once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session is the fenced read\'s caller; the registry rejects a job the session cannot see and an unknown job.', parameters: [{ name: 'request', description: 'target job, owning session, and optional resume offset.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], returns: 'anchor, coalesced output frames, and the terminal status.', }, @@ -1049,67 +1055,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'jobs', summary: 'Abstract background job registry.', - description: 'Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.\n- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected.\n- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle.', + description: 'Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: \'teardown\'`, because a job whose owner is being destroyed has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: \'scope\' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.\n- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model\'s consuming cursor and observers\' absolute offsets read the same bytes and never disturb each other.\n- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle.', methods: [ { - signature: 'abstract start(spec: JobStart): JobId', - description: 'Preflight access, validation, owner cleanup, and implementation-owned admission before starting and atomically registering work. Any preflight rejection leaves no job id or execution resource. A throwing starter leaves nothing registered; after it returns, registration cannot fail. Settlement records the outcome, notifies listeners, and releases waiters.', - parameters: [{ name: 'spec', description: 'job identity, owner, and synchronous starter.' }], + signature: 'abstract readonly events: JobEvents', + description: 'Lifecycle and output events, filtered per subscription.', + parameters: [], + }, + { + signature: 'abstract start(spec: JobSpec): JobId', + description: 'Preflight access, validation, owner cleanup, and implementation-owned admission before starting and atomically registering work. Any preflight rejection leaves no job id or execution resource. A throwing starter leaves nothing registered; after it returns, registration cannot fail.', + parameters: [{ name: 'spec', description: 'job identity, owner, output sources, and synchronous starter.' }], returns: 'the registry-issued `-N` id.', }, { - signature: 'abstract list(caller?: Agent): JobSnapshot[]', - description: 'List caller-owned and unowned jobs in registration order without exposing another session\'s labels.', - parameters: [{ name: 'caller', description: 'reading agent; a non-agent caller sees only unowned jobs.' }], - returns: 'fresh snapshots.', - }, - { - signature: 'abstract get(id: JobId, caller?: Agent): JobSnapshot', - description: 'Return a non-consuming snapshot without changing its read cursor or notice state. Throws for an unknown or foreign job.', - parameters: [{ name: 'id', description: 'job to look up.' }, { name: 'caller', description: 'reading agent checked against the owner.' }], - returns: 'a fresh snapshot.', - }, - { - signature: 'abstract read(id: JobId, caller?: Agent): JobRead', - description: 'Read the next stream delta, or the idempotent final output after settlement. A terminal read marks the job reported. Throws for an unknown or foreign job.', - parameters: [{ name: 'id', description: 'job to read.' }, { name: 'caller', description: 'reading agent checked against the owner.' }], - returns: 'output text and the post-read snapshot.', - }, - { - signature: 'abstract kill(id: JobId, caller?: Agent, reason?: string): \'requested\' | \'already-finished\'', - description: 'Request cancellation, then mark the job stopping and reported. A producer throw propagates without changing job state. Throws for an unknown or foreign job.', - parameters: [{ name: 'id', description: 'job to cancel.' }, { name: 'caller', description: 'killing agent checked against the owner.' }, { name: 'reason', description: 'logged reason forwarded to the producer.' }], - returns: '`requested` for live work, otherwise `already-finished`.', - }, - { - signature: 'abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise', - description: 'Wait for settlement or timeout without cancelling the job. Caller abort rejects only while the job is live; after settlement the terminal snapshot wins so a notice suppressed for this waiter is still delivered. Throws for invalid, unknown, or foreign input.', - parameters: [{ name: 'id', description: 'job to wait for.' }, { name: 'timeoutMs', description: 'positive finite wait bound in milliseconds.' }, { name: 'caller', description: 'waiting agent checked against the owner.' }, { name: 'signal', description: 'optional cancellation of the wait itself.' }], - returns: 'snapshot at settlement or timeout.', - }, - { - signature: 'abstract onJobDone(listener: JobDoneListener): () => void', - description: 'Register an effect-scoped completion listener. It receives the settlements of the owners its registering context\'s scope covers; each listener is contained; returned promises are observed but not awaited. No listener runs after service disposal.', - parameters: [{ name: 'listener', description: 'receives each terminal snapshot and its exact owner.' }], - returns: 'disposer that unregisters the listener.', - }, - { - signature: 'abstract onJobsChanged(listener: JobsChangedListener): () => void', - description: 'Register an effect-scoped observer of visible-set changes. It fires after every commit that changes what list returns for that owner — registration, every stopping transition (including the one teardown performs before it awaits a slow producer), settlement, owner-disposal removal, and the emptying that service disposal commits — so an observer re-reads rather than accumulating deltas.\n\nDelivery is owner-relative on the same terms as onJobDone: an observer registered from an unscoped context — a host composition\'s own carrier — sees every owner, while one registered under an agent composition\'s scope sees exactly the agents composed under it.\n\nThis is not a superset of onJobDone: that one delivers the terminal record under first-wins semantics a job controller couples to notice delivery, while this one carries no delivery meaning and marks nothing reported. Listeners are contained and never awaited.', - parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned job changed and every caller\'s set did.' }], - returns: 'disposer that unregisters the listener.', - }, - { - signature: 'abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead', - description: 'Read retained record output from an absolute byte offset without consuming it. Resume by passing a previous read\'s `next`; a foreign offset inside a retained chunk returns that whole chunk (its `at` may precede `from`). Never marks the job reported. Throws for an unknown or foreign job, a job without a JobStart.record declaration, or a negative or non-integer offset.', - parameters: [{ name: 'id', description: 'job to read.' }, { name: 'from', description: 'absolute byte offset to read from (0 for the retained head).' }, { name: 'caller', description: 'reading agent checked against the owner.' }], - returns: 'retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.', - }, - { - signature: 'abstract onOutput(listener: JobOutputListener): () => void', - description: 'Register an effect-scoped observer of record advancement — one signal per committed append and one at settlement, carrying only the job id. A consumer schedules a readRecord from its own cursor; the registry never pushes payloads. Delivery is owner-relative on the same terms as onJobsChanged. Listeners are contained and never awaited.', - parameters: [{ name: 'listener', description: 'receives the id whose record advanced.' }], - returns: 'disposer that unregisters the listener.', + signature: 'abstract visibleTo(caller?: SessionId): VisibleJobs', + description: 'Bind the caller\'s identity once and return its operations. A caller sees its own jobs and every unowned job; a caller-less view sees unowned jobs only. The view resolves visibility on every call, so it stays valid as jobs come and go.', + parameters: [{ name: 'caller', description: 'the reading session, or undefined for an anonymous caller.' }], + returns: 'the operations available to that caller.', }, { signature: 'abstract attachController(name: string): () => void', @@ -4175,20 +4138,44 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'JobChannel', - declaration: 'export type JobChannel = \'stdout\' | \'stderr\';', + declaration: 'export type JobChannel = \'stdout\' | \'stderr\' | \'log\';', }, { - name: 'JobDoneListener', - declaration: 'export type JobDoneListener = (snapshot: JobSnapshot, owner: Agent | undefined) => void | PromiseLike;', + name: 'JobChunk', + declaration: 'export interface JobChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: JobChannel;\n readonly gapBefore?: true;\n}', + }, + { + name: 'JobEvent', + declaration: 'export type JobEvent = {\n readonly type: \'registered\' | \'progress\' | \'stopping\' | \'removed\';\n readonly job: JobView;\n} | {\n readonly type: \'settled\';\n readonly job: JobView;\n readonly cause: JobSettleCause;\n} | {\n readonly type: \'output\';\n readonly id: JobId;\n readonly owner?: SessionId;\n readonly total: number;\n};', + }, + { + name: 'JobEventFilter', + declaration: 'export type JobEventFilter = {\n readonly owner: SessionId;\n} | {\n readonly owners: \'all\' | \'scope\';\n};', + }, + { + name: 'JobEventListener', + declaration: 'export type JobEventListener = (event: JobEvent) => void;', + }, + { + name: 'JobEvents', + declaration: 'export interface JobEvents {\n subscribe(filter: JobEventFilter, listener: JobEventListener): () => void;\n}', + }, + { + name: 'JobHandle', + declaration: 'export interface JobHandle {\n readonly id: JobId;\n append(text: string, options?: JobAppendOptions): void;\n updateProgress(line: string): void;\n}', }, { name: 'JobHooks', - declaration: 'export interface JobHooks {\n cancel(reason?: string): void;\n done: Promise;\n readOutput?(): string;\n}', + declaration: 'export interface JobHooks {\n cancel(reason?: string): void;\n done: Promise;\n}', }, { name: 'JobId', declaration: 'export type JobId = Branded<\'JobId\'>;', }, + { + name: 'JobKillOptions', + declaration: 'export interface JobKillOptions {\n reason?: string;\n}', + }, { name: 'JobKind', declaration: 'export type JobKind = JobKindMap[keyof JobKindMap];', @@ -4199,55 +4186,55 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'JobObserveFrame', - declaration: 'export type JobObserveFrame = {\n readonly type: \'opened\';\n readonly jobId: JobId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: JobObserveStatus;\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly JobWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: JobObserveStatus;\n readonly detail?: string;\n readonly finishedAt?: number;\n};', + declaration: 'export type JobObserveFrame = {\n readonly type: \'opened\';\n readonly job: JobView;\n readonly from: number;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly JobChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly job: JobView;\n};', }, { name: 'JobObserveRequest', declaration: 'export interface JobObserveRequest {\n readonly sessionId?: SessionId;\n readonly jobId: JobId;\n readonly from?: number;\n}', }, - { - name: 'JobObserveStatus', - declaration: 'export type JobObserveStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';', - }, { name: 'JobOutcome', - declaration: 'export interface JobOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n output?: string;\n}', + declaration: 'export interface JobOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n result?: string;\n}', }, { - name: 'JobOutputListener', - declaration: 'export type JobOutputListener = (id: JobId) => void;', + name: 'JobOutputRead', + declaration: 'export interface JobOutputRead {\n chunks: readonly JobChunk[];\n next: number;\n lossy: boolean;\n}', + }, + { + name: 'JobOutputSource', + declaration: 'export interface JobOutputSource {\n channel?: JobChannel;\n read(fromByte: number): JobSourceRead;\n}', }, { name: 'JobRead', - declaration: 'export interface JobRead {\n text: string;\n snapshot: JobSnapshot;\n}', + declaration: 'export interface JobRead {\n chunks: readonly JobChunk[];\n lossy: boolean;\n result?: string;\n job: JobView;\n}', }, { - name: 'JobRecordChunk', - declaration: 'export interface JobRecordChunk {\n at: number;\n text: string;\n channel?: JobChannel;\n gapBefore?: true;\n}', + name: 'JobRowsFrame', + declaration: 'export interface JobRowsFrame {\n readonly type: \'rows\';\n readonly jobs: readonly JobView[];\n}', }, { - name: 'JobRecordRead', - declaration: 'export interface JobRecordRead {\n chunks: readonly JobRecordChunk[];\n next: number;\n lossy: boolean;\n}', + name: 'JobRowsRequest', + declaration: 'export interface JobRowsRequest {\n readonly sessionId: SessionId;\n}', }, { - name: 'JobsChangedListener', - declaration: 'export type JobsChangedListener = (owner: Agent | undefined) => void;', + name: 'JobSettleCause', + declaration: 'export type JobSettleCause = \'producer\' | \'kill\' | \'teardown\';', }, { - name: 'JobSnapshot', - declaration: 'export interface JobSnapshot {\n id: JobId;\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: JobStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n outputTotal?: number;\n outputEarliest?: number;\n}', + name: 'JobSourceRead', + declaration: 'export interface JobSourceRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n}', }, { - name: 'JobStart', - declaration: 'export type JobStart = PlainJobStart | RecordingJobStart;', + name: 'JobSpec', + declaration: 'export interface JobSpec {\n kind: JobKind;\n label: string;\n owner?: SessionId;\n outputLimitBytes?: number;\n output?: readonly JobOutputSource[];\n run(job: JobHandle): JobHooks;\n}', }, { name: 'JobStatus', declaration: 'export type JobStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';', }, { - name: 'JobWireChunk', - declaration: 'export interface JobWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}', + name: 'JobView', + declaration: 'export interface JobView {\n readonly id: JobId;\n readonly kind: JobKind;\n readonly label: string;\n readonly owner?: SessionId;\n readonly outputLimitBytes?: number;\n readonly status: JobStatus;\n readonly progress?: string;\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly output: {\n readonly total: number;\n readonly earliest: number;\n };\n}', }, { name: 'JsonSchemaNode', @@ -4537,10 +4524,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PermissionSelect', declaration: 'export interface PermissionSelect {\n options: PresetOption[];\n currentValue: string;\n}', }, - { - name: 'PlainJobStart', - declaration: 'export interface PlainJobStart extends JobStartBase {\n record?: undefined;\n run(job: RunningJob): JobHooks;\n}', - }, { name: 'PostToolDecision', declaration: 'export type PostToolDecision = {\n kind: \'accept\';\n content?: ContentBlock[];\n value?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'accept\';\n value: JsonValue;\n content?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'block\';\n feedback: ContentBlock[];\n additionalContexts?: UserMessage[];\n};', @@ -4661,14 +4644,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ReasoningEffortId', declaration: 'export type ReasoningEffortId = Branded<\'ReasoningEffortId\'>;', }, - { - name: 'RecordingJob', - declaration: 'export interface RecordingJob extends RunningJob {\n append(text: string, options?: JobAppendOptions): void;\n}', - }, - { - name: 'RecordingJobStart', - declaration: 'export interface RecordingJobStart extends JobStartBase {\n record: true;\n run(job: RecordingJob): JobHooks;\n}', - }, { name: 'RedactedSecret', declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}', @@ -4749,10 +4724,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RunnerFailureRule', declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}', }, - { - name: 'RunningJob', - declaration: 'export interface RunningJob {\n readonly id: JobId;\n updateDetail(detail: string): void;\n}', - }, { name: 'SandboxEnforcement', declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';', @@ -4863,11 +4834,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionControlBaseline', - declaration: 'export interface SessionControlBaseline {\n readonly queues: Readonly>;\n readonly jobs: Readonly>;\n readonly projections: Readonly>;\n}', + declaration: 'export interface SessionControlBaseline {\n readonly queues: Readonly>;\n readonly projections: Readonly>;\n}', }, { name: 'SessionControlFrame', - declaration: 'export type SessionControlFrame = {\n readonly type: \'baseline\';\n readonly value: SessionControlBaseline;\n} | {\n readonly type: \'queue\';\n readonly sessionId: SessionId;\n readonly items: readonly SessionQueuedItem[];\n} | {\n readonly type: \'jobs\';\n readonly sessionId: SessionId;\n readonly jobs: readonly SessionJob[];\n} | ({\n readonly type: \'projection\';\n} & SessionProjectionUpdate);', + declaration: 'export type SessionControlFrame = {\n readonly type: \'baseline\';\n readonly value: SessionControlBaseline;\n} | {\n readonly type: \'queue\';\n readonly sessionId: SessionId;\n readonly items: readonly SessionQueuedItem[];\n} | ({\n readonly type: \'projection\';\n} & SessionProjectionUpdate);', }, { name: 'SessionCreateRequest', @@ -4997,10 +4968,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionInspection', declaration: 'export interface SessionInspection extends SessionStorageMetadata {\n readonly events: readonly SessionEvent[];\n}', }, - { - name: 'SessionJob', - declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly record?: true;\n}', - }, { name: 'SessionLineageNode', declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n descendants: SessionLineageNode[];\n}', @@ -6009,6 +5976,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'VerifiedWebhookDelivery', declaration: 'export interface VerifiedWebhookDelivery {\n readonly kind: K;\n readonly source: WebhookSourceId;\n readonly deliveryId: WebhookDeliveryId;\n readonly event: WebhookEventOf;\n readonly receivedAt: number;\n}', }, + { + name: 'VisibleJobs', + declaration: 'export interface VisibleJobs {\n list(): JobView[];\n get(id: JobId): JobView;\n read(id: JobId): JobRead;\n readAt(id: JobId, from: number): JobOutputRead;\n kill(id: JobId, options?: JobKillOptions): \'requested\' | \'already-finished\';\n wait(id: JobId, timeoutMs: number, signal?: AbortSignal): Promise;\n}', + }, { name: 'WebBootBatch', declaration: 'export interface WebBootBatch {\n phase: WebBootBatchPhase;\n url: string;\n rev: string;\n entries: string[];\n}', diff --git a/packages/jobs/jobs-local/README.i18n.yaml b/packages/jobs/jobs-local/README.i18n.yaml index 9c5a47c63b..1405d2ee15 100644 --- a/packages/jobs/jobs-local/README.i18n.yaml +++ b/packages/jobs/jobs-local/README.i18n.yaml @@ -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/jobs/jobs-local/README.md -README.md: 07772f3a8de79f4e812e5217843eb770925050fb -README.zh.md: bf728c5b935941ee3bb783866a9dbf09f7865d76 +README.md: 58a35e813af03a00cf978d001a12f26bbb848ff4 +README.zh.md: 486a9cef3813d4d07c328339ee179e08e97c98b3 diff --git a/packages/jobs/jobs-local/README.md b/packages/jobs/jobs-local/README.md index 07772f3a8d..58a35e813a 100644 --- a/packages/jobs/jobs-local/README.md +++ b/packages/jobs/jobs-local/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-jobs-local` runs background jobs inside the harness process: work keeps running while the agent moves on, and the owning agent can read, wait on, list, and cancel it, with completion delivered as an in-session notice when `dsh-tool-jobs` is also mounted. It implements the `dsh-jobs` contract with in-memory records handed out as fresh snapshots, never live state. A per-owner concurrency limit (default 10) bounds how many jobs one agent can have running or stopping at once, and a job that declares a record keeps a bounded in-memory output ring (256 KiB live, trimmed to 16 KiB at settlement by default) that observers read at absolute offsets; jobs die with the harness process and are not durable across restarts. +`dsh-jobs-local` runs background jobs inside the harness process: work keeps running while the agent moves on, and the owning agent can read, wait on, list, and cancel it, with completion delivered as an in-session notice when `dsh-tool-jobs` is also mounted. It implements the `dsh-jobs` contract with in-memory records handed out as fresh projections, never live state. A per-owner concurrency limit (default 10) bounds how many jobs one agent can have running or stopping at once, and every job keeps a bounded in-memory output ring (256 KiB live, trimmed to 16 KiB at settlement) that the registry fills from the producer's pull sources at `pumpPollMs` (default 150 ms) and from pushed appends. ## Table of Contents @@ -33,7 +33,7 @@ Choose it when jobs should live in the harness process and die with it. Avoid it ### Minimal configuration -Loading the plugin registers `ctx.jobs`; `maxConcurrentJobsPerOwner` is optional and defaults to `10`. +Loading the plugin registers `ctx.jobs`; every field is optional. ```yaml - name: '@deepseek-ai/dsh-jobs-local' @@ -42,8 +42,9 @@ Loading the plugin registers `ctx.jobs`; `maxConcurrentJobsPerOwner` is optional | Field | Default | Meaning | |---|---|---| | `maxConcurrentJobsPerOwner` | `10` | Maximum `running` plus `stopping` jobs per exact owner, or in the shared unowned bucket | -| `retainBytes` | `262144` | Live record retention per job, in UTF-8 bytes | -| `settledRetainBytes` | `16384` | Record retention kept after a job settles, in UTF-8 bytes | +| `retainBytes` | `262144` | Live ring retention per job, in UTF-8 bytes | +| `settledRetainBytes` | `16384` | Ring retention kept after a job settles, in UTF-8 bytes | +| `pumpPollMs` | `150` | Poll interval for a job's pull sources, in milliseconds | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-jobs-local) is the exhaustive source for the accepted fields. @@ -71,11 +72,11 @@ This section explains the design decisions behind the registry and points at the ### Design philosophy -- **In-memory records, fresh snapshots.** `LocalJobRegistry` keeps one `TrackedTask` per job — lifecycle state plus the optional record ring — and projects a new read-only snapshot or chunk copy per call; callers never receive live state. -- **Retention is bounded per record.** Appends past the live cap drop the oldest retained chunks (a single oversized chunk keeps its UTF-8-safe tail); a reader below the retained window gets a lossy read, never an error, and settlement trims to the settled cap. -- **Owner-relative layers, one process-wide registry.** Controllers, completion listeners, and change observers are filed into the scope that registered them (`ScopedLayers`), and reads union the global layer with the owner's scope chain — so one preset's job controls never hold `start()` open for an agent whose own composition loads none, and a settlement reaches only the listeners its owner's composition registered. +- **In-memory records, fresh projections.** `LocalJobRegistry` keeps one `TrackedJob` per job — lifecycle state, the output ring, and the model cursor — and projects a new read-only view or chunk copy per call; callers never receive live state. +- **Retention is bounded per ring.** Appends past the live cap drop the oldest retained chunks (a single oversized chunk keeps its UTF-8-safe tail); a reader below the retained window gets a lossy read, never an error, and settlement trims to the settled cap. +- **Owner-relative layers, one process-wide registry.** Controllers and `{ owners: 'scope' }` subscriptions are filed into the scope that registered them (`ScopedLayers`), and reads union the global layer with the owner's scope chain — so one preset's job controls never hold `start()` open for an agent whose own composition loads none, and a scoped settlement reaches only the listeners its owner's composition registered. - **Preflight before start.** `start()` checks controller service, spec validity, live ownership, and capacity before invoking the producer, so a rejection leaves no job id or execution resource; registration commits without a later failable step. -- **First-wins settlement, completion last.** The earliest terminal outcome records once, releases waiters, and notifies listeners once with per-listener containment; completion is announced after the record is committed and the visible-set change published, because a reporter may open a model turn synchronously. +- **First-wins settlement, event last.** The earliest terminal outcome records once, waits for the pump's final drain, trims the ring, releases waiters, and then delivers one `settled` event with per-listener containment, followed by the ring's final `output` signal. - **Teardown never deadlocks.** A throwing cancel force-fails the record and reports a possible orphan instead of stalling disposal. ### Source map @@ -83,19 +84,22 @@ This section explains the design decisions behind the registry and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, `LocalJobRegistry`, admission, lifecycle, teardown | +| [`src/events.ts`](src/events.ts) | Scope-layered event routing: `{ owner }`, `{ owners: 'all' }`, and `{ owners: 'scope' }` subscriptions | +| [`src/ring.ts`](src/ring.ts) | The bounded per-job output ring: append, retention trim, offset reads | +| [`src/pump.ts`](src/pump.ts) | The registry-owned pull pump: one timer per job, final drain before settlement | | — | No runtime invariant companion is published; `@deepseek-ai/dsh-jobs/invariant` owns per-snapshot identity, status, timestamp, and owner checks. This provider's admission decision uses private configuration and must fail before a backend starter runs; `LocalJobRegistry.start()` enforces it synchronously for current producers. Repeating an aggregate after publication would expose private configuration solely to this companion and would not verify the fail-closed pre-start guarantee. | ### Scope layers -`attachController`, `onJobDone`, `onJobsChanged`, and `onOutput` register into the calling context's scope layer. The controller question (`servesOwner`) and listener delivery (`listenersFor`, `changedFor`, `outputFor`) walk the same chain: global layer first, then each scoped layer along the owner's chain. Registrations are anonymous tokens so duplicate labels stay independently disposable. +`attachController` and `{ owners: 'scope' }` subscriptions register into the calling context's scope layer; `{ owner }` and `{ owners: 'all' }` subscriptions are unscoped. The controller question (`servesOwner`) and scoped delivery walk the same chain: global layer first, then each scoped layer along the owner's chain. Registrations are anonymous tokens so duplicate labels stay independently disposable. ### Admission and settlement -`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` marks a job reported when waiters are pending, trims a declared record to the settled cap, resolves every waiter, records the terminal snapshot, announces the visible-set change and the record's final output signal, then notifies completion listeners. Pending waits mark the job reported before listeners run so completion reporters do not duplicate notices; a teardown cancel marks it for the same reason — nothing will read a notice addressed to an owner being destroyed. +`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` records the terminal outcome once (merging a recorded kill reason into a `killed` detail), clears the progress line, trims the ring to the settled cap, resolves every waiter, then emits `settled` with its cause and the ring's final `output` signal. The cause is `kill` after `VisibleJobs.kill`, `teardown` after an owner or service cancel, and `producer` otherwise; `dsh-tool-jobs` uses it to skip notices nobody can read. ### Teardown -Owner disposal (`disposeOwned`) cancels the owner's jobs, awaits their settlement, removes their records, and announces the removal — the one visible-set change no per-job record carries. Service disposal (`disposeAll`) closes listeners, cancels all live jobs, awaits settlement, clears the store, announces the emptying to the distinct owners, then detaches the cross-fiber owner-cleanup effects. +Owner disposal (`disposeOwned`) cancels the owner's jobs, awaits their settlement, removes their records, and announces each removal — the one visible-set change no per-job record carries. Service disposal (`disposeAll`) cancels all live jobs, awaits settlement, removes every record (announcing each removal, so a subscriber mounted outside the service drops its rows), then detaches the cross-fiber owner-cleanup effects. @@ -106,7 +110,7 @@ Owner disposal (`disposeOwned`) cancels the owner's jobs, awaits their settlemen Read these pages when the package-level contract is not enough. They move from the registry contract to the model-facing controls and the design records. -- [Background task runtime subsystem](../../../docs/subsystems/jobs.md) — the job types, snapshot fields, and `ctx.jobs` cordis surface. +- [Background task runtime subsystem](../../../docs/subsystems/jobs.md) — the job types, projection fields, and `ctx.jobs` cordis surface. - [jobs group map](../README.md) — the sibling group page and its package table. - [Registry contract](../jobs/README.md) — the abstract `ctx.jobs` service this package implements. - [Model-facing job controls](../tool-jobs/README.md) — the `job_output`, `job_list`, and `job_kill` tools and completion notices. diff --git a/packages/jobs/jobs-local/README.zh.md b/packages/jobs/jobs-local/README.zh.md index bf728c5b93..486a9cef38 100644 --- a/packages/jobs/jobs-local/README.zh.md +++ b/packages/jobs/jobs-local/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-jobs-local` 在 harness 进程内运行后台任务:工作会在 agent 继续推进的同时保持运行,拥有它的 agent 可以读取、等待、列出和取消它;同时挂载 `dsh-tool-jobs` 时,完成以会话内通知送达。它用内存记录实现 `dsh-jobs` 约定,并且只交出全新快照,从不交出实时状态。按所有者的并发上限(默认 10)约束一个 agent 同时处于运行或停止中的任务数量,声明了 record 的任务另持有一个有界的内存输出环(默认运行期 256 KiB、结算时裁剪到 16 KiB),供观察者按绝对偏移读取;任务会随 harness 进程终止而消失,无法跨重启持久。 +`dsh-jobs-local` 在 harness 进程内运行后台任务:工作会在 agent 继续推进的同时保持运行,拥有它的 agent 可以读取、等待、列出和取消它;同时挂载 `dsh-tool-jobs` 时,完成以会话内通知送达。它用内存记录实现 `dsh-jobs` 约定,并且只交出全新投影,从不交出实时状态。按所有者的并发上限(默认 10)约束一个 agent 同时处于运行或停止中的任务数量;每个 job 保有一个有界的内存输出环(运行期 256 KiB,结算时裁剪到 16 KiB),注册表按 `pumpPollMs`(默认 150 毫秒)从生产方的拉取源以及推送的追加填充它。 ## 目录 @@ -33,7 +33,7 @@ kind: "package-reference" ### 最小配置 -加载插件即注册 `ctx.jobs`;`maxConcurrentJobsPerOwner` 可选,默认为 `10`。 +加载插件即注册 `ctx.jobs`;每个字段都是可选的。 ```yaml - name: '@deepseek-ai/dsh-jobs-local' @@ -42,8 +42,9 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `maxConcurrentJobsPerOwner` | `10` | 每个精确所有者,或共享的无主桶中,`running` 加 `stopping` 任务的最大数量 | -| `retainBytes` | `262144` | 每个任务 record 的运行期保留量,UTF-8 字节 | -| `settledRetainBytes` | `16384` | 任务结算后保留的 record 量,UTF-8 字节 | +| `retainBytes` | `262144` | 每个任务输出环的运行期保留量,UTF-8 字节 | +| `settledRetainBytes` | `16384` | 任务结算后保留的环容量,UTF-8 字节 | +| `pumpPollMs` | `150` | 任务拉取源的轮询间隔,毫秒 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-jobs-local)是每个受支持字段的穷尽式真源。 @@ -71,11 +72,11 @@ kind: "package-reference" ### 设计理念 -- **内存记录、全新快照。** `LocalJobRegistry` 为每个任务保存一份 `TrackedTask`——生命周期状态加可选的 record 环——每次调用都投影新的只读快照或块副本;调用方永远拿不到实时状态。 -- **record 保留量有界。** 追加超出运行期上限时丢弃最老的保留块(单个超限块保留其 UTF-8 安全尾部);低于保留窗口的读取得到 lossy 结果而非错误,结算时裁剪到结算上限。 -- **按所有者分层,一个进程级注册表。** 控制器、完成监听器与变更观察者归档到注册方所在的 scope(`ScopedLayers`),读取把全局层与所有者的 scope 链求并集——因此某个 preset 的任务控制绝不会为自身组合未加载任何控制器的 agent 保持 `start()` 可用,一次结算也只会抵达其所有者所属组合注册的监听器。 +- **内存记录、全新投影。** `LocalJobRegistry` 为每个任务保存一份 `TrackedJob`——生命周期状态、输出环与模型游标——每次调用都投影新的只读视图或块副本;调用方永远拿不到实时状态。 +- **环的保留量有界。** 追加超出运行期上限时丢弃最老的保留块(单个超限块保留其 UTF-8 安全尾部);低于保留窗口的读取得到 lossy 结果而非错误,结算时裁剪到结算上限。 +- **按所有者分层,一个进程级注册表。** 控制器与 `{ owners: 'scope' }` 订阅归档到注册方所在的 scope(`ScopedLayers`),读取把全局层与所有者的 scope 链求并集——因此某个 preset 的任务控制绝不会为自身组合未加载任何控制器的 agent 保持 `start()` 可用,一次带 scope 的结算也只会抵达其所有者所属组合注册的监听器。 - **启动前先预检。** `start()` 在调用生产方之前检查控制器服务、spec 有效性、仍存活的所有权与容量,因此拒绝不会留下 job id 或执行资源;注册一旦提交,后续不再有可失败步骤。 -- **结算首次优先,完成最后。** 最早的终止结果只记录一次,释放等待方,并只通知监听器一次,各监听器故障单独隔离;完成在记录提交且可见集变更发布之后才宣布,因为报告方可能同步开启一个模型轮次。 +- **结算首次优先,事件最后。** 最早的终止结果只记录一次,等待泵的最后一次排干,裁剪环,释放等待方,然后投递一次带逐监听器隔离的 `settled` 事件,再跟上环的最终 `output` 信号。 - **销毁永不死锁。** 抛出的取消会强制失败记录并报告可能的孤立工作,而不是让释放停滞。 ### 源码地图 @@ -83,19 +84,22 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、`LocalJobRegistry`、准入、生命周期、销毁 | +| [`src/events.ts`](src/events.ts) | 按 scope 分层的事件路由:`{ owner }`、`{ owners: 'all' }` 与 `{ owners: 'scope' }` 订阅 | +| [`src/ring.ts`](src/ring.ts) | 每个任务的有界输出环:追加、保留裁剪、按偏移读取 | +| [`src/pump.ts`](src/pump.ts) | 注册表拥有的拉取泵:每个任务一个定时器,结算前最后一次排干 | | — | 不发布运行时不变式伴生入口;快照检查位于 `dsh-jobs/invariant`。 | ### scope 分层 -`attachController`、`onJobDone`、`onJobsChanged` 与 `onOutput` 注册到调用上下文所在的 scope 层。控制器问题(`servesOwner`)与监听器投递(`listenersFor`、`changedFor`、`outputFor`)走同一条链:先是全局层,再沿所有者的链逐层。注册是无名 token,因此重复标签仍可独立释放。 +`attachController` 与 `{ owners: 'scope' }` 订阅注册到调用上下文所在的 scope 层;`{ owner }` 与 `{ owners: 'all' }` 订阅不带 scope。控制器问题(`servesOwner`)与带 scope 的投递走同一条链:先是全局层,再沿所有者的链逐层。注册是无名 token,因此重复标签仍可独立释放。 ### 准入与结算 -`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 在存在挂起等待方时把任务标为已报告,把已声明的 record 裁剪到结算保留量,解析每个等待方,记录终止快照,宣布可见集变更与 record 的最终输出信号,然后通知完成监听器。挂起的等待会在监听器运行前把任务标为已报告,因此完成报告方不会重复通知;销毁时的取消出于同样理由标记——面向正在被销毁的所有者的通知不会有人读到。 +`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 只记录一次终止结果(把记录下来的 kill 原因合并进 `killed` 的 detail),清除进度行,把环裁剪到结算保留量,解析每个等待方,然后发出带原因的 `settled` 与环的最终 `output` 信号。原因在 `VisibleJobs.kill` 之后为 `kill`,在所有者或服务取消之后为 `teardown`,否则为 `producer`;`dsh-tool-jobs` 据此跳过没人能读的通知。 ### 销毁 -所有者释放(`disposeOwned`)会取消该所有者的任务、等待其结算、移除其记录,并宣布移除——这是任何逐任务记录都无法表达的可见集变更。服务释放(`disposeAll`)会关闭监听器、取消所有存活任务、等待结算、清空存储、向不同的所有者宣布清空,然后分离跨 fiber 的所有者清理 effect。 +所有者释放(`disposeOwned`)会取消该所有者的任务、等待其结算、移除其记录,并逐条宣布移除——这是任何逐任务记录都无法表达的可见集变更。服务释放(`disposeAll`)会取消所有存活任务、等待结算、移除每条记录(逐条宣布移除,让挂载在服务之外的订阅者丢掉它的行),然后分离跨 fiber 的所有者清理 effect。 @@ -106,7 +110,7 @@ kind: "package-reference" 当包级约定不够用时阅读以下页面。它们从注册表约定逐步进入模型侧控制与设计记录。 -- [后台任务运行时子系统](../../../docs/subsystems/jobs.zh.md)——任务类型、快照字段与 `ctx.jobs` 的 cordis 接口面。 +- [后台任务运行时子系统](../../../docs/subsystems/jobs.zh.md)——任务类型、投影字段与 `ctx.jobs` 的 cordis 接口面。 - [jobs 组映射](../README.zh.md)——同级组页面及其包表格。 - [注册表约定](../jobs/README.zh.md)——本包实现的抽象 `ctx.jobs` 服务。 - [模型侧任务控制](../tool-jobs/README.zh.md)——`job_output`、`job_list` 与 `job_kill` 工具及完成通知。 diff --git a/packages/jobs/jobs-local/src/events.ts b/packages/jobs/jobs-local/src/events.ts index 333d5db5fc..8c13e4dd9c 100644 --- a/packages/jobs/jobs-local/src/events.ts +++ b/packages/jobs/jobs-local/src/events.ts @@ -23,7 +23,9 @@ interface Subscription { * by a name a second registrant could shadow. */ export class JobLayer implements ScopeLayer { + /** Tokens of the job controllers attached from this scope. */ readonly controllers = new AnonymousEntries() + /** The `{ owners: 'scope' }` subscriptions registered from this scope. */ readonly scoped = new AnonymousEntries() isEmpty(): boolean { diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index 54d4ecc8e9..ca8c2269ef 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -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/jobs/jobs/README.md -README.md: 5d4e10de9fa852dc4a38c521321bae53c744909b -README.zh.md: b584191a6b94e79ba8908875e7a0706a525edf4f +README.md: 4a82606f7988827ce8279c649f0fe1caeb34745e +README.zh.md: 56e4711acd550b7cb1278739cb60415c9e7ad065 diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index 5d4e10de9f..4a82606f79 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -1,5 +1,5 @@ --- -description: "The background-job registry contract for users and maintainers composing, implementing, or debugging background work: ids, ownership, lifecycle, and completion listeners." +description: "The background-job registry contract for users and maintainers composing, implementing, or debugging background work: ids, ownership, lifecycle, the output ring, and the event stream." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-jobs` lets tools run long work as background jobs: the work gets a stable `-N` id, keeps running while the agent moves on, and the owning agent can read its output, wait for it with a timeout, or request cancellation at any time. Jobs belong to the agent session that started them, so one agent's work is never visible to another, and completion reaches the owner as an in-session notice rather than by polling. A producer can also declare a per-job observation record: an append-only bounded output stream that any number of observers (the Web client) read at absolute byte offsets without touching the model-facing consuming cursor. This package ships the contract only: the process-local registry lives in `dsh-jobs-local`, and the model-facing controls and completion notices live in `dsh-tool-jobs`. Load an implementation to get background jobs; without one, `ctx.jobs` does not exist and `start()` cannot run. +`dsh-jobs` lets tools run long work as background jobs: the work gets a stable `-N` id, keeps running while the agent moves on, and the owning agent can read its output, wait for it with a timeout, or request cancellation at any time. Jobs belong to the agent session that started them, so one agent's work is never visible to another, and completion reaches the owner as an in-session notice rather than by polling. Every job owns one bounded output ring: the registry pumps the producer's pull sources into it and accepts pushed appends, the model consumes it through a registry-kept cursor, and any number of observers (the Web client) read it at absolute byte offsets without touching that cursor. ## Table of Contents @@ -29,9 +29,9 @@ Use this package when you are composing a background-job capability or writing a ### What a background job gives you -A producer registers work with a kind and a one-line label; the registry returns a `-N` id such as `bash-1`. Anyone who owns the job can read output, list jobs, wait up to a timeout for settlement, and request cancellation — each call returns a fresh snapshot of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the owning agent is notified through the completion listener that `dsh-tool-jobs` turns into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing output read or completion notice stays bounded. +A producer registers work with a kind and a one-line label; the registry returns a `-N` id such as `bash-1`. Anyone who owns the job can read output, list jobs, wait up to a timeout for settlement, and request cancellation — each call returns a fresh projection of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the registry's event stream announces it and `dsh-tool-jobs` turns the settlement into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing read or notice stays bounded. -A producer that declares `record: true` additionally streams raw output into the job's bounded record through the `RecordingJob` face its starter receives — a start without the declaration receives the plain `RunningJob` face, which has no `append`: observers read retained chunks at absolute byte offsets and get signaled on advancement, the settlement that ends the job also ends the stream, and `updateDetail` publishes a live progress line into every snapshot. The record is invisible to the model: reads consume nothing and never touch notice state. +A producer streams output by naming pull sources on its spec — non-consuming offset readers the registry pumps at its own cadence — or by pushing chunks through the `JobHandle` its starter receives; both land in the job's bounded ring, where `stdout` and `stderr` chunks reach the model and `log` chunks reach observers only. Observers read retained chunks at absolute byte offsets and are signaled on advancement; the settlement that ends the job also ends the stream, and `updateProgress` publishes a live progress line into every projection until then. Observation is invisible to the model: `readAt` consumes nothing and never touches notice state. ### The ownership boundary @@ -69,7 +69,7 @@ This section explains the design decisions behind the contract and points at the - **Contract and implementation are separate packages.** `JobRegistry` is an abstract Cordis service; loading the class directly throws, so a misconfigured composition fails at load instead of registering an empty `ctx.jobs`. - **One registry per process, owner-relative answers.** One instance serves every composition in the process, so registrations and deliveries are relative to the registering scope: a controller or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it. - **Access is fenced by the owner's session id.** Ids are predictable, so authorization — not secrecy — is the boundary. -- **Settlement is first-wins, and completion is announced last.** One terminal record, released waiters, and one round of contained listener notification; completion is announced after the record is committed and every other observer has seen it, because a reporter may open a model turn synchronously. +- **Settlement is first-wins, and its event follows every released waiter.** One terminal record, released waiters, then one round of contained event delivery; a consumer that claims a settlement while waiting therefore always claims before the event, so `dsh-tool-jobs` never announces a completion the model already collected. - **Registrations outlive producer and controller fibers.** Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. ### Source map @@ -77,14 +77,14 @@ This section explains the design decisions behind the contract and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `JobRegistry` service and its contract | -| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `RecordingJob`, `JobSnapshot`, listener types | +| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobSpec`, `JobHandle`, `JobHooks`, `JobOutcome`, `VisibleJobs`, `JobEvent`, and the read results | +| [`src/view.ts`](src/view.ts) | Client-safe leaf: `JobView`, `JobChunk`, `JobStatus`, and the merge-extensible `JobKindMap` | | [`src/brand.ts`](src/brand.ts) | `JobId` branded identifier, importable without the agent dependency | -| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`: poll pump copying producer offset-readers into a job record | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates snapshot identity, status, timestamps, owner, and record-offset fields | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates every projection the registry hands out — identity, status, timestamps, owner, and ring offsets | ### Service operations -Every operation is a thin projection over the registered jobs: `get` and `list` return non-consuming snapshots, `read` advances the single stream cursor, `readRecord` reads retained record chunks at an absolute offset without consuming anything, `kill` invokes producer cancellation before changing status, `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; listeners observe terminal records, visible-set changes, and record advancement at owner granularity, and `attachController` scopes controller availability to its effect lifetime. Exact signatures and behavior live in the JSDoc on [`src/index.ts`](src/index.ts) and the generated [`ctx.jobs` cordis surface](../../../docs/subsystems/jobs.md). +Every operation is a thin projection over the registered jobs, bound to one caller by `visibleTo`: `list` and `get` return fresh projections, `read` advances the model's cursor and hands out the producer's result once after settlement, `readAt` reads retained chunks at an absolute offset without consuming anything, `kill` invokes producer cancellation before changing status and records the reason for the terminal `detail`, `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; `events.subscribe` delivers registration, progress, stopping, settlement, removal, and output commits at owner, scope, or process granularity. @@ -95,12 +95,13 @@ Every operation is a thin projection over the registered jobs: `get` and `list` Read these pages when the package-level contract is not enough. They move from the job types to the shipped implementation, the model-facing controls, and the design records. -- [Background task runtime subsystem](../../../docs/subsystems/jobs.md) — the job types, snapshot fields, and `ctx.jobs` cordis surface. +- [Background task runtime subsystem](../../../docs/subsystems/jobs.md) — the job types, projection fields, and `ctx.jobs` cordis surface. - [jobs group map](../README.md) — the sibling group page and its package table. - [Process-local registry](../jobs-local/README.md) — the shipped implementation that runs jobs in this process. - [Model-facing job controls](../tool-jobs/README.md) — the `job_output`, `job_list`, and `job_kill` tools and completion notices. - [Generic long-running tool runtime Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) — the design behind the background-job runtime. - [job-registry seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md) — the owner-fenced registry contract and its rationale. +- [Jobs seam consolidation Agent Note](../../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md) — one output ring, one projection, one event stream. ----- @@ -120,8 +121,8 @@ No direct invalidation; the named consumers own any request-prefix changes. These limits define when the contract is a poor fit. They are current package constraints, not a task backlog. -- **The contract is in-process** — `JobStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam. -- **Stream output has one consuming cursor** — the model owns it; independent observers read the job's declared record through the non-consuming `readRecord` instead. +- **The contract is in-process** — `JobSpec.run()` passes callbacks and the registry resolves the live `Agent` behind the owner session; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam. +- **The model's cursor is the only consuming read** — independent observers use the non-consuming `readAt` and never move it. - **Foreground work cannot be promoted** — producers choose foreground or background before starting. diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index b584191a6b..56e4711acd 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -1,5 +1,5 @@ --- -description: "后台任务注册表约定,供组合、实现或排查后台工作的用户与维护者阅读:id、归属、生命周期与完成监听器。" +description: "后台任务注册表约定,供组合、实现或排查后台工作的用户与维护者阅读:id、归属、生命周期、输出环与事件流。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-jobs` 让工具可以把长时间工作注册为后台任务:工作获得稳定的 `-N` id,在 agent 继续推进的同时保持运行,拥有它的 agent 可以随时读取输出、带超时等待或请求取消。任务属于启动它的 agent 会话,因此一个 agent 的工作永远不会被另一个 agent 看到;完成以会话内通知而非轮询的方式送达给拥有者。生产方还可以声明按 job 划分的观测 record:一条追加式的有界输出流,任意数量的观察者(Web 客户端)按绝对字节偏移读取,不触碰模型侧的消耗型游标。本包只提供约定:进程本地注册表位于 `dsh-jobs-local`,模型侧控制与完成通知位于 `dsh-tool-jobs`。加载一个实现才能获得后台任务;没有实现时 `ctx.jobs` 不存在,`start()` 无法运行。 +`dsh-jobs` 让工具可以把长时间工作注册为后台任务:工作获得稳定的 `-N` id,在 agent 继续推进的同时保持运行,拥有它的 agent 可以随时读取输出、带超时等待或请求取消。任务属于启动它的 agent 会话,因此一个 agent 的工作永远不会被另一个 agent 看到;完成以会话内通知而非轮询的方式送达给拥有者。每个 job 拥有一个有界的输出环:注册表把生产方的拉取源泵入其中并接受推送的追加,模型通过注册表保管的游标消耗它,任意数量的观察者(Web 客户端)按绝对字节偏移读取它而不触碰该游标。 ## 目录 @@ -29,9 +29,9 @@ kind: "package-reference" ### 后台任务提供什么 -生产方以 kind 和一行标签注册工作;注册表返回 `-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新快照,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,拥有它的 agent 会通过 `dsh-tool-jobs` 转成会话内通知的完成监听器得到通知,因此无需轮询。生产方还可以附加可选的字节上限,让每次完整的模型侧输出读取或完成通知保持有界。 +生产方以 kind 和一行标签注册工作;注册表返回 `-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新投影,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,注册表的事件流宣布它,`dsh-tool-jobs` 把结算变成会话内通知,因此无需轮询。生产方可以附加一个可选的字节上限,让每次完整的模型侧读取或通知保持有界。 -声明 `record: true` 的生产方还会通过 starter 收到的 `RecordingJob` 面(未声明的 start 收到的是没有 `append` 的普通 `RunningJob` 面)把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。 +生产方通过在 spec 上声明拉取源——由注册表按自己的节奏泵送的非消耗偏移读取器——或经 starter 收到的 `JobHandle` 推送块来流式输出;二者都落入该 job 的有界环,其中 `stdout` 与 `stderr` 块到达模型,`log` 块只到达观察者。观察者按绝对字节偏移读取保留块并在推进时收到信号;结束 job 的结算同时封流,在此之前 `updateProgress` 把实时进度行发布进每个投影。观测对模型不可见:`readAt` 不消耗任何东西,也从不触碰通知状态。 ### 归属边界 @@ -69,7 +69,7 @@ kind: "package-reference" - **约定与实现分属不同包。** `JobRegistry` 是抽象 Cordis 服务;直接加载该类会抛出异常,因此错误配置的组合会在加载时失败,而不是注册一个空的 `ctx.jobs`。 - **每进程一个注册表,按所有者给出答案。** 一个实例服务进程内的每套组合,因此注册与投递都相对注册方所在 scope:从不带 scope 的上下文注册的控制器或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,恰好服务于该组合下组合出的 agent。 - **访问以所有者的会话 id 为界。** id 可预测,因此是授权——而非保密——构成边界。 -- **结算首次优先,完成最后宣布。** 一条终止记录、释放的等待方,以及一轮受到隔离的监听器通知;完成在记录提交且该结算的所有其他观察者都已看到之后才宣布,因为报告方可能同步开启一个模型轮次。 +- **结算首次优先,其事件跟在每个被释放的等待方之后。** 一条终止记录、释放的等待方,然后是一轮受到隔离的事件投递;在等待中认领结算的消费方因此总是在事件之前认领,`dsh-tool-jobs` 永远不会播报模型已经收走的完成。 - **注册的存续期长于生产方与控制器 fiber。** 所有者与服务释放会取消正在运行的工作并等待守约的生产方;抛出异常的销毁取消只强制失败记录。 ### 源码地图 @@ -77,14 +77,14 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `JobRegistry` 服务及其约定 | -| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`RecordingJob`、`JobSnapshot`、监听器类型 | +| [`src/types.ts`](src/types.ts) | 共享词汇:`JobSpec`、`JobHandle`、`JobHooks`、`JobOutcome`、`VisibleJobs`、`JobEvent` 与读取结果 | +| [`src/view.ts`](src/view.ts) | 客户端安全叶子:`JobView`、`JobChunk`、`JobStatus` 与可合并扩展的 `JobKindMap` | | [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 | -| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`:把生产方偏移读取器复制进 job record 的轮询泵 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳、所有者与 record 偏移字段 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验注册表交出的每个投影——标识、状态、时间戳、所有者与环偏移 | ### 服务操作 -每个操作都是已注册任务之上的薄投影:`get` 与 `list` 返回非消费式快照,`read` 推进唯一的流游标,`readRecord` 按绝对偏移读取保留的 record 块且不消耗任何东西,`kill` 在改变状态前调用生产方取消,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;监听器按所有者粒度观察终止记录、可见集变化与 record 推进,`attachController` 把控制器可用性限定在其 effect 生命周期内。确切签名与行为见 [`src/index.ts`](src/index.ts) 的 JSDoc 与生成的 [`ctx.jobs` cordis 接口面](../../../docs/subsystems/jobs.zh.md)。 +每个操作都是已注册任务之上的薄投影,由 `visibleTo` 绑定到一个调用方:`list` 与 `get` 返回全新投影,`read` 推进模型游标并在结算后把生产方的 result 交出一次,`readAt` 按绝对偏移读取保留块且不消耗任何东西,`kill` 在改变状态前调用生产方取消并为终态 `detail` 记录原因,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;`events.subscribe` 按所有者、scope 或进程粒度投递注册、进度、停止中、结算、移除与输出提交。 @@ -95,12 +95,13 @@ kind: "package-reference" 当包级约定不够用时阅读以下页面。它们从任务类型逐步进入随附实现、模型侧控制与设计记录。 -- [后台任务运行时子系统](../../../docs/subsystems/jobs.zh.md)——任务类型、快照字段与 `ctx.jobs` 的 cordis 接口面。 +- [后台任务运行时子系统](../../../docs/subsystems/jobs.zh.md)——任务类型、投影字段与 `ctx.jobs` 的 cordis 接口面。 - [jobs 组映射](../README.zh.md)——同级组页面及其包表格。 - [进程本地注册表](../jobs-local/README.zh.md)——在本进程中运行任务的随附实现。 - [模型侧任务控制](../tool-jobs/README.zh.md)——`job_output`、`job_list` 与 `job_kill` 工具及完成通知。 - [通用长时间运行工具运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)——后台任务运行时背后的设计。 - [任务注册表 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md)——按所有者隔离的注册表约定及其理由。 +- [jobs seam 收敛 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md)——一个输出环、一个投影、一条事件流。 ----- @@ -120,8 +121,8 @@ kind: "package-reference" 这些限制说明约定何时不合适。它们是当前包约束,不是任务积压。 -- **约定是进程内的**——`JobStart.run()` 传入回调和确切的 `Agent` 对象;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。 -- **流输出只有一个消费游标**——它属于模型;独立观察者改用非消耗的 `readRecord` 读取 job 声明的 record。 +- **约定是进程内的**——`JobSpec.run()` 传入回调,注册表解析拥有者会话背后的活体 `Agent`;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。 +- **模型游标是唯一的消耗式读取**——独立观察者使用非消耗的 `readAt`,从不移动它。 - **前台工作无法转为后台**——生产方在启动前选择前台或后台。 diff --git a/packages/jobs/tool-jobs/README.i18n.yaml b/packages/jobs/tool-jobs/README.i18n.yaml index ca617bc8fb..df53b29100 100644 --- a/packages/jobs/tool-jobs/README.i18n.yaml +++ b/packages/jobs/tool-jobs/README.i18n.yaml @@ -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/jobs/tool-jobs/README.md -README.md: bb9fea89b4aabe38296c01c910f36e99244680db -README.zh.md: 9820b1536f7f5c8860e7f67b1eee956e6d240462 +README.md: a596aad916f37a0a5778958d7b4cae966221ad3c +README.zh.md: 72fbe6c2d287649337ee41681b7b05c17ffb377a diff --git a/packages/jobs/tool-jobs/README.md b/packages/jobs/tool-jobs/README.md index bb9fea89b4..a596aad916 100644 --- a/packages/jobs/tool-jobs/README.md +++ b/packages/jobs/tool-jobs/README.md @@ -25,19 +25,19 @@ English | [中文](README.zh.md) ## Use this package -Load this plugin in any composition where the agent should start, observe, and stop background jobs: it registers the three tools, attaches the controller producers need, and delivers completion notices. It requires the `ctx.tools`, `ctx.jobs`, and `ctx.systemPrompt` services from the composed harness. +Load this plugin in any composition where the agent should start, observe, and stop background jobs: it registers the three tools, attaches the controller producers need, and delivers completion notices. It requires the `ctx.tools`, `ctx.jobs`, `ctx.agents`, and `ctx.systemPrompt` services from the composed harness. ### The three tools -- `job_output(job_id, wait?, timeout_ms?)` — Read a job's output. Stream jobs return only the output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap and leaves a still-running job alive on timeout. +- `job_output(job_id, wait?, timeout_ms?)` — Read a job's output. Stream jobs return only the output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap and leaves a still-running job alive on timeout. A read renders stdout first and stderr in one `[stderr]` section, notes output that left memory before the read, and carries a job's result (a subagent's answer) exactly once, on the first read after settlement. - `job_list()` — List your background jobs with their ids, kinds, and statuses, one per line: ` [] —
      • ) } - const open = !adding && editing?.provider === row.entry.provider + const open = !addOpen && editing?.provider === row.entry.provider const credentialConfigured = row.credential?.configured === true const credentialMissing = !credentialConfigured && row.apiKeyEnv !== undefined @@ -392,11 +412,10 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS aria-label={providerCopy(t('editProvider'), target)} onClick={() => { setSavedTarget(undefined) - // One card at a time: leaving `declaring` set would show - // the create card beside this editor, and closing either - // one discards the other's draft. - setDeclaring(false) - setAdding(false) + // One card at a time: the add card closes with whatever + // it held, since closing either card would otherwise + // discard the other's draft. + setAddOpen(false) setEditing(open ? undefined : target) }} > @@ -443,110 +462,130 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS })}
      - {addTarget !== undefined && addNamespace !== undefined + {addOpen ? (
      -
      - {t('provider')} - -
      - { closeEditor(changed, addTarget) }} - /> - {addRow === undefined - ? null - : renderSlot( - 'settings.models.provider-card', - { provider: addRow.entry, configured: addRow.configured, keyConfigured: keyConfiguredOf(addRow) }, - { entryKey: addRow.entry.settingsNs }, - )} + {catalogOffered && customOffered + ? ( +
      + { + setAddMode(next) + setVisited(previous => new Set([...previous, next])) + }} + /> +

      + {t(mode === 'catalog' ? 'addCatalogHint' : 'addCustomHint')} +

      +
      + ) + : null} + {visited.has('catalog') && addTarget !== undefined && addNamespace !== undefined + ? ( + + ) + : null} + {visited.has('custom') + ? ( + + ) + : null}
      ) - : declaring + : catalogOffered || customOffered ? ( -
      - row.entry.provider)} - protocols={protocols} - /* v8 ignore next -- the card only opens from a button disabled without this namespace */ - revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0} - operations={operations} - t={t} - readOnly={!state.writable} - onClose={(changed) => { - setDeclaring(false) - if (changed) void controller.load() + // One entry for both ways to gain a provider; the card behind it + // splits them. Full width, so it lines up with the rows above. +
      +
      ) - : ( - // One row for the two ways to gain a provider: adopt one the - // adapter already knows, or declare one it does not. Side by side - // and equal-width so they read as siblings and line up with the - // rows above, rather than two pills of different lengths. -
      - {configurable.length > 0 && ( - - )} - {state.namespaces.has('llm-pi-ai') && ( - - )} -
      - )} + : null}
      {renderSlot('settings.models.footer', {})} {t('customApiUnset')} : null} - {protocols.map(choice => )} + {protocols.map(choice => )}
      ) diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 2b0f92d8df..9cd6d52321 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -14,7 +14,12 @@ export const en = { deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.', deleteConfirm: 'Delete {provider}', deleting: 'Deleting {provider}…', - add: 'Add provider', + add: 'Add model provider', + addMode: 'How to add', + addCatalog: 'Third-party model provider', + addCustom: 'Custom model API', + addCatalogHint: 'Pick OpenAI, Anthropic, Kimi, or another provider from the built-in catalog and enter its API key.', + addCustomHint: 'Connect a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint by its base URL, protocol, and models.', provider: 'Provider', close: 'Close', cancel: 'Cancel', @@ -79,8 +84,6 @@ export const en = { fetchSelectAll: 'Select all', fetchDeselectAll: 'Deselect all', fetchAdopt: 'Add selected', - customAdd: 'Add a custom provider', - customTitle: 'Custom provider', customTag: 'Custom', customRoute: 'Provider ID', customRouteHint: 'Lowercase identifier, starting with a letter, that uniquely names this provider in requests and as its credential name.', @@ -89,6 +92,9 @@ export const en = { customDisplayName: 'Display name', customApi: 'API protocol', customApiUnset: 'Not selected', + protocolOpenAiCompletions: 'OpenAI Chat Completions', + protocolOpenAiResponses: 'OpenAI Responses', + protocolAnthropicMessages: 'Anthropic Messages', customNeedsBaseUrl: 'A custom provider needs a base URL.', customBaseUrlInvalid: 'Enter a valid HTTP or HTTPS URL.', customNeedsModels: 'A custom provider needs at least one model.', @@ -116,7 +122,7 @@ export type ModelsKey = keyof typeof en export const zh: { [Key in keyof typeof en]: string } = { nav: '模型', title: '模型', - intro: '填入各提供方的 API 密钥即可使用其模型。', + intro: '填入各提供商的 API 密钥即可使用其模型。', edit: '编辑', editProvider: '编辑 {provider}', remove: '删除', @@ -126,8 +132,13 @@ export const zh: { [Key in keyof typeof en]: string } = { deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。', deleteConfirm: '删除 {provider}', deleting: '正在删除 {provider}…', - add: '添加提供方', - provider: '提供方', + add: '添加模型提供商', + addMode: '添加方式', + addCatalog: '第三方模型提供商', + addCustom: '自定义模型 API', + addCatalogHint: '从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。', + addCustomHint: '连接中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口,需填写 API 地址、协议和模型。', + provider: '提供商', close: '关闭', cancel: '取消', apply: '保存', @@ -136,7 +147,7 @@ export const zh: { [Key in keyof typeof en]: string } = { credentialConfigured: 'API 密钥已配置', credentialMissing: 'API 密钥缺失', readOnly: '当前部署的设置文档为只读。', - loadFailed: '加载提供方目录失败', + loadFailed: '加载提供商目录失败', conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。', retry: '重试', keyInput: 'API 密钥', @@ -146,7 +157,7 @@ export const zh: { [Key in keyof typeof en]: string } = { keyEnvLocked: '由启动环境提供(只读)', customized: '自定义设置', baseUrl: 'API 地址', - baseUrlDefault: '提供方默认', + baseUrlDefault: '提供商默认', deepSeekChatBaseUrl: 'https://api.deepseek.com', deepSeekMessagesBaseUrl: 'https://api.deepseek.com/anthropic', deepSeekEndpointHint: '请填写与当前连接配置兼容的 API 地址。', @@ -159,9 +170,9 @@ export const zh: { [Key in keyof typeof en]: string } = { modelName: '显示名称', modelNamePlaceholder: '留空时使用模型 ID', contextWindow: '上下文窗口', - contextWindowPlaceholder: '使用提供方默认值', + contextWindowPlaceholder: '使用提供商默认值', maxTokens: '最大输出 token 数', - maxTokensPlaceholder: '使用提供方默认值', + maxTokensPlaceholder: '使用提供商默认值', modelAdvanced: '模型选项', modelInputTypes: '输入类型', modelInputText: '文本', @@ -170,7 +181,7 @@ export const zh: { [Key in keyof typeof en]: string } = { removeModel: '删除模型', modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。', - keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。', + keyBlankNew: '请输入 API 密钥;若该提供商以其他方式鉴权,可以留空。', keyIllegalCharacters: '该 API 密钥格式错误,请检查。', modelIdRequired: '模型 ID 不能为空。', modelIdDuplicate: '模型 ID 不能重复。', @@ -181,33 +192,34 @@ export const zh: { [Key in keyof typeof en]: string } = { modelCapacityInvalid: '容量需为数字,可加 K 或 M 后缀。', modelDuplicate: '每个模型 ID 只能出现一次。', fetchModels: '获取可用模型', - fetching: '正在询问提供方\u2026', + fetching: '正在询问提供商\u2026', fetchNeedsBaseUrl: '请先填写 API 地址,再获取。', - fetchEmpty: '该提供方没有列出任何模型,请手动添加。', + fetchEmpty: '该提供商没有列出任何模型,请手动添加。', fetchTitle: '选择要添加的模型', - fetchDescription: '以下是模型提供方的可用模型,勾选要添加的模型。', + fetchDescription: '以下是模型提供商的可用模型,勾选要添加的模型。', fetchSearch: '搜索模型', fetchNoMatches: '没有匹配的模型。', fetchSelectAll: '全选', fetchDeselectAll: '取消全选', fetchAdopt: '添加所选', - customAdd: '添加自定义提供方', - customTitle: '自定义提供方', customTag: '自定义', customRoute: 'Provider ID', - customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供方,并用于派生凭据名。', + customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供商,并用于派生凭据名。', customRouteInvalid: '需以小写字母开头,之后可用小写字母、数字和短横线。', - customRouteTaken: '已有提供方使用了这个 ID。', + customRouteTaken: '已有提供商使用了这个 ID。', customDisplayName: '显示名称', customApi: 'API 协议', customApiUnset: '未选择', - customNeedsBaseUrl: '自定义提供方需要填写 API 地址。', + protocolOpenAiCompletions: 'OpenAI Chat Completions', + protocolOpenAiResponses: 'OpenAI Responses', + protocolAnthropicMessages: 'Anthropic Messages', + customNeedsBaseUrl: '自定义模型 API 需要填写 API 地址。', customBaseUrlInvalid: '请输入有效的 HTTP 或 HTTPS 地址。', - customNeedsModels: '自定义提供方至少需要一个模型。', + customNeedsModels: '自定义模型 API 至少需要一个模型。', customBaseUrlPlaceholder: 'https://gateway.example/v1', customAnthropicBaseUrlPlaceholder: 'https://gateway.example', settingsPathUnresolvable: '无法解析设置路径', - create: '创建提供方', + create: '创建提供商', creating: '创建中\u2026', welcomeTitle: '内测声明', welcomeBody: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。', diff --git a/packages/client/ui-settings-models/src/client/protocol-label.ts b/packages/client/ui-settings-models/src/client/protocol-label.ts new file mode 100644 index 0000000000..f65323f32c --- /dev/null +++ b/packages/client/ui-settings-models/src/client/protocol-label.ts @@ -0,0 +1,27 @@ +/** + * Product names for the wire protocols a pi-ai route may speak. The pickers + * show these instead of the schema identifiers (`openai-completions`), which + * are what `settings.yaml` carries and what the option values stay. + */ + +import type { ModelsKey } from './locales.ts' + +/** The protocols this page names, keyed by their schema identifier. */ +const PROTOCOL_LABEL_KEYS: Readonly> = { + 'openai-completions': 'protocolOpenAiCompletions', + 'openai-responses': 'protocolOpenAiResponses', + 'anthropic-messages': 'protocolAnthropicMessages', +} + +/** + * The picker text for one protocol identifier. + * @param t - section copy. + * @param protocol - the schema identifier of the protocol. + * @returns the product name this page knows the protocol by; a protocol the + * adapter adds before this page names it shows its identifier, the spelling + * `settings.yaml` needs anyway. + */ +export function protocolLabel(t: (key: ModelsKey) => string, protocol: string): string { + const key = PROTOCOL_LABEL_KEYS[protocol] + return key === undefined ? protocol : t(key) +} diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index e9cec03e60..125209e81b 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -46,6 +46,7 @@ function capacityInputs(label: string): HTMLInputElement[] { const PiAiConfig = Schema.object({ providers: Schema.dict(Schema.object({ apiKeyEnv: Schema.string().role('credential-ref'), + api: Schema.union(['openai-completions', 'openai-responses', 'anthropic-messages']), baseURL: Schema.string(), reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), headers: Schema.dict(Schema.string()), @@ -307,12 +308,11 @@ async function mountDeepSeekCard(overrides: Parameters[0] = } describe('ModelsSection', () => { - it('hides both add actions when their settings namespaces are absent', async () => { + it('hides the add action when no settings namespace can open an editor', async () => { const scripted = scriptedFace() scripted.face.settings.describe.mockResolvedValue(remoteOk({ writable: true, hasDocument: false, namespaces: [] })) await mountFace(scripted) expect(screen.queryByRole('button', { name: en.add })).toBeNull() - expect(screen.queryByRole('button', { name: en.customAdd })).toBeNull() }) it('offers only providers whose settings namespace can open an editor', async () => { @@ -322,8 +322,11 @@ describe('ModelsSection', () => { namespaces: wireNamespaces().filter(view => view.ns !== 'llm-pi-ai'), })) await mountFace(scripted) - expect(screen.queryByRole('button', { name: en.customAdd })).toBeNull() fireEvent.click(screen.getByRole('button', { name: en.add })) + // Without the pi-ai namespace nothing can be hand-declared, so the card + // is the catalog form alone: no mode switch, no custom panel. + expect(screen.queryByRole('tablist')).toBeNull() + expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull() expect(screen.queryByRole('option', { name: 'anthropic' })).toBeNull() expect(screen.getByRole('option', { name: 'plain' })).toBeTruthy() }) @@ -339,7 +342,6 @@ describe('ModelsSection', () => { fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) expect(await screen.findByLabelText(en.keyInput)).toBeTruthy() expect(screen.getByRole('button', { name: en.add })).toBeTruthy() - expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy() }) it('renders nothing before the slot injects its dependencies', () => { @@ -1437,6 +1439,88 @@ describe('ModelsSection', () => { expect(mutate).not.toHaveBeenCalled() }) + it('opens the add card on the catalog mode with both modes offered', async () => { + await mountSection() + fireEvent.click(screen.getByRole('button', { name: en.add })) + const modes = screen.getByRole('tablist', { name: en.addMode }) + expect(within(modes).getByRole('tab', { name: en.addCatalog }).getAttribute('aria-selected')).toBe('true') + expect(within(modes).getByRole('tab', { name: en.addCustom }).getAttribute('aria-selected')).toBe('false') + expect(screen.getByText(en.addCatalogHint)).toBeTruthy() + expect(screen.queryByText(en.addCustomHint)).toBeNull() + expect(screen.getByRole('combobox', { name: en.provider })).toBeTruthy() + // The custom panel is mounted but hidden, so its fields are not reachable. + expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull() + }) + + it('switches between the modes without losing either draft', async () => { + await mountSection() + fireEvent.click(screen.getByRole('button', { name: en.add })) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-catalog' } }) + + fireEvent.click(screen.getByRole('tab', { name: en.addCustom })) + expect(screen.getByText(en.addCustomHint)).toBeTruthy() + expect(screen.queryByText(en.addCatalogHint)).toBeNull() + expect(screen.queryByRole('combobox', { name: en.provider })).toBeNull() + fireEvent.change(screen.getByRole('textbox', { name: en.customRoute }), { target: { value: 'acme' } }) + + // Both panels stay mounted from here on, so the shown one is queried by name. + fireEvent.click(screen.getByRole('tab', { name: en.addCatalog })) + const catalog = screen.getByRole('tabpanel', { name: en.addCatalog }) + expect(within(catalog).getByLabelText(en.keyInput).value).toBe('sk-catalog') + fireEvent.click(screen.getByRole('tab', { name: en.addCustom })) + expect(screen.getByRole('textbox', { name: en.customRoute }).value).toBe('acme') + }) + + it('opens on the custom mode when every catalog provider is already configured', async () => { + const scripted = scriptedFace() + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, + ])) + await mountFace(scripted) + fireEvent.click(screen.getByRole('button', { name: en.add })) + const catalog = screen.getByRole('tab', { name: en.addCatalog }) + expect(catalog.disabled).toBe(true) + expect(screen.getByRole('tab', { name: en.addCustom }).getAttribute('aria-selected')).toBe('true') + expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy() + }) + + it('shows the custom form alone when no directory row can be adopted', async () => { + const scripted = scriptedFace() + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([])) + await mountFace(scripted) + fireEvent.click(screen.getByRole('button', { name: en.add })) + expect(screen.queryByRole('tablist')).toBeNull() + expect(screen.queryByRole('combobox', { name: en.provider })).toBeNull() + expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy() + }) + + it('disables the add action when neither mode can proceed', async () => { + const scripted = scriptedFace() + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([ + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, + ])) + // A pi-ai schema naming no protocol leaves nothing to declare either. + const protocolless = JSON.parse(JSON.stringify( + Schema.object({ providers: Schema.dict(Schema.object({})) }).toJSON(), + )) as JsonValue + scripted.face.settings.describe.mockResolvedValue(remoteOk({ + writable: true, hasDocument: false, + namespaces: wireNamespaces().map(view => view.ns === 'llm-pi-ai' ? { ...view, schema: protocolless } : view), + })) + await mountFace(scripted) + expect(screen.getByRole('button', { name: en.add }).disabled).toBe(true) + }) + + it('collapses the add card from the custom mode on cancel', async () => { + await mountSection() + fireEvent.click(screen.getByRole('button', { name: en.add })) + fireEvent.click(screen.getByRole('tab', { name: en.addCustom })) + fireEvent.click(within(screen.getByRole('tabpanel', { name: en.addCustom })).getByText(en.cancel)) + expect(screen.queryByRole('tablist')).toBeNull() + expect(screen.getByRole('button', { name: en.add })).toBeTruthy() + }) + it('cancels the add card back to the add button', async () => { await mountSection() fireEvent.click(screen.getByText(en.add)) diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 9b6ff1f55a..36dc899778 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -225,6 +225,12 @@ function openEditor(provider: string): void { fireEvent.click(summary) } +/** Open the add card from the section and switch it to the custom-API mode. */ +function openCustomMode(): void { + fireEvent.click(screen.getByRole('button', { name: en.add })) + fireEvent.click(screen.getByRole('tab', { name: en.addCustom })) +} + /** Open one model row's advanced fold, where the capacities live. */ function expandModel(index: number): void { fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${index}`)) @@ -1417,27 +1423,40 @@ describe('hand-declared providers', () => { expect(buttonNamed(en.create).disabled).toBe(true) }) - it('closes the create card when an existing row is opened for editing', async () => { + it('closes the add card when an existing row is opened for editing', async () => { await mountSection({ providers: { openai: { baseURL: 'https://proxy.example/v1' } } }) - fireEvent.click(screen.getByRole('button', { name: en.customAdd })) - expect(screen.getByText(en.customTitle)).toBeTruthy() + openCustomMode() + expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy() // Two cards at once would each be closable by the other: whichever one is // dismissed clears the shared state and discards the other's draft. openEditor('openai') - expect(screen.queryByText(en.customTitle)).toBeNull() + expect(screen.queryByRole('tablist')).toBeNull() + expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull() }) - it('reaches the card from the section and returns to the button on cancel', async () => { + it('reaches the custom form from the section and returns to the button on cancel', async () => { await mountSection() - fireEvent.click(screen.getByRole('button', { name: en.customAdd })) - expect(screen.getByText(en.customTitle)).toBeTruthy() + openCustomMode() + expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy() fireEvent.click(screen.getByText(en.cancel)) - await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() }) - expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy() + await waitFor(() => { expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull() }) + expect(screen.getByRole('button', { name: en.add })).toBeTruthy() + }) + + it('names each protocol by its product name and falls back to the identifier of an unknown one', () => { + mountCard({ protocols: [...PROTOCOLS, 'google-generative-ai'] }) + const protocol = screen.getByLabelText(en.customApi) + const labels = [...protocol.options].map(option => [option.value, option.textContent]) + expect(labels).toEqual([ + ['openai-completions', en.protocolOpenAiCompletions], + ['openai-responses', en.protocolOpenAiResponses], + ['anthropic-messages', en.protocolAnthropicMessages], + ['google-generative-ai', 'google-generative-ai'], + ]) }) it('refuses an unusable key on the field and blocks creation', () => { @@ -1608,15 +1627,15 @@ describe('API key field', () => { const { controller, mutate } = await mountSection() const load = vi.spyOn(controller, 'load') - fireEvent.click(screen.getByRole('button', { name: en.customAdd })) - fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) - fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + openCustomMode() + fireEvent.change(screen.getByRole('textbox', { name: en.customRoute }), { target: { value: 'acme' } }) + fireEvent.change(screen.getByRole('textbox', { name: en.baseUrl }), { target: { value: 'https://acme.test/v1' } }) fireEvent.click(screen.getByRole('button', { name: en.addModel })) fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } }) fireEvent.click(screen.getByText(en.create)) await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) await waitFor(() => { expect(load).toHaveBeenCalledOnce() }) - expect(screen.queryByText(en.customTitle)).toBeNull() + expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull() }) }) From 536a94b18809905d11a432d4e17e6ce11ba17be9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 19 Sep 2026 15:48:10 +0800 Subject: [PATCH 079/284] fix(settings-models): exact Models nav locators, scoped vendor term, review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new "添加模型提供商" button shares a prefix with the Models nav entry, so every e2e that clicked the nav through a non-exact locator resolved two buttons once the page rendered; the nav locators are exact now. The vendor sense of "provider" is registered in the terminology table as 模型提供商 with its scope — the Models settings copy, the providers user guide, and this package's README — while the generic provider rows keep 提供方, and the package's Chinese README uses the vendor term throughout. The custom card's header comment no longer claims a mode switch that a custom-only card does not render, and the SegmentedControl suite gains a positive Home/End case whose first enabled option is not the first option. --- apps/web/tests/models-settings.e2e.ts | 2 +- .../tests/onboarding-deepseek-config.e2e.ts | 4 +-- .../tests/onboarding-usable-provider.e2e.ts | 4 +-- apps/web/tests/settings-chrome.e2e.ts | 6 ++-- docs/i18n/terminology.md | 4 +-- .../tests/segmented-control.client.spec.tsx | 11 +++++++ .../ui-settings-models/README.i18n.yaml | 2 +- .../client/ui-settings-models/README.zh.md | 32 +++++++++---------- .../src/client/CustomProviderCard.tsx | 10 +++--- 9 files changed, 44 insertions(+), 31 deletions(-) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 6d514cf97b..a46439dcbb 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -75,7 +75,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '模型' }).click() + await dialog.getByRole('button', { name: '模型', exact: true }).click() await dialog.getByText('填入各提供商的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 }) // The dormant pi-ai adapter contributes its whole installed catalog; no // provider is configured yet, so the page is one add button. diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index d8e201a49d..4b4f6e0929 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -99,7 +99,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await page.getByRole('button', { name: '设置', exact: true }).click() const settings = page.getByRole('dialog', { name: '设置' }) await settings.waitFor({ timeout: 10_000 }) - await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '模型', exact: true }).click() const deepSeekRow = settings.getByText('DeepSeek', { exact: true }).first() await deepSeekRow.waitFor({ timeout: 10_000 }) await deepSeekRow.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click() @@ -199,7 +199,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await page.getByRole('button', { name: '设置', exact: true }).click() const settings = page.getByRole('dialog', { name: '设置' }) await settings.waitFor({ timeout: 10_000 }) - await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '模型', exact: true }).click() const deepSeek = settings.getByText('DeepSeek', { exact: true }).first() await deepSeek.waitFor({ timeout: 10_000 }) await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click() diff --git a/apps/web/tests/onboarding-usable-provider.e2e.ts b/apps/web/tests/onboarding-usable-provider.e2e.ts index efc04dcdaa..42a2593487 100644 --- a/apps/web/tests/onboarding-usable-provider.e2e.ts +++ b/apps/web/tests/onboarding-usable-provider.e2e.ts @@ -54,7 +54,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first- await settings.waitFor({ timeout: 10_000 }) // Dismissing the onboarding step leaves Settings closed, so enter the // Models section explicitly before exercising its normal cards. - await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '模型', exact: true }).click() const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true }) await setupKey.waitFor({ timeout: 10_000 }) @@ -115,7 +115,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first- // setup card over a user who already has somewhere to send a request. await page.getByRole('button', { name: '设置', exact: true }).click() await settings.waitFor({ timeout: 10_000 }) - await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '模型', exact: true }).click() await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 }) expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index b396d5f969..7478f8a5e8 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -94,8 +94,8 @@ describe('web e2e: settings modal and General preferences', () => { const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE) // Section switch: aria-current moves (the Models page itself has its own scenario file). - await dialog.getByRole('button', { name: '模型' }).click() - await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true') + await dialog.getByRole('button', { name: '模型', exact: true }).click() + await expect.poll(() => dialog.getByRole('button', { name: '模型', exact: true }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true') expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull() // Built-in plugins: the read-only Plugin list, a projection of the same // assembled Loader tree, shown as the section's one page; management and @@ -137,7 +137,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(await dialog.getByRole('button', { name: '内置插件', exact: true }).getAttribute('aria-current')).toBe('true') // One contribution shows as the page itself, without a tab row. expect(await dialog.getByRole('tab').count()).toBe(0) - expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull() + expect(await dialog.getByRole('button', { name: '模型', exact: true }).getAttribute('aria-current')).toBeNull() const pluginsSnapshot = await captureStableAria( page, PLUGIN_ROW_SELECTOR, diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index feb1c96792..bab89e6efd 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -150,7 +150,7 @@ | merge | 合并 | | | | | message | 消息 | | | | | mod | 模组 | | | | -| model provider | 模型提供方 | | | | +| model provider | 模型提供商 | | | 指为模型提供 API 的厂商或网关:模型设置页文案、用户指南 providers 与 `ui-settings-models` README 用「提供商」;llm seam 的开发者文档(可配置提供方目录、dormant 等)沿用「提供方」 | | model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 | | module | 模块 | | | | | non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 | @@ -168,7 +168,7 @@ | plugin | 插件 | | | | | postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 | | prompt | 提示词 | | | | -| provider | 提供方 | | | | +| provider | 提供方 | | | 泛指的提供方(搜索、检查、settings 文件、subagent 等);模型厂商或网关见 model provider 行 | | provider-neutral | 提供方无关 | | 提供方中立 | | | quality gate | 质量门禁 | | | | | quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 | diff --git a/packages/client/ui-primitives/tests/segmented-control.client.spec.tsx b/packages/client/ui-primitives/tests/segmented-control.client.spec.tsx index 56af1d3224..7972eb9f3c 100644 --- a/packages/client/ui-primitives/tests/segmented-control.client.spec.tsx +++ b/packages/client/ui-primitives/tests/segmented-control.client.spec.tsx @@ -97,6 +97,17 @@ describe('SegmentedControl', () => { expect(onChange).not.toHaveBeenCalled() }) + it('lands Home and End on the first and last enabled options, not the first and last options', () => { + const { onChange } = mount('import', { + options: [{ value: 'catalog', label: 'Catalog', disabled: true }, OPTIONS[1]!, OPTIONS[2]!], + }) + fireEvent.keyDown(tab('Import'), { key: 'Home' }) + expect(onChange).toHaveBeenLastCalledWith('custom') + onChange.mockClear() + fireEvent.keyDown(tab('Import'), { key: 'End' }) + expect(onChange).not.toHaveBeenCalled() + }) + it('skips disabled options while walking with the arrow keys', () => { const { onChange } = mount('catalog', { options: [OPTIONS[0]!, { value: 'custom', label: 'Custom', disabled: true }, OPTIONS[2]!], diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index c81192e0e4..f04690ca04 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md README.md: 619941e125d6c8607246b5924c66c9a11af47948 -README.zh.md: c4ba114dbb6501f564f810efc339114ea902604e +README.zh.md: d55d0cb6c6f89867050cc33b7bb200fc2bc40401 diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index c4ba114dbb..d55d0cb6c6 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -1,5 +1,5 @@ --- -description: "dsh Web 客户端的模型设置与产品引导插件:提供方行、API 密钥管理、模型列表与 DeepSeek 首次运行弹窗。" +description: "dsh Web 客户端的模型设置与产品引导插件:提供商行、API 密钥管理、模型列表与 DeepSeek 首次运行弹窗。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-settings-models` 是 dsh Web 客户端的 Models 设置页面:用户可以配置 API 密钥(以只写方式存入 profile 的凭据引用之下)、编辑每个提供方的模型列表,并手工声明自定义 pi-ai 路由;页面以提供方行展示,一次只展开一张编辑卡片。该页面把提供方目录、设置文档与凭据描述合并为一个共享快照,因此行的状态在三个方面始终一致。它还会带首次运行的用户走两个有序弹窗——版本化内测声明,以及按条件显示的官方 DeepSeek 凭据步骤。 +`dsh-client-ui-settings-models` 是 dsh Web 客户端的 Models 设置页面:用户可以配置 API 密钥(以只写方式存入 profile 的凭据引用之下)、编辑每个提供商的模型列表,并手工声明自定义 pi-ai 路由;页面以提供商行展示,一次只展开一张编辑卡片。该页面把提供商目录、设置文档与凭据描述合并为一个共享快照,因此行的状态在三个方面始终一致。它还会带首次运行的用户走两个有序弹窗——版本化内测声明,以及按条件显示的官方 DeepSeek 凭据步骤。 ## 目录 @@ -25,33 +25,33 @@ kind: "package-reference"
      ## 使用本包 -从设置导航打开 Models 页面,即可看到每个已配置的提供方都有一行。其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。 +从设置导航打开 Models 页面,即可看到每个已配置的提供商都有一行。其配置键未在任何位置配置的整分节提供商会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。 -存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。 +存在已存储目录错误的提供商仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。 ### API 密钥 -编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,从而保留提供方原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 +编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供商留空密钥会保存一个不带引用的 profile,从而保留提供商原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 -### 编辑提供方 +### 编辑提供商 -收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。 +收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。 `llm-deepseek` 的 DeepSeek 卡片编辑共用的端点、凭据和模型目录,不提供协议选择器。Cordis YAML 选择 Messages 时,官方端点占位符为 `https://api.deepseek.com/anthropic`;保存卡片不会改写协议配置。 -展开**自定义设置 → 模型选项**编辑模型。两个提供方家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供方默认值,最后回退为文本。已知 pi-ai 提供方会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `settings.yaml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。 +展开**自定义设置 → 模型选项**编辑模型。两个提供商家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供商默认值,最后回退为文本。已知 pi-ai 提供商会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `settings.yaml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。 -### 新增与删除提供方 +### 新增与删除提供商 -**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片直接显示该表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用。面板在其方式首次显示时挂载,之后在卡片打开期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供方错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。 +**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片直接显示该表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用。面板在其方式首次显示时挂载,之后在卡片打开期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。 ### 首次运行弹窗 -版本化声明步骤完成后,DeepSeek 步骤从同一份合并快照投影首次运行就绪状态。用户已经能够到达的**任何**提供方都会直接结束该步骤、不做渲染;只有没有任何提供方的用户才会被询问官方 DeepSeek 密钥。「稍后配置」只完成这次协调器遍历;适配器缺失、路由不活动、合并失败、只读部署或能力不可用时,该步骤不渲染即完成——Models 仍是诊断界面。 +版本化声明步骤完成后,DeepSeek 步骤从同一份合并快照投影首次运行就绪状态。用户已经能够到达的**任何**提供商都会直接结束该步骤、不做渲染;只有没有任何提供商的用户才会被询问官方 DeepSeek 密钥。「稍后配置」只完成这次协调器遍历;适配器缺失、路由不活动、合并失败、只读部署或能力不可用时,该步骤不渲染即完成——Models 仍是诊断界面。 ### 扩展 slot -本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。 +本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供商」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。 ----- @@ -61,7 +61,7 @@ kind: "package-reference"
      实现细节——点击展开 -页面只持有脱敏后的描述符,从不持有完整设置分区:因此每次编辑都以 `settings.mutate` 路径操作落到已存分区上——每个改动字段一次 set、每个清除字段一次 unset、删除提供方行则一次 unset。 +页面只持有脱敏后的描述符,从不持有完整设置分区:因此每次编辑都以 `settings.mutate` 路径操作落到已存分区上——每个改动字段一次 set、每个清除字段一次 unset、删除提供商行则一次 unset。 ### 校验 @@ -73,7 +73,7 @@ kind: "package-reference" ### 引导协调器 -声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤面向 `llm-deepseek` 中的 `deepseek-official`,在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供方设置。 +声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤面向 `llm-deepseek` 中的 `deepseek-official`,在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供商设置。
      @@ -87,7 +87,7 @@ kind: "package-reference" - [ui-settings](../ui-settings/README.zh.md)——本页所依赖 scope 与 schema 服务所在的领域底座。 - [settings](../../settings/README.zh.md)——持久化用户设置 seam 及其文件提供方。 - [credentials](../../credentials/README.zh.md)——本页写入密钥所经的凭据引用 seam。 -- [llm](../../llm/README.zh.md)——本页所配置提供方所在的适配器注册表。 +- [llm](../../llm/README.zh.md)——本页所配置提供商所在的适配器注册表。 - [Web 配置平面](../../../.agents/notes/archived/architecture/2026-07-30-web-config-plane.md)——手写编辑器的设计依据。 ----- @@ -99,7 +99,7 @@ kind: "package-reference" #### KV Cache 影响 -无;该包既不组装也不发送提供方请求。 +无;该包既不组装也不发送提供商请求。 ## 已知限制与延期工作 diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 92c1e762dd..f8213d2237 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -6,10 +6,12 @@ * This is a create, not an edit, which is why it is its own form rather than * the provider editor with extra fields: the route id is being *chosen* here, * and the settings address does not exist until it is. It renders as the - * custom-API panel of the section's add card, whose mode switch is its title. - * One `settings.mutate` sets the whole profile at `providers.`; the key - * travels separately through `credentials/set` under the reference the profile - * records, exactly as an existing provider's key does. + * custom-API panel of the section's add card; the card's mode switch names it + * when both modes are offered, and with the custom mode alone the card shows + * this form directly. One `settings.mutate` sets the whole profile at + * `providers.`; the key travels separately through `credentials/set` + * under the reference the profile records, exactly as an existing provider's + * key does. * * The three fields a hand-declared route cannot default — endpoint, protocol, * and at least one model — are required here rather than at load, so the From 7d06fcdc05fa891f874716665430524bb39cd2c3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 19 Sep 2026 16:04:34 +0800 Subject: [PATCH 080/284] fix(settings-models): derive the add panels, lock the switch in flight, pair tabs with panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The add card's panels are now derived from the shown mode and the modes visited since it opened, and each renders only while its mode is still offered, so a refresh that changes which modes are offered — every configurable row gone, the pi-ai namespace unmounted, a route freed after the catalog was exhausted — can neither leave the card without a panel nor keep a form whose write the Host would refuse. The catalog draft resolves to the chosen row while its namespace can take the write, else to the first addable row; closing either panel forgets it, so a later refresh cannot open a row editor unasked. Both cards report a write or an endpoint interrogation in flight, and the switch and the catalog select lock while one is, since a switch underneath it would orphan the answer or the picker it opens. The SegmentedControl takes the owner's id and pairs each tab with its panel through `aria-controls` and `aria-labelledby`; a card offering one mode carries that mode as its title instead of an orphan tabpanel, and a locked segment names its reason as hover text. `fullWidth` goes: no consumer asked for it. --- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../src/SegmentedControl.module.css | 7 +- .../ui-primitives/src/SegmentedControl.tsx | 19 +- .../tests/segmented-control.client.spec.tsx | 31 +++- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- .../src/client/CustomProviderCard.tsx | 12 +- .../src/client/ModelListEditor.tsx | 10 +- .../src/client/ModelsSection.tsx | 166 +++++++++++++----- .../src/client/ProviderEditor.tsx | 10 ++ .../ui-settings-models/src/client/locales.ts | 4 + .../tests/components.client.spec.tsx | 165 +++++++++++++++++ .../tests/model-list-catalog.client.spec.tsx | 8 +- 16 files changed, 368 insertions(+), 80 deletions(-) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index eb89a4eb3f..f031a6b178 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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/ui-primitives/README.md -README.md: 61db832d764d127d8e0c050a10da384b34287316 -README.zh.md: b9c1013d8280202359682dbb59988b1824fbe8ab +README.md: adfdb1cac3136553bb1e45001089fa2fe19159f2 +README.zh.md: 85666ec13b623b180600ba3311424b97468ae348 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 61db832d76..adfdb1cac3 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -38,7 +38,7 @@ Check this table before writing a control in a feature package. A plugin cannot |---|---| | `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. | | `Switch` | Two-state toggle, 36×20. `label` is required, so the control cannot ship unnamed. | -| `SegmentedControl` | Tablist of two or more equal-width segments with one sliding indicator, for switching a card or panel between a few modes; the owner holds the selection, `label` names the list, and a segment may be `disabled` with a `title`. | +| `SegmentedControl` | Tablist of two or more equal-width segments with one sliding indicator, for switching a card or panel between a few modes; the owner holds the selection and `label` names the list. `id` seeds each tab's id (`-`) and the panel it controls (`--panel`), which the owner renders and points back at the tab with `aria-labelledby`; a segment may be `disabled` with a `title`, and `disabled` on the control locks every segment while the shown panel has work in flight. | | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | | `Input` | Single-line text entry for search boxes and inline forms. | | `Menu` | Dropdown of items, separators, and group labels, with nested submenus. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index b9c1013d82..85666ec13b 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -38,7 +38,7 @@ kind: "package-library" |---|---| | `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。 | | `Switch` | 36×20 的双态开关。`label` 必填,控件不可能在没有名称的情况下发布。 | -| `SegmentedControl` | 两段或更多等宽分段加一个滑动指示块的 tablist,用于在几种模式间切换一张卡片或面板;选中项由调用方持有,`label` 为列表命名,分段可 `disabled` 并带 `title`。 | +| `SegmentedControl` | 两段或更多等宽分段加一个滑动指示块的 tablist,用于在几种模式间切换一张卡片或面板;选中项由调用方持有,`label` 为列表命名。`id` 派生每个 tab 的 id(`-`)及其控制的面板 id(`--panel`),面板由调用方渲染并用 `aria-labelledby` 指回 tab;分段可 `disabled` 并带 `title`,控件级 `disabled` 在当前面板有进行中的操作时锁住全部分段。 | | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | | `Input` | 单行文本输入,用于搜索框与行内表单。 | | `Menu` | 由条目、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | diff --git a/packages/client/ui-primitives/src/SegmentedControl.module.css b/packages/client/ui-primitives/src/SegmentedControl.module.css index 4795c8f0e6..a35b25edd4 100644 --- a/packages/client/ui-primitives/src/SegmentedControl.module.css +++ b/packages/client/ui-primitives/src/SegmentedControl.module.css @@ -4,8 +4,7 @@ * the soft elevation shadow (border: 0), the way every elevated surface draws * its edge, so the elevation gate keeps it off the alias-border set. * - * Segments are equal-width grid tracks — sized to the widest label when the - * control hugs its content, sharing the container when it spans it — which is + * Segments are equal-width grid tracks sized to the widest label, which is * what lets one indicator be placed arithmetically: the tablist carries the * segment count and the selected index as custom properties, and the indicator * derives its width and offset from them instead of measuring the DOM. */ @@ -20,10 +19,6 @@ background: var(--dsw-alias-interactive-bg-hover); } -.control[data-full-width='true'] { - display: grid; -} - .indicator { position: absolute; top: 3px; diff --git a/packages/client/ui-primitives/src/SegmentedControl.tsx b/packages/client/ui-primitives/src/SegmentedControl.tsx index b260f0a22f..ffbe2a819e 100644 --- a/packages/client/ui-primitives/src/SegmentedControl.tsx +++ b/packages/client/ui-primitives/src/SegmentedControl.tsx @@ -2,6 +2,9 @@ // sliding indicator, for switching a card or panel between a few modes. The // owner holds the selection; `label` is required so the tablist never ships // without an accessible name, and every segment label is owner-localized. +// Each tab is `-` and controls the panel `--panel`, the +// element the owner renders for that mode and points back at the tab with +// `aria-labelledby`. import { useEffect, useRef } from 'react' import type { CSSProperties, KeyboardEvent } from 'react' @@ -52,24 +55,27 @@ function walk( /** * Render a segmented control. + * @param props.id - the owner's base id: each tab is `-` and names + * `--panel` as the panel it controls. * @param props.value - the selected option's value; the control is fully controlled. * @param props.options - the segments in display order; at least two. * @param props.onChange - called with the value a click or a walk key asks for, * never with the value already selected. * @param props.label - localized accessible name of the tablist. - * @param props.fullWidth - stretch across the container instead of hugging the - * segment labels; the segments stay equal-width either way. + * @param props.disabled - lock every segment, typically while the shown panel + * has a write or a fetch in flight that switching would orphan. * @param props.className - extra class for layout placement. * @returns the tablist element. */ export function SegmentedControl({ - value, options, onChange, label, fullWidth = false, className, + id, value, options, onChange, label, disabled = false, className, }: { + id: string value: Value options: readonly SegmentedControlOption[] onChange: (next: Value) => void label: string - fullWidth?: boolean + disabled?: boolean // `| undefined` so a caller can forward an optional class straight through // under exactOptionalPropertyTypes (a CSS-module lookup is string|undefined). className?: string | undefined @@ -104,7 +110,6 @@ export function SegmentedControl({ ref={list} role="tablist" aria-label={label} - data-full-width={fullWidth} className={clsx(css.control, className)} style={indicator} > @@ -114,11 +119,13 @@ export function SegmentedControl({ return ( diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index 4073eaec1f..44572cdf3d 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -68,7 +68,8 @@ export function apply(ctx: ClientContext): void { const pathInjected = (): OpenPathInjected => ({ hooks: { openInAppDesktop: paths.desktop }, loadDesktop: () => paths.load(), - openPath: (path, action) => paths.openPath(path, action), + openPath: (path, action, application) => paths.openPath(path, action, application), + applications: (path, signal) => paths.applications(path, signal), }) ctx.slots.inject('sidebar.right.tab.document.actions', () => ctx.slots.register({ name: 'sidebar.right.tab.document.actions', diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts index 6d32f759a0..bb198d640c 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -44,6 +44,8 @@ export const zh = { 'open.error': '打开失败', 'menu.toggle': '选择打开方式', 'menu.aria': '打开方式', + 'path.appDefault': '{app}(默认)', + 'path.appsError': '无法获取应用列表', 'path.open': '打开', 'path.open.tooltip': '用默认应用打开', 'path.more': '更多打开方式', @@ -66,6 +68,8 @@ export const en: Record = { 'open.error': 'Failed to open', 'menu.toggle': 'Choose an app to open in', 'menu.aria': 'Open in', + 'path.appDefault': '{app} (default)', + 'path.appsError': 'Could not load applications', 'path.open': 'Open', 'path.open.tooltip': 'Open in default app', 'path.more': 'More ways to open', diff --git a/packages/client/ui-open-in-app/src/client/open-path.ts b/packages/client/ui-open-in-app/src/client/open-path.ts index 161fcf104f..3c44e54a23 100644 --- a/packages/client/ui-open-in-app/src/client/open-path.ts +++ b/packages/client/ui-open-in-app/src/client/open-path.ts @@ -5,7 +5,7 @@ */ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionOpenWorkspacePathRequest } from '@deepseek-ai/dsh-api-session-controller/types' +import type { SessionOpenWorkspacePathRequest, SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types' /** What a path gesture asks of the Host desktop: the default application, or the file manager showing the file. */ export type OpenInAppPathAction = 'open' | 'reveal' @@ -18,6 +18,9 @@ export interface OpenInAppPathRemote { readonly session: { /** Whether the Host can hand a workspace path to a native desktop. */ canOpenWorkspacePath(): Promise> + workspacePathApplications( + request: { path: string }, signal?: AbortSignal, + ): Promise> /** Open one Host path in its default application, or reveal it in the file manager. */ openWorkspacePath(request: SessionOpenWorkspacePathRequest, signal?: AbortSignal): Promise> } @@ -47,10 +50,11 @@ export class OpenInAppPathController { * Open one Host path in its default application, or reveal it in the file manager. * @param path - absolute path on the Host, as the file's metadata reports it. * @param action - default application open, or file-manager reveal. + * @param application - registered application path for an explicit open. * @returns the failure kind to announce, or `null` once the Host acknowledged. */ - async openPath(path: string, action: OpenInAppPathAction): Promise { - const request: SessionOpenWorkspacePathRequest = action === 'reveal' ? { path, action } : { path } + async openPath(path: string, action: OpenInAppPathAction, application?: string): Promise { + const request: SessionOpenWorkspacePathRequest = action === 'reveal' ? { path, action } : { path, ...(application === undefined ? {} : { application }) } let ok = false try { ok = (await this.remote.session.openWorkspacePath(request)).ok @@ -61,6 +65,22 @@ export class OpenInAppPathController { return ok ? null : action === 'open' ? 'openError' : 'revealError' } + /** + * Refresh the file's OS associations; failures remain distinct from an empty handler list. + * @param path - file path reported by the Host. + * @param signal - lifetime of the requesting preview. + * @returns application metadata, or null when the query fails. + */ + async applications(path: string, signal: AbortSignal): Promise { + try { + const result = await this.remote.session.workspacePathApplications({ path }, signal) + return result.ok ? result.value : null + } catch (_error) { + // Carrier failure leaves default opening available while the menu reports the failed query. + return null + } + } + private async run(): Promise { let available = false try { diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index dab2b08df0..14dc706604 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -24,6 +24,7 @@ afterEach(() => { /** The Session Remote slice the path controls call; answers a desktop and acknowledges every gesture. */ const remote = { session: { + workspacePathApplications: vi.fn(async () => ({ ok: true as const, value: [] })), canOpenWorkspacePath: vi.fn(async () => ({ ok: true as const, value: true })), openWorkspacePath: vi.fn(async () => ({ ok: true as const, value: { opened: true as const } })), }, diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index ae9803c164..fd408119fd 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -40,6 +40,7 @@ function bench(over: { desktop?: boolean | null; openPath?: Bench['openPath'] } absolutePath: ABSOLUTE_PATH, useOpenInAppDesktop: useSelector(desktop), loadDesktop, + applications: vi.fn(async () => []), openPath, t, } as unknown as Bench['props'] diff --git a/packages/client/ui-open-in-app/tests/open-path.client.spec.ts b/packages/client/ui-open-in-app/tests/open-path.client.spec.ts index 2e8560f7cf..1b1702cc2f 100644 --- a/packages/client/ui-open-in-app/tests/open-path.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/open-path.client.spec.ts @@ -10,6 +10,7 @@ type SessionRemote = OpenInAppPathRemote['session'] function remoteOf(over: Partial = {}): OpenInAppPathRemote { return { session: { + workspacePathApplications: async () => ({ ok: true, value: [] }), canOpenWorkspacePath: async () => ({ ok: true, value: true }), openWorkspacePath: async () => ({ ok: true, value: { opened: true } }), ...over, @@ -69,3 +70,18 @@ describe('OpenInAppPathController gestures', () => { expect(await rejecting.openPath('/w/clip.mp4', 'open')).toBe('openError') }) }) + + +it('queries current handlers and carries an explicit application without changing the default gesture', async () => { + const applications = [{ id: '/Applications/Music.app', name: 'Music', default: true, icon: null }] + const query = vi.fn(async () => ({ ok: true as const, value: applications })) + const open = vi.fn(async () => ({ ok: true as const, value: { opened: true } })) + const controller = new OpenInAppPathController(remoteOf({ workspacePathApplications: query, openWorkspacePath: open })) + const signal = new AbortController().signal + expect(await controller.applications('/file.mp3', signal)).toEqual(applications) + expect(query).toHaveBeenCalledWith({ path: '/file.mp3' }, signal) + await controller.openPath('/file.mp3', 'open', '/Applications/Music.app') + expect(open).toHaveBeenLastCalledWith({ path: '/file.mp3', application: '/Applications/Music.app' }) + await controller.openPath('/file.mp3', 'open') + expect(open).toHaveBeenLastCalledWith({ path: '/file.mp3' }) +}) diff --git a/packages/util/native-command/src/file-applications.ts b/packages/util/native-command/src/file-applications.ts new file mode 100644 index 0000000000..830b8557d6 --- /dev/null +++ b/packages/util/native-command/src/file-applications.ts @@ -0,0 +1,86 @@ +/** Native file associations queried from the desktop that owns the path. */ +import { runNativeCommand } from './runner.ts' +import type { PathOpenerInternals } from './path-opener.ts' + +/** One OS-registered application capable of opening the requested file. */ +export interface NativeFileApplication { + /** OS application identifier; callers must revalidate it against the file's current handlers before opening. */ + readonly id: string + readonly name: string + readonly default: boolean + /** PNG data URL, or null when the desktop supplies no icon. */ + readonly icon: string | null +} + +/** AppKit runs inside the system JXA host; paths arrive as argv, never executable source. */ +const MAC_APPLICATIONS = ` +ObjC.import('AppKit'); +function run(argv) { + var workspace = $.NSWorkspace.sharedWorkspace; + var file = $.NSURL.fileURLWithPath(argv[0]); + var preferred = workspace.URLForApplicationToOpenURL(file); + var preferredPath = preferred.isNil() ? null : ObjC.unwrap(preferred.path); + var urls = workspace.URLsForApplicationsToOpenURL(file); + var apps = []; + for (var i = 0; i < urls.count; i++) { + var url = urls.objectAtIndex(i); + var path = ObjC.unwrap(url.path); + var icon = workspace.iconForFile(path); + var bitmap = $.NSBitmapImageRep.imageRepWithData(icon.TIFFRepresentation); + var png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({})); + apps.push({ + id: path, + name: ObjC.unwrap($.NSFileManager.defaultManager.displayNameAtPath(path)), + default: path === preferredPath, + icon: png.isNil() ? null : 'data:image/png;base64,' + ObjC.unwrap(png.base64EncodedStringWithOptions(0)) + }); + } + return JSON.stringify(apps); +}` + +/** + * List registered handlers in OS preference order, including the current default and application icons. + * @param path - verified absolute local file path. + * @param signal - caller lifetime, propagated to the OS query. + * @param internals - platform and command adapter for deterministic tests. + * @returns current file handlers; an empty list when the platform has no association query. + */ +export async function nativeFileApplications( + path: string, signal: AbortSignal, internals: PathOpenerInternals = {}, +): Promise { + signal.throwIfAborted() + const platform = internals.platform ?? process.platform + if (platform !== 'darwin') return [] + const run = internals.run ?? runNativeCommand + const { stdout } = await run('/usr/bin/osascript', ['-l', 'JavaScript', '-e', MAC_APPLICATIONS, path], signal) + const value: unknown = JSON.parse(stdout) + if (!Array.isArray(value)) throw new Error('Invalid native application list') + const applications: NativeFileApplication[] = [] + for (const entry of value) { + if (typeof entry !== 'object' || entry === null + || typeof entry.id !== 'string' || entry.id.length === 0 + || typeof entry.name !== 'string' || typeof entry.default !== 'boolean' + || !(entry.icon === null || (typeof entry.icon === 'string' && /^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(entry.icon)))) { + throw new Error('Invalid native application entry') + } + applications.push({ id: entry.id, name: entry.name, default: entry.default, icon: entry.icon }) + } + return applications +} + +/** + * Open a file in a currently registered handler; stale or arbitrary application identifiers are rejected. + * @param path - verified absolute local file path. + * @param application - identifier returned by the file association query. + * @param signal - caller lifetime, propagated to query and launch. + * @param internals - platform and command adapter for deterministic tests. + * @returns after the system launcher accepts the file. + */ +export async function openNativeFileApplication( + path: string, application: string, signal: AbortSignal, internals: PathOpenerInternals = {}, +): Promise { + const apps = await nativeFileApplications(path, signal, internals) + if (!apps.some(app => app.id === application)) throw new Error('Application is not registered for this file') + const run = internals.run ?? runNativeCommand + await run('/usr/bin/open', ['-a', application, path], signal) +} diff --git a/packages/util/native-command/src/index.ts b/packages/util/native-command/src/index.ts index 29a0f16c18..ea948a745b 100644 --- a/packages/util/native-command/src/index.ts +++ b/packages/util/native-command/src/index.ts @@ -18,3 +18,6 @@ export type { PathOpenerInternals, PathOpenerRunner, } from './path-opener.ts' + +export { nativeFileApplications, openNativeFileApplication } from './file-applications.ts' +export type { NativeFileApplication } from './file-applications.ts' diff --git a/packages/util/native-command/tests/file-applications.spec.ts b/packages/util/native-command/tests/file-applications.spec.ts new file mode 100644 index 0000000000..b6f2e3328c --- /dev/null +++ b/packages/util/native-command/tests/file-applications.spec.ts @@ -0,0 +1,37 @@ +/** File association results and explicit handler authorization at the native command adapter. */ +import { describe, expect, it, vi } from 'vitest' +import { nativeFileApplications, openNativeFileApplication } from '../src/file-applications.ts' + +const application = { id: '/Applications/Music.app', name: 'Music', default: true, icon: null } +const signal = new AbortController().signal + +describe('native file associations', () => { + it('passes file paths as arguments and preserves the desktop default', async () => { + const run = vi.fn(async () => ({ stdout: JSON.stringify([application]), stderr: '' })) + const path = '/tmp/中文 $(touch nope).mp3' + await expect(nativeFileApplications(path, signal, { platform: 'darwin', run })).resolves.toEqual([application]) + expect(run).toHaveBeenCalledWith('/usr/bin/osascript', ['-l', 'JavaScript', '-e', expect.any(String), path], signal) + }) + + it.each(['{}', '[null]', '[{"id":1}]', JSON.stringify([{ ...application, icon: 'javascript:alert(1)' }])])('rejects malformed native output %s', async (stdout) => { + await expect(nativeFileApplications('/file.mp3', signal, { + platform: 'darwin', run: async () => ({ stdout, stderr: '' }), + })).rejects.toThrow() + }) + + it('launches only a currently registered application with argv', async () => { + const run = vi.fn(async () => ({ stdout: JSON.stringify([application]), stderr: '' })) + await openNativeFileApplication('/file.mp3', application.id, signal, { platform: 'darwin', run }) + expect(run).toHaveBeenLastCalledWith('/usr/bin/open', ['-a', application.id, '/file.mp3'], signal) + run.mockClear() + await expect(openNativeFileApplication('/file.mp3', '/arbitrary.app', signal, { platform: 'darwin', run })).rejects.toThrow('not registered') + expect(run).toHaveBeenCalledOnce() + }) + + it('does not query after cancellation or on unsupported platforms', async () => { + const run = vi.fn() + await expect(nativeFileApplications('/file.mp3', signal, { platform: 'freebsd', run })).resolves.toEqual([]) + await expect(nativeFileApplications('/file.mp3', AbortSignal.abort(), { platform: 'darwin', run })).rejects.toThrow() + expect(run).not.toHaveBeenCalled() + }) +}) From d6d9d47c4929a7eff2ed27184f822814105acd35 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:32:57 +0800 Subject: [PATCH 089/284] fix: constrain application icon pixels before transport --- .../ui-open-in-app/tests/open-path-action.client.spec.tsx | 8 ++++---- packages/util/native-command/src/file-applications.ts | 6 +++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index fd408119fd..b2a14bca11 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -66,10 +66,10 @@ describe('OpenPathAction gestures', () => { render() const main = screen.getByRole('button', { name: zh['path.open'] }) await act(async () => { fireEvent.click(main) }) - expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open') + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.reveal'] })) }) - expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal') + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined) expect(screen.queryByRole('menu')).toBeNull() }) @@ -84,7 +84,7 @@ describe('OpenPathAction gestures', () => { expect(b.openPath).not.toHaveBeenCalled() fireEvent.click(more) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.defaultApp'] })) }) - expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open') + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) }) it('disables both halves while its own gesture settles and leaves no failure on the control', async () => { @@ -113,7 +113,7 @@ describe('OpenPathEmptyAction', () => { const b = bench() render() await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.unpreviewable'] })) }) - expect(b.openPath).toHaveBeenCalledWith(ABSOLUTE_PATH, 'open') + expect(b.openPath).toHaveBeenCalledWith(ABSOLUTE_PATH, 'open', undefined) expect(screen.queryByRole('alert')).toBeNull() }) diff --git a/packages/util/native-command/src/file-applications.ts b/packages/util/native-command/src/file-applications.ts index 830b8557d6..7777a7f6fc 100644 --- a/packages/util/native-command/src/file-applications.ts +++ b/packages/util/native-command/src/file-applications.ts @@ -26,7 +26,11 @@ function run(argv) { var url = urls.objectAtIndex(i); var path = ObjC.unwrap(url.path); var icon = workspace.iconForFile(path); - var bitmap = $.NSBitmapImageRep.imageRepWithData(icon.TIFFRepresentation); + var thumbnail = $.NSImage.alloc.initWithSize($.NSMakeSize(32, 32)); + thumbnail.lockFocus; + icon.drawInRectFromRectOperationFraction($.NSMakeRect(0, 0, 32, 32), $.NSZeroRect, $.NSCompositingOperationSourceOver, 1); + thumbnail.unlockFocus; + var bitmap = $.NSBitmapImageRep.imageRepWithData(thumbnail.TIFFRepresentation); var png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({})); apps.push({ id: path, From 97877bd8b48a9e0440868aa717558940d5db2071 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:34:24 +0800 Subject: [PATCH 090/284] test: cover file handler selection and shared path authorization --- packages/api/session-controller/src/index.ts | 5 +++-- packages/api/session-controller/src/types.ts | 2 +- .../session-open-workspace-path.host.spec.ts | 22 +++++++++++++++++++ .../session-controller/tests/test-remote.ts | 5 +++++ .../tests/open-path-action.client.spec.tsx | 19 ++++++++++++++++ packages/util/native-command/package.json | 5 ++++- .../native-command/src/file-applications.ts | 12 +++------- packages/util/native-command/src/types.ts | 11 ++++++++++ 8 files changed, 68 insertions(+), 13 deletions(-) create mode 100644 packages/util/native-command/src/types.ts diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 0f0a44339d..0658b01127 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' -import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath, type NativeFileApplication } from '@deepseek-ai/dsh-native-command' +import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' @@ -29,6 +29,7 @@ import { SessionSkillCatalog } from './skill-catalog.ts' import { SessionMediaReferences } from './media-references.ts' import type { ModelCatalog, + SessionWorkspacePathApplication, SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, @@ -349,7 +350,7 @@ export class SessionController extends TypertRemoteService { @Remote('workspacePathApplications') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, - ): Promise { + ): Promise { if (!this.canOpenPath()) return [] await this.verifyDesktopPath(request.path, signal) return this.fileApplications(resolve(request.path), signal) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index ac155e7ab5..e238e518b5 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -1,4 +1,4 @@ -import type { NativeFileApplication } from '@deepseek-ai/dsh-native-command' +import type { NativeFileApplication } from '@deepseek-ai/dsh-native-command/types' /** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ import type { diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index 6a14c7761d..63df8886bd 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -215,3 +215,25 @@ it('dispatches default-app opening to the association adapter', async () => { await controller.openWorkspacePath({ path: '/report.html' }, signal) expect(open).toHaveBeenCalledWith('/report.html', signal) }) + + +it('uses the same path verification for handler queries and explicit application opening', async () => { + const ctx = await context() + const applications = [{ id: '/Applications/Music.app', name: 'Music', default: true, icon: null }] + const fileApplications = vi.fn(async () => applications) + const openFileApplication = vi.fn(async () => {}) + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, + fileApplications, openFileApplication, + }) + const signal = new AbortController().signal + expect(await controller.workspacePathApplications({ path: '/file.mp3' }, signal)).toEqual(applications) + await controller.openWorkspacePath({ path: '/file.mp3', application: applications[0]!.id }, signal) + expect(openFileApplication).toHaveBeenCalledWith(expect.stringContaining('file.mp3'), applications[0]!.id, signal) + const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + onTestFinished(() => { mapping.mockRestore() }) + await expect(controller.workspacePathApplications({ path: '/remote.mp3' }, signal)).rejects.toThrow('verified Host path') + await expect(controller.openWorkspacePath({ path: '/remote.mp3', application: applications[0]!.id }, signal)).rejects.toThrow('verified Host path') + expect(fileApplications).toHaveBeenCalledOnce() + expect(openFileApplication).toHaveBeenCalledOnce() +}) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index b4d3db8198..152cffe9b2 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -1,3 +1,4 @@ +import type { SessionControllerInternals } from '../src/index.ts' /** Test-only direct Remote face over the Session Controller's internal controllers. */ import { SessionLogOffset } from '@deepseek-ai/dsh-session' @@ -94,6 +95,8 @@ export interface TestSessionRemoteDefaults { readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly fileApplications?: SessionControllerInternals['fileApplications'] + readonly openFileApplication?: SessionControllerInternals['openFileApplication'] readonly revealPath?: (path: string, signal: AbortSignal) => Promise readonly canOpenPath?: () => boolean } @@ -288,6 +291,8 @@ function installControllers( }, { ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + ...defaults.fileApplications === undefined ? {} : { fileApplications: defaults.fileApplications }, + ...defaults.openFileApplication === undefined ? {} : { openFileApplication: defaults.openFileApplication }, ...defaults.revealPath === undefined ? {} : { revealPath: defaults.revealPath }, ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, }, diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index b2a14bca11..6f34ced839 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -141,3 +141,22 @@ it('dismisses a failure on schedule even when its owner rerenders', async () => act(() => { vi.advanceTimersByTime(1) }) expect(screen.queryByRole('alert')).toBeNull() }) + + +it('shows the default icon and current application list, and selects an application without replacing default opening', async () => { + const b = bench() + const icon = 'data:image/png;base64,aGVsbG8=' + const applications = vi.fn(async () => [ + { id: '/Music.app', name: 'Music', default: true, icon }, + { id: '/Player.app', name: 'Player', default: false, icon: null }, + ]) + const view = render() + await act(async () => {}) + expect(view.container.querySelector('[data-open-path-open] img')?.getAttribute('src')).toBe(icon) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) + expect(screen.getByRole('menuitem', { name: 'Music(默认)' })).toBeTruthy() + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Player' })) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', '/Player.app') + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) +}) diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 8060a32a2a..a4a749b100 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -19,7 +19,10 @@ "default": "./lib/index.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./types": { + "types": "./lib/types/types.d.ts" + } }, "files": [ "lib/index.js", diff --git a/packages/util/native-command/src/file-applications.ts b/packages/util/native-command/src/file-applications.ts index 7777a7f6fc..f865d93145 100644 --- a/packages/util/native-command/src/file-applications.ts +++ b/packages/util/native-command/src/file-applications.ts @@ -2,15 +2,9 @@ import { runNativeCommand } from './runner.ts' import type { PathOpenerInternals } from './path-opener.ts' -/** One OS-registered application capable of opening the requested file. */ -export interface NativeFileApplication { - /** OS application identifier; callers must revalidate it against the file's current handlers before opening. */ - readonly id: string - readonly name: string - readonly default: boolean - /** PNG data URL, or null when the desktop supplies no icon. */ - readonly icon: string | null -} +import type { NativeFileApplication } from './types.ts' + +export type { NativeFileApplication } from './types.ts' /** AppKit runs inside the system JXA host; paths arrive as argv, never executable source. */ const MAC_APPLICATIONS = ` diff --git a/packages/util/native-command/src/types.ts b/packages/util/native-command/src/types.ts new file mode 100644 index 0000000000..4b67d05159 --- /dev/null +++ b/packages/util/native-command/src/types.ts @@ -0,0 +1,11 @@ +/** Browser-safe metadata for native file associations. */ + +/** One OS-registered application capable of opening the requested file. */ +export interface NativeFileApplication { + /** OS application identifier; callers must revalidate it against the file's current handlers before opening. */ + readonly id: string + readonly name: string + readonly default: boolean + /** PNG data URL, or null when the desktop supplies no icon. */ + readonly icon: string | null +} From 03d44191b391c00e84bf82527cbf09f507232824 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:35:56 +0800 Subject: [PATCH 091/284] fix: expose browser-safe file association metadata to Remote generation --- packages/util/native-command/package.json | 6 ++++-- tsconfig.base.json | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index a4a749b100..391b6c023d 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -21,12 +21,14 @@ "./src/*": "./src/*", "./package.json": "./package.json", "./types": { - "types": "./lib/types/types.d.ts" + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" } }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/types.js" ], "license": "MIT", "peerDependencies": { diff --git a/tsconfig.base.json b/tsconfig.base.json index cc1b65cc78..3f58c32145 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -366,6 +366,7 @@ "@deepseek-ai/dsh-mcp-client": ["./packages/mcp/mcp-client/src"], "@deepseek-ai/dsh-mcp-resources": ["./packages/mcp/mcp-resources/src"], "@deepseek-ai/dsh-message-feedback": ["./packages/feedback/message-feedback/src"], + "@deepseek-ai/dsh-native-command/types": ["./packages/util/native-command/src/types.ts"], "@deepseek-ai/dsh-native-command": ["./packages/util/native-command/src"], "@deepseek-ai/dsh-office-to-pdf": ["./packages/document/office-to-pdf/src"], "@deepseek-ai/dsh-output-retention": ["./packages/util/output-retention/src"], From 083901120492c835692780824a0bdac500e582bf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:37:11 +0800 Subject: [PATCH 092/284] fix: include native association types in client compilation --- .../session-controller/tsconfig.client.json | 75 ++++++++++++++----- .../tests/open-path-action.client.spec.tsx | 26 +++++++ packages/util/native-command/src/types.d.ts | 11 +++ .../util/native-command/src/types.d.ts.map | 1 + packages/util/native-command/src/types.js | 3 + packages/util/native-command/src/types.js.map | 1 + 6 files changed, 99 insertions(+), 18 deletions(-) create mode 100644 packages/util/native-command/src/types.d.ts create mode 100644 packages/util/native-command/src/types.d.ts.map create mode 100644 packages/util/native-command/src/types.js create mode 100644 packages/util/native-command/src/types.js.map diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 3bb07893e2..b8a0d78fc2 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -11,23 +11,62 @@ "src/remote-events.ts" ], "references": [ - { "path": "../../../vendor/cordis" }, - { "path": "../gateway/tsconfig.client.json" }, - { "path": "../../attachment/attachment" }, - { "path": "../../client/connection/tsconfig.client.json" }, - { "path": "../../client/file-upload/tsconfig.client.json" }, - { "path": "../../client/store" }, - { "path": "../../context/file-reference" }, - { "path": "../../core/session" }, - { "path": "../../jobs/jobs" }, - { "path": "../../llm/llm" }, - { "path": "../../session/session-projection" }, - { "path": "../../session/session-title" }, - { "path": "../../subagent/subagent" }, - { "path": "../../util/brand" }, - { "path": "../../util/crypto" }, - { "path": "../../util/workspace-path" }, - { "path": "../../workspace/workspace" }, - { "path": "../../typert/protocol" } + { + "path": "../../../vendor/cordis" + }, + { + "path": "../gateway/tsconfig.client.json" + }, + { + "path": "../../attachment/attachment" + }, + { + "path": "../../client/connection/tsconfig.client.json" + }, + { + "path": "../../client/file-upload/tsconfig.client.json" + }, + { + "path": "../../client/store" + }, + { + "path": "../../context/file-reference" + }, + { + "path": "../../core/session" + }, + { + "path": "../../jobs/jobs" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../session/session-projection" + }, + { + "path": "../../session/session-title" + }, + { + "path": "../../subagent/subagent" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/crypto" + }, + { + "path": "../../util/workspace-path" + }, + { + "path": "../../workspace/workspace" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../util/native-command" + } ] } diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index 6f34ced839..f72a9d9780 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -160,3 +160,29 @@ it('shows the default icon and current application list, and selects an applicat await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) }) + + +it('ignores an obsolete file query and refreshes the default application when the menu opens', async () => { + const b = bench() + const old = Promise.withResolvers() + const applications = vi.fn() + .mockImplementationOnce(() => old.promise) + .mockResolvedValue([{ id: '/Player.app', name: 'Player', default: true, icon: null }]) + const view = render() + view.rerender() + await act(async () => { old.resolve([{ id: '/Old.app', name: 'Old', default: true, icon: null }]) }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) + expect(screen.queryByRole('menuitem', { name: 'Old(默认)' })).toBeNull() + expect(screen.getByRole('menuitem', { name: 'Player(默认)' })).toBeTruthy() + expect(applications.mock.calls[0]![1].aborted).toBe(true) + expect(applications).toHaveBeenLastCalledWith('/other.mp3', expect.any(AbortSignal)) +}) + +it('keeps default opening available when application discovery fails', async () => { + const b = bench() + render( null} />) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) + expect(screen.getByRole('menuitem', { name: zh['path.appsError'] }).getAttribute('aria-disabled')).toBe('true') + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.defaultApp'] })) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) +}) diff --git a/packages/util/native-command/src/types.d.ts b/packages/util/native-command/src/types.d.ts new file mode 100644 index 0000000000..9206e9b400 --- /dev/null +++ b/packages/util/native-command/src/types.d.ts @@ -0,0 +1,11 @@ +/** Browser-safe metadata for native file associations. */ +/** One OS-registered application capable of opening the requested file. */ +export interface NativeFileApplication { + /** OS application identifier; callers must revalidate it against the file's current handlers before opening. */ + readonly id: string + readonly name: string + readonly default: boolean + /** PNG data URL, or null when the desktop supplies no icon. */ + readonly icon: string | null +} +//# sourceMappingURL=types.d.ts.map diff --git a/packages/util/native-command/src/types.d.ts.map b/packages/util/native-command/src/types.d.ts.map new file mode 100644 index 0000000000..378f0bc5ab --- /dev/null +++ b/packages/util/native-command/src/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAE1D,2EAA2E;AAC3E,MAAM,WAAW,qBAAqB;IACpC,gHAAgH;IAChH,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B"} \ No newline at end of file diff --git a/packages/util/native-command/src/types.js b/packages/util/native-command/src/types.js new file mode 100644 index 0000000000..77e2c20121 --- /dev/null +++ b/packages/util/native-command/src/types.js @@ -0,0 +1,3 @@ +/** Browser-safe metadata for native file associations. */ +export {}; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/packages/util/native-command/src/types.js.map b/packages/util/native-command/src/types.js.map new file mode 100644 index 0000000000..c8c16fbd5f --- /dev/null +++ b/packages/util/native-command/src/types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA,0DAA0D"} \ No newline at end of file From 0ae1b2f91cb1f070179c9d056559f1d9c2d2ef0f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:39:42 +0800 Subject: [PATCH 093/284] docs: describe native file application selection and platform scope --- docs/subsystems/session.i18n.yaml | 4 ++-- docs/subsystems/session.md | 10 ++++++++++ docs/subsystems/session.zh.md | 10 ++++++++++ packages/api/session-controller/README.i18n.yaml | 4 ++-- packages/api/session-controller/README.md | 2 ++ packages/api/session-controller/README.zh.md | 2 ++ packages/client/ui-open-in-app/README.i18n.yaml | 4 ++-- packages/client/ui-open-in-app/README.md | 2 ++ packages/client/ui-open-in-app/README.zh.md | 2 ++ .../extensions/tool-cordis/src/api-catalog.ts | 16 +++++++++++++++- packages/util/native-command/README.i18n.yaml | 4 ++-- packages/util/native-command/README.md | 2 ++ packages/util/native-command/README.zh.md | 2 ++ packages/util/native-command/src/types.d.ts | 11 ----------- packages/util/native-command/src/types.d.ts.map | 1 - packages/util/native-command/src/types.js | 3 --- packages/util/native-command/src/types.js.map | 1 - scripts/gen-cordis-catalog.ts | 1 + 18 files changed, 56 insertions(+), 25 deletions(-) delete mode 100644 packages/util/native-command/src/types.d.ts delete mode 100644 packages/util/native-command/src/types.d.ts.map delete mode 100644 packages/util/native-command/src/types.js delete mode 100644 packages/util/native-command/src/types.js.map diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 83eecfd1c7..15e84bb5d7 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -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 docs/subsystems/session.md -session.md: 62f7785d4d1db9c30706a8b967e6f4073b810149 -session.zh.md: 7cd3c38162df209d245d3f1085ab1edce002531a +session.md: 5c8d27167fb95e4ab2da952ea6edb47aa6afd99b +session.zh.md: b9e7299689d535f56b48e53fd4b2dff152b3b78f diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 62f7785d4d..5c8d27167f 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -758,6 +758,8 @@ The backends that consume this contract are on [persistence.md](persistence.md). `SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`; optional `action: "reveal"` selects file-manager navigation instead of default-application opening. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. +The optional `application` selects a registered file handler without changing the system default; `workspacePathApplications({ path })` returns current handlers, names, icons, and default selection after Host path verification. + @@ -845,6 +847,14 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | */ @Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise +/** + * Query current file handlers on the serving desktop without activating an Agent. + * @param request - file path in Host filesystem syntax. + * @param signal - caller lifetime, propagated to filesystem and desktop queries. + * @returns OS application names, icons, and default selection; empty when desktop opening is unavailable. + */ +@Remote('workspacePathApplications') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, ): Promise + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 7cd3c38162..b9e7299689 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -762,6 +762,8 @@ interface TurnEndReasonMap { `SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径;controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。 可选的 `action: "reveal"` 选择文件管理器导航;省略时使用默认应用打开。 +可选的 `application` 指定文件关联应用,不修改系统默认值;`workspacePathApplications({ path })` 在校验 Host 路径后返回当前关联应用、名称、图标和默认项。 + @@ -849,6 +851,14 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | */ @Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise +/** + * Query current file handlers on the serving desktop without activating an Agent. + * @param request - file path in Host filesystem syntax. + * @param signal - caller lifetime, propagated to filesystem and desktop queries. + * @returns OS application names, icons, and default selection; empty when desktop opening is unavailable. + */ +@Remote('workspacePathApplications') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, ): Promise + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index d04576c269..3ed051d32d 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: a0e18910d14f879171a1415476a7e05a0d261a8a -README.zh.md: 58466e387fd604137a2358e8b9a7de3320691034 +README.md: 8e879e5d26f9aa3f920789499af80e12c6beaa44 +README.zh.md: ac6e775e0f7a0a29057cd37c310fd6d00cd38a5f diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index a0e18910d1..8e879e5d26 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -68,6 +68,8 @@ References keep local Session data, scoped Contexts, and history streams alive, `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. +`workspacePathApplications({ path })` returns the serving desktop’s file handlers after the same filesystem mapping check used by `openWorkspacePath`. An optional `application` in the open request selects a currently registered handler without changing the system default. Application names, default selection, icons, and platform coverage come from [native-command](../../util/native-command/README.md). These operations do not activate an Agent or append Session events. + ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 58466e387f..ac6e775e0f 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -68,6 +68,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 +`workspacePathApplications({ path })` 使用与 `openWorkspacePath` 相同的文件系统映射校验,返回服务端桌面上该文件的关联应用。打开请求中的可选 `application` 指定当前关联的应用,不修改系统默认应用。应用名称、默认项、图标和平台支持范围由 [native-command](../../util/native-command/README.zh.md) 提供。这些操作不激活 Agent,也不追加会话事件。 + ----- diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index fbf2a75bc6..cc15c6a7eb 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -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/ui-open-in-app/README.md -README.md: b70b39e79a172badc2b237083d6cd268e9cce06e -README.zh.md: 9d47e57cfe388b3823cee648f1fdf3187b534d78 +README.md: a21a0b3bf3cc5dabe79dc2aa907640d66c8b25c5 +README.zh.md: cbbefaa2d619f591167f7b7775b2eb4c2c945ff1 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index b70b39e79a..a21a0b3bf3 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -33,6 +33,8 @@ The main button shows the remembered application's icon — the real application In the document preview, the header's "Open ▾" split button opens the previewed file in the Host's default application from its main button, and its menu offers that action and "Show file location" (the file manager's reveal). A file the preview cannot render, whether an unsupported suffix such as video or an archive or a file the reader rejected as non-text or oversized, shows an "Open in default app" button in its empty state where Retry would otherwise stand. Both controls hand the Host the absolute path the file's metadata reports, appear only after the Host answered that a desktop is available, disable only while their own gesture settles, and announce a failed gesture once through a transient toast ("Could not open. Try again." or "Could not show the file location. Try again."); nothing stays on the control afterwards. +The document header shows the default application’s icon when available. Its menu lists the file’s OS-registered applications and marks the default. Choosing an application opens this file once; the main button continues to use the system default. Associations refresh when the file or menu visibility changes; cancelled queries cannot replace another file’s results. A failed query displays a menu message while default opening and reveal remain available. Platform coverage follows [native-command](../../util/native-command/README.md). + ----- diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index 9d47e57cfe..cbbefaa2d6 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" 在文档预览里,头部的「打开 ▾」分体按钮的主按钮用 Host 默认应用打开当前文件,菜单提供同一动作和「显示文件位置」(文件管理器的定位)。预览无法渲染的文件,无论是视频、压缩包这类不支持的后缀,还是被读取器判为非文本或过大的文件,都会在空态里原本放重试的位置显示「用默认应用打开」按钮。两个控件都把文件元数据报出的绝对路径交给 Host,只在 Host 回答有桌面之后出现,只在自己的手势结算期间禁用,失败时通过短暂 toast 提示一次(「打开失败,请重试」或「无法显示文件位置,请重试」),之后控件上不残留任何状态。 +文档标题栏在可用时显示默认应用图标。菜单列出系统为该文件注册的应用,并标记默认项。选择应用只用于本次打开,主按钮始终使用系统默认应用。文件或菜单显示状态变化时会重新查询关联应用;已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,默认打开和定位仍可使用。平台支持范围见 [native-command](../../util/native-command/README.zh.md)。 + ----- diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 78975d4a43..1dbd2216b2 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1744,6 +1744,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'confirmation after the native opener accepts the path.', throws: ['RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails.'], }, + { + signature: '@Remote(\'workspacePathApplications\') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, ): Promise', + description: 'Query current file handlers on the serving desktop without activating an Agent.', + parameters: [{ name: 'request', description: 'file path in Host filesystem syntax.' }, { name: 'signal', description: 'caller lifetime, propagated to filesystem and desktop queries.' }], + returns: 'OS application names, icons, and default selection; empty when desktop opening is unavailable.', + }, { signature: '@Remote(\'rename\') rename(request: SessionRenameRequest): Promise', description: 'Rename one Session after explicitly resuming it.', @@ -5271,6 +5277,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ModelReasoningEffort', declaration: 'export interface ModelReasoningEffort {\n readonly id: string;\n readonly name: string;\n readonly description?: string;\n}', }, + { + name: 'NativeFileApplication', + declaration: 'export interface NativeFileApplication {\n readonly id: string;\n readonly name: string;\n readonly default: boolean;\n readonly icon: string | null;\n}', + }, { name: 'ObjectJsonSchema', declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', @@ -5981,7 +5991,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionOpenWorkspacePathRequest', - declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly action?: \'reveal\';\n readonly path: string;\n}', + declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly action?: \'reveal\';\n readonly application?: string;\n readonly path: string;\n}', }, { name: 'SessionOpenWorkspacePathValue', @@ -6235,6 +6245,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionWireHeader', declaration: 'export interface SessionWireHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly isSeeded: boolean;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}', }, + { + name: 'SessionWorkspacePathApplication', + declaration: 'export type SessionWorkspacePathApplication = NativeFileApplication;', + }, { name: 'SettingsApplies', declaration: 'export type SettingsApplies = \'live\' | \'restart\';', diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 8bc90e6e8d..d9fba84086 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/README.i18n.yaml @@ -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/util/native-command/README.md -README.md: 9abf4031a915121b6fd2a218473b27c864f8ced8 -README.zh.md: 083318cf4bb37e1c18f016512d58df8bb8f3aec5 +README.md: 8d193855bfb5295b17378312da7d85017867501f +README.zh.md: 4a2fed91fa8ba0c6ae03ad6f91f296352f93fe87 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 9abf4031a9..8d193855bf 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -49,6 +49,8 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ `revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that a desktop window selected the file. +`nativeFileApplications(path, signal)` queries macOS LaunchServices for the file’s registered handlers, current default, localized names, and PNG application icons. `openNativeFileApplication(path, application, signal)` rechecks membership before launching the selected handler. Callers authorize the local file path. Windows and Linux currently return no handlers; default opening and reveal remain available through the path openers. + ----- diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 083318cf4b..4a2fed91fa 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -49,6 +49,8 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s `revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明桌面窗口已选中文件。 +`nativeFileApplications(path, signal)` 从 macOS LaunchServices 查询文件的关联应用、当前默认应用、本地化名称和 PNG 应用图标。`openNativeFileApplication(path, application, signal)` 在启动指定应用前重新核对关联列表。调用方负责验证本地文件路径。Windows 和 Linux 当前返回空列表;默认打开和文件定位仍由路径打开接口提供。 + ----- diff --git a/packages/util/native-command/src/types.d.ts b/packages/util/native-command/src/types.d.ts deleted file mode 100644 index 9206e9b400..0000000000 --- a/packages/util/native-command/src/types.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** Browser-safe metadata for native file associations. */ -/** One OS-registered application capable of opening the requested file. */ -export interface NativeFileApplication { - /** OS application identifier; callers must revalidate it against the file's current handlers before opening. */ - readonly id: string - readonly name: string - readonly default: boolean - /** PNG data URL, or null when the desktop supplies no icon. */ - readonly icon: string | null -} -//# sourceMappingURL=types.d.ts.map diff --git a/packages/util/native-command/src/types.d.ts.map b/packages/util/native-command/src/types.d.ts.map deleted file mode 100644 index 378f0bc5ab..0000000000 --- a/packages/util/native-command/src/types.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAE1D,2EAA2E;AAC3E,MAAM,WAAW,qBAAqB;IACpC,gHAAgH;IAChH,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B"} \ No newline at end of file diff --git a/packages/util/native-command/src/types.js b/packages/util/native-command/src/types.js deleted file mode 100644 index 77e2c20121..0000000000 --- a/packages/util/native-command/src/types.js +++ /dev/null @@ -1,3 +0,0 @@ -/** Browser-safe metadata for native file associations. */ -export {}; -//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/packages/util/native-command/src/types.js.map b/packages/util/native-command/src/types.js.map deleted file mode 100644 index c8c16fbd5f..0000000000 --- a/packages/util/native-command/src/types.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA,0DAA0D"} \ No newline at end of file diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index c3a8c6a239..dd5f0f3d31 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -379,6 +379,7 @@ export const LINK_MAP: Readonly> = { ModelCatalog: 'session.md', SessionOpenWorkspacePathRequest: 'session.md', SessionOpenWorkspacePathValue: 'session.md', + SessionWorkspacePathApplication: 'session.md', SessionModels: 'session.md', SessionModelsRequest: 'session.md', SessionPage: 'session.md', From e435340eff439ec1be0f3fdc2a8a8658cabff793 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:41:48 +0800 Subject: [PATCH 094/284] test: exercise application selection through the recorded Web session --- apps/web/tests/document-preview.e2e.ts | 25 ++++++++++++++++--- .../tests/open-path-action.client.spec.tsx | 2 +- .../native-command/src/file-applications.ts | 4 +-- .../tests/file-applications.spec.ts | 4 +-- .../document-preview/applications.expected.md | 5 ++++ 5 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 snapshots/web/document-preview/applications.expected.md diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 31b2fd06b6..922ea15992 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -102,10 +102,17 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () const command = process.platform === 'darwin' ? 'open' : 'xdg-open' await writeFile(join(nativeRoot, command), `#!/usr/bin/env node const fs = require('node:fs'); -const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; -const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; +const path = process.argv[2] === '-a' ? process.argv[4] : process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; +const action = process.argv[2] === '-a' ? 'application' : process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '\\n'); `, { mode: 0o700 }) + if (process.platform === 'darwin') { + const apps = [ + { id: '/Applications/Test Player.app', name: 'Test Player', default: true, icon: `data:image/png;base64,${TINY_PNG.toString('base64')}` }, + { id: '/Applications/Other Player.app', name: 'Other Player', default: false, icon: null }, + ] + await writeFile(join(nativeRoot, 'osascript'), `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(JSON.stringify(apps))});\n`, { mode: 0o700 }) + } vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) } // The Open In rows carry the default-application controls; the SSH marker @@ -756,13 +763,25 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + expect(gestures[0]).toEqual({ path: clip, action: 'open' }) expect(gestures[1]?.action).toBe('reveal') expect([clip, cwd]).toContain(gestures[1]?.path) + if (process.platform === 'darwin') { + await expect.poll(() => headerOpen.locator('img').count()).toBe(1) + await unsupported.locator('[data-open-path-more]').click() + await page.getByRole('menuitem', { name: 'Test Player (default)', exact: true }).waitFor() + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'applications.expected.md'), await page.getByRole('menu').ariaSnapshot(), MODE) + await page.getByRole('menuitem', { name: 'Other Player', exact: true }).click() + await expect.poll(async () => (await opened()).length).toBe(3) + expect((await opened())[2]).toEqual({ path: clip, action: 'application' }) + await headerOpen.click() + await expect.poll(async () => (await opened()).length).toBe(4) + expect((await opened())[3]).toEqual({ path: clip, action: 'open' }) + } // Gesture facts stay out of the golden: the stub does not run on Windows. expect(await page.getByRole('alert').count()).toBe(0) } expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE) - await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'paging.patch.yml']) + await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'applications.expected.md', 'paging.patch.yml']) }) }) diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index f72a9d9780..45dfe9272b 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -182,7 +182,7 @@ it('keeps default opening available when application discovery fails', async () const b = bench() render( null} />) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) - expect(screen.getByRole('menuitem', { name: zh['path.appsError'] }).getAttribute('aria-disabled')).toBe('true') + expect(screen.getByRole('menuitem', { name: zh['path.appsError'] }).hasAttribute('disabled')).toBe(true) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.defaultApp'] })) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) }) diff --git a/packages/util/native-command/src/file-applications.ts b/packages/util/native-command/src/file-applications.ts index f865d93145..dd6d3d7cad 100644 --- a/packages/util/native-command/src/file-applications.ts +++ b/packages/util/native-command/src/file-applications.ts @@ -50,7 +50,7 @@ export async function nativeFileApplications( const platform = internals.platform ?? process.platform if (platform !== 'darwin') return [] const run = internals.run ?? runNativeCommand - const { stdout } = await run('/usr/bin/osascript', ['-l', 'JavaScript', '-e', MAC_APPLICATIONS, path], signal) + const { stdout } = await run('osascript', ['-l', 'JavaScript', '-e', MAC_APPLICATIONS, path], signal) const value: unknown = JSON.parse(stdout) if (!Array.isArray(value)) throw new Error('Invalid native application list') const applications: NativeFileApplication[] = [] @@ -80,5 +80,5 @@ export async function openNativeFileApplication( const apps = await nativeFileApplications(path, signal, internals) if (!apps.some(app => app.id === application)) throw new Error('Application is not registered for this file') const run = internals.run ?? runNativeCommand - await run('/usr/bin/open', ['-a', application, path], signal) + await run('open', ['-a', application, path], signal) } diff --git a/packages/util/native-command/tests/file-applications.spec.ts b/packages/util/native-command/tests/file-applications.spec.ts index b6f2e3328c..fd87806cc6 100644 --- a/packages/util/native-command/tests/file-applications.spec.ts +++ b/packages/util/native-command/tests/file-applications.spec.ts @@ -10,7 +10,7 @@ describe('native file associations', () => { const run = vi.fn(async () => ({ stdout: JSON.stringify([application]), stderr: '' })) const path = '/tmp/中文 $(touch nope).mp3' await expect(nativeFileApplications(path, signal, { platform: 'darwin', run })).resolves.toEqual([application]) - expect(run).toHaveBeenCalledWith('/usr/bin/osascript', ['-l', 'JavaScript', '-e', expect.any(String), path], signal) + expect(run).toHaveBeenCalledWith('osascript', ['-l', 'JavaScript', '-e', expect.any(String), path], signal) }) it.each(['{}', '[null]', '[{"id":1}]', JSON.stringify([{ ...application, icon: 'javascript:alert(1)' }])])('rejects malformed native output %s', async (stdout) => { @@ -22,7 +22,7 @@ describe('native file associations', () => { it('launches only a currently registered application with argv', async () => { const run = vi.fn(async () => ({ stdout: JSON.stringify([application]), stderr: '' })) await openNativeFileApplication('/file.mp3', application.id, signal, { platform: 'darwin', run }) - expect(run).toHaveBeenLastCalledWith('/usr/bin/open', ['-a', application.id, '/file.mp3'], signal) + expect(run).toHaveBeenLastCalledWith('open', ['-a', application.id, '/file.mp3'], signal) run.mockClear() await expect(openNativeFileApplication('/file.mp3', '/arbitrary.app', signal, { platform: 'darwin', run })).rejects.toThrow('not registered') expect(run).toHaveBeenCalledOnce() diff --git a/snapshots/web/document-preview/applications.expected.md b/snapshots/web/document-preview/applications.expected.md new file mode 100644 index 0000000000..9a6667e3e4 --- /dev/null +++ b/snapshots/web/document-preview/applications.expected.md @@ -0,0 +1,5 @@ +- menu: + - menuitem "Open in default app" + - menuitem "Test Player (default)" + - menuitem "Other Player" + - menuitem "Show file location" From 5325babc7b5287dba51e0982f94bac05bd03fb9e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:43:06 +0800 Subject: [PATCH 095/284] test: cover default native command adapter selection --- .../native-command/tests/file-applications.spec.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/util/native-command/tests/file-applications.spec.ts b/packages/util/native-command/tests/file-applications.spec.ts index fd87806cc6..b8aeed5c26 100644 --- a/packages/util/native-command/tests/file-applications.spec.ts +++ b/packages/util/native-command/tests/file-applications.spec.ts @@ -1,5 +1,6 @@ /** File association results and explicit handler authorization at the native command adapter. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi, onTestFinished } from 'vitest' +import * as runner from '../src/runner.ts' import { nativeFileApplications, openNativeFileApplication } from '../src/file-applications.ts' const application = { id: '/Applications/Music.app', name: 'Music', default: true, icon: null } @@ -35,3 +36,12 @@ describe('native file associations', () => { expect(run).not.toHaveBeenCalled() }) }) + + +it('uses the production command adapter and current platform when no override is supplied', async () => { + const run = vi.spyOn(runner, 'runNativeCommand').mockResolvedValue({ stdout: JSON.stringify([application]), stderr: '' }) + onTestFinished(() => { run.mockRestore() }) + expect(await nativeFileApplications('/file.mp3', signal)).toEqual(process.platform === 'darwin' ? [application] : []) + await openNativeFileApplication('/file.mp3', application.id, signal, { platform: 'darwin' }) + expect(run).toHaveBeenLastCalledWith('open', ['-a', application.id, '/file.mp3'], signal) +}) From 0c3045d488e2623be21558a523669e96f7ad8e31 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:45:21 +0800 Subject: [PATCH 096/284] fix: validate native metadata and refresh generated source references --- apps/web/tests/document-preview.e2e.ts | 4 ++-- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 ++-- docs/event-producer-consumer.md | 10 +++++----- docs/event-producer-consumer.zh.md | 10 +++++----- docs/persistence-catalog.i18n.yaml | 4 ++-- docs/persistence-catalog.md | 8 ++++---- docs/persistence-catalog.zh.md | 8 ++++---- docs/persistence-schema.json | 6 +++--- packages/util/native-command/src/file-applications.ts | 4 +++- tsconfig.base.json | 2 +- 13 files changed, 35 insertions(+), 33 deletions(-) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 922ea15992..dd35398713 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -89,8 +89,8 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () let outsideRoot: string | undefined let nativeRoot: string | undefined let openLog = '' - const opened = async (): Promise> => - (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' }) + const opened = async (): Promise> => + (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' | 'application' }) beforeAll(async () => { outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-')) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 106e1545fb..d336b38454 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: dad6b57ae0712e04ad16729b4067c541f83c68d6 -config-catalog.zh.md: 4ba0378810f3db4797c0d41fa5230f7069438b3f +config-catalog.md: 24d48dbba4bee38feaaf2b4abd525871ee60de12 +config-catalog.zh.md: c1339795e97d2c2247b06c97d99058ee0e6f864e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dad6b57ae0..24d48dbba4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -213,7 +213,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:78`](../packages/api/session-controller/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4ba0378810..c1339795e9 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -215,7 +215,7 @@ export interface Config { } ``` -来源:[`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:78`](../packages/api/session-controller/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index f3dd91b507..f0e8b7e68c 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: c26ca2d18995eb4e883815fd4f22ec4773d10728 -event-producer-consumer.zh.md: 588ec67cd55f40b5544b81603d23e65f3ebe1119 +event-producer-consumer.md: e9d9c436a8f52fcdb9de26e5de9cc3f3b9c79dbd +event-producer-consumer.zh.md: 81e38e7171f3b6c44e97f47dc18b3a1976fbe7a2 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c26ca2d189..e9d9c436a8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 588ec67cd5..81e38e7171 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ae6c4b51eb..13aa1bdf41 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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 docs/persistence-catalog.md -persistence-catalog.md: 43b4f86125847625026bd5619216ccdc7be926c1 -persistence-catalog.zh.md: c655d97086fa60630aa5dae1bf3f93be595f179b +persistence-catalog.md: d8ec0b383514434995e8825e2f1331e4e22ecc8c +persistence-catalog.zh.md: 2ac58c08611a42bb149dd89fddb4e04595aca4f1 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 43b4f86125..d8ec0b3835 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -662,7 +662,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src 'model/selection': ModelSelection ``` -Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) +Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -4066,7 +4066,7 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message. SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -Sources: [`packages/api/session-controller/src/types.ts:86`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:87`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5304,7 +5304,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:383`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7195,7 +7195,7 @@ Sources: [`packages/core/tools/src/index.ts:34`](../packages/core/tools/src/inde SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c655d97086..2ac58c0861 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -664,7 +664,7 @@ export type SessionEvent = { 'model/selection': ModelSelection ``` -来源:[`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -4068,7 +4068,7 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91` SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -来源:[`packages/api/session-controller/src/types.ts:86`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:87`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5306,7 +5306,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:383`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7197,7 +7197,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 428d512264..c1fe9ea857 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -44144,7 +44144,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:380", + "packages/api/session-controller/src/types.ts:383", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -57727,7 +57727,7 @@ }, "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:385" + "packages/api/session-controller/src/types.ts:388" ] }, { @@ -86253,7 +86253,7 @@ "packages/api/session-controller/src/types.ts#ModelSelection" ], "sources": [ - "packages/api/session-controller/src/types.ts:86" + "packages/api/session-controller/src/types.ts:87" ] }, { diff --git a/packages/util/native-command/src/file-applications.ts b/packages/util/native-command/src/file-applications.ts index dd6d3d7cad..50e7c215ae 100644 --- a/packages/util/native-command/src/file-applications.ts +++ b/packages/util/native-command/src/file-applications.ts @@ -54,8 +54,10 @@ export async function nativeFileApplications( const value: unknown = JSON.parse(stdout) if (!Array.isArray(value)) throw new Error('Invalid native application list') const applications: NativeFileApplication[] = [] - for (const entry of value) { + const entries: readonly unknown[] = value + for (const entry of entries) { if (typeof entry !== 'object' || entry === null + || !('id' in entry) || !('name' in entry) || !('default' in entry) || !('icon' in entry) || typeof entry.id !== 'string' || entry.id.length === 0 || typeof entry.name !== 'string' || typeof entry.default !== 'boolean' || !(entry.icon === null || (typeof entry.icon === 'string' && /^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(entry.icon)))) { diff --git a/tsconfig.base.json b/tsconfig.base.json index 3f58c32145..0e6ff1c909 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -289,6 +289,7 @@ "@deepseek-ai/dsh-session-log-export/client": ["./packages/session-query/session-log-export/src/client"], "@deepseek-ai/dsh-compaction-image-offload/projection": ["./packages/compaction/compaction-image-offload/src/projection.ts"], "@deepseek-ai/dsh-session-format-catalog/message-projections": ["./packages/session/session-format-catalog/src/message-projections.ts"], + "@deepseek-ai/dsh-native-command/types": ["./packages/util/native-command/src/types.ts"], // BEGIN generated package aliases — pnpm run gen-tsconfig-paths "@deepseek-ai/dsh-acp": ["./packages/acp/acp/src"], "@deepseek-ai/dsh-acp-app": ["./packages/bundle/acp-app/src"], @@ -366,7 +367,6 @@ "@deepseek-ai/dsh-mcp-client": ["./packages/mcp/mcp-client/src"], "@deepseek-ai/dsh-mcp-resources": ["./packages/mcp/mcp-resources/src"], "@deepseek-ai/dsh-message-feedback": ["./packages/feedback/message-feedback/src"], - "@deepseek-ai/dsh-native-command/types": ["./packages/util/native-command/src/types.ts"], "@deepseek-ai/dsh-native-command": ["./packages/util/native-command/src"], "@deepseek-ai/dsh-office-to-pdf": ["./packages/document/office-to-pdf/src"], "@deepseek-ai/dsh-output-retention": ["./packages/util/output-retention/src"], From 79798d8c5a1934b278f93b5ef62dbf22bc4cf80a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 11:00:05 +0800 Subject: [PATCH 097/284] fix: satisfy package publication and file-action coverage gates --- .../session-open-workspace-path.host.spec.ts | 14 ++++++++++++++ .../ui-open-in-app/src/client/OpenPathAction.tsx | 2 +- .../tests/browser-plugin.client.spec.ts | 3 +++ .../tests/open-path-action.client.spec.tsx | 15 +++++++++++++-- .../ui-open-in-app/tests/open-path.client.spec.ts | 9 +++++++++ packages/util/native-command/package.json | 4 ++-- 6 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index 63df8886bd..aaa23c11a0 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -237,3 +237,17 @@ it('uses the same path verification for handler queries and explicit application expect(fileApplications).toHaveBeenCalledOnce() expect(openFileApplication).toHaveBeenCalledOnce() }) + + +it('avoids desktop queries when unavailable and rejects an empty query path', async () => { + const fileApplications = vi.fn(async () => []) + const unavailable = createSessionTestController(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: false, fileApplications, + }) + expect(await unavailable.workspacePathApplications({ path: '/file.mp3' }, new AbortController().signal)).toEqual([]) + const available = createSessionTestController(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, + }) + await expect(available.workspacePathApplications({ path: '' }, new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/bad-request' }) + expect(fileApplications).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx index af45eb7d1e..bc12a61103 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx @@ -125,7 +125,7 @@ export function OpenPathAction(props: OpenPathActionProps): ReactNode { ]} onSelect={(id) => { if (id.startsWith('app:')) run('open', id.slice(4)) - else if (id === 'open' || id === 'reveal') run(id) + else run(id === 'reveal' ? 'reveal' : 'open') }} anchor={(
      diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index 14dc706604..da5f8f1c9b 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -74,6 +74,9 @@ describe('open-in-app browser half', () => { expect(face.hooks.openInAppDesktop.getSnapshot()).toBeNull() await Promise.all([face.loadDesktop(), emptyFace.loadDesktop()]) expect(remote.session.canOpenWorkspacePath).toHaveBeenCalledOnce() + const signal = new AbortController().signal + await expect(face.applications('/w/clip.mp4', signal)).resolves.toEqual([]) + expect(remote.session.workspacePathApplications).toHaveBeenCalledWith({ path: '/w/clip.mp4' }, signal) expect(face.hooks.openInAppDesktop.getSnapshot()).toBe(true) expect(await face.openPath('/w/clip.mp4', 'reveal')).toBeNull() expect(remote.session.openWorkspacePath).toHaveBeenLastCalledWith({ path: '/w/clip.mp4', action: 'reveal' }) diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index 45dfe9272b..0b1c017fd5 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -5,10 +5,10 @@ * through their own toast. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { OpenPathAction, type OpenPathActionProps } from '../src/client/OpenPathAction.tsx' +import { OpenPathAction, usePathGesture, type OpenPathActionProps } from '../src/client/OpenPathAction.tsx' import { OpenPathEmptyAction, type OpenPathEmptyActionProps } from '../src/client/OpenPathEmptyAction.tsx' import type { OpenInAppPathAction, OpenInAppPathFailure } from '../src/client/open-path.ts' import { zh } from '../src/client/locales.ts' @@ -186,3 +186,14 @@ it('keeps default opening available when application discovery fails', async () await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.defaultApp'] })) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) }) + + +it('coalesces repeated gestures before the busy state renders', async () => { + const pending = Promise.withResolvers() + const b = bench({ openPath: vi.fn(() => pending.promise) }) + const { result } = renderHook(() => usePathGesture(b.props)) + act(() => { result.current.act('open'); result.current.act('open') }) + expect(b.openPath).toHaveBeenCalledOnce() + await act(async () => { pending.resolve(null) }) + expect(result.current.pending).toBe(false) +}) diff --git a/packages/client/ui-open-in-app/tests/open-path.client.spec.ts b/packages/client/ui-open-in-app/tests/open-path.client.spec.ts index 1b1702cc2f..2def94a830 100644 --- a/packages/client/ui-open-in-app/tests/open-path.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/open-path.client.spec.ts @@ -85,3 +85,12 @@ it('queries current handlers and carries an explicit application without changin await controller.openPath('/file.mp3', 'open') expect(open).toHaveBeenLastCalledWith({ path: '/file.mp3' }) }) + + +it('distinguishes failed association queries from an empty application list', async () => { + const remote = remoteOf({ workspacePathApplications: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'unavailable', {}) }) }) + const controller = new OpenInAppPathController(remote) + expect(await controller.applications('/file.mp3', new AbortController().signal)).toBeNull() + const rejecting = new OpenInAppPathController(remoteOf({ workspacePathApplications: async () => { throw new Error('disconnected') } })) + expect(await rejecting.applications('/file.mp3', new AbortController().signal)).toBeNull() +}) diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 391b6c023d..9fc16ba0a9 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -27,8 +27,8 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts", - "lib/types/types.js" + "lib/types/**/*.js", + "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { From dd39d56af557eeed2bac1164c9f24abc00a7d39d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 11:02:45 +0800 Subject: [PATCH 098/284] test: remove legacy unknown assertion from path-action fixture --- .../ui-open-in-app/tests/open-path-action.client.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index 0b1c017fd5..e6c7c90b64 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -43,7 +43,7 @@ function bench(over: { desktop?: boolean | null; openPath?: Bench['openPath'] } applications: vi.fn(async () => []), openPath, t, - } as unknown as Bench['props'] + } as Bench['props'] return { props, desktop, loadDesktop, openPath } } From c6c9da3eba0fda0245ce2fa2f23a0dfbcf8051cf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 11:03:54 +0800 Subject: [PATCH 099/284] chore: retire the removed path-action unknown-cast baseline --- scripts/no-unknown-casts.baseline.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index f5ecfea159..6f389f9866 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -632,9 +632,6 @@ "d6ca469f487b05b19564776f9a8f8288b1f0d9fb5b7fb6681e68f063fb021f38": 1, "fdc0ec545dc17a50bdc1c64102915706cfbfdbb65a2165ae4691f29d577063cd": 1 }, - "packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx": { - "362d9206df82a6d4563c2e5c19457342207b02b246c91656dec01a4b2033fd8e": 1 - }, "packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts": { "dc36dd8f678695e3b84ce0573968470941c5b6fc5efe529cba17acb1f2799d1c": 1 }, From e969aaff58d9ffc5fb37354e615b1aee22c2be5a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 11:46:50 +0800 Subject: [PATCH 100/284] refactor: share compact opening controls for files and directories --- apps/web/tests/document-preview.e2e.ts | 7 +- .../src/client/OpenInAppAction.module.css | 64 ------ .../src/client/OpenInAppAction.tsx | 172 +++------------- .../src/client/OpenPathAction.tsx | 156 ++++----------- .../src/client/OpenPathEmptyAction.module.css | 26 --- .../src/client/OpenPathEmptyAction.tsx | 31 +-- ...module.css => OpenTargetButton.module.css} | 45 ++++- .../src/client/OpenTargetButton.tsx | 143 ++++++++++++++ .../ui-open-in-app/src/client/locales.ts | 4 +- .../tests/open-in-app-action.client.spec.tsx | 187 +++++------------- .../tests/open-path-action.client.spec.tsx | 51 +++-- scripts/no-unknown-casts.baseline.json | 4 - .../document-preview/applications.expected.md | 1 - .../web/document-preview/document.expected.md | 4 +- 14 files changed, 328 insertions(+), 567 deletions(-) delete mode 100644 packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css delete mode 100644 packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css rename packages/client/ui-open-in-app/src/client/{OpenPathAction.module.css => OpenTargetButton.module.css} (50%) create mode 100644 packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index dd35398713..171115a18b 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -748,7 +748,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '## Unviewable binary', '', '- State: unsupported', `- Line: ${unsupportedLine.trim()}`, - `- Header control: ${await headerOpen.innerText()}`, + `- Header control has no text: ${String(await headerOpen.innerText() === '')}`, `- Empty-state control: ${await emptyOpen.innerText()}`, ].join('\n')) if (STUB_OPENER) { @@ -757,10 +757,11 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + await emptyOpen.click() await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(1) await unsupported.locator('[data-open-path-more]').click() - await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click() + await page.getByRole('menuitem', { name: /^Show file location/ }).click() await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(2) const gestures = await opened() - expect(gestures[0]).toEqual({ path: clip, action: 'open' }) + expect(gestures[0]?.action).toBe(process.platform === 'darwin' ? 'open' : 'reveal') + expect([clip, cwd]).toContain(gestures[0]?.path) expect(gestures[1]?.action).toBe('reveal') expect([clip, cwd]).toContain(gestures[1]?.path) if (process.platform === 'darwin') { diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css b/packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css deleted file mode 100644 index f5186cc9a6..0000000000 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css +++ /dev/null @@ -1,64 +0,0 @@ -/* Compact Session-header split button beside the round icon buttons. */ - -.split { - display: inline-flex; - align-items: stretch; - box-sizing: border-box; - height: 24px; - border: 0.5px solid var(--dsw-alias-border-l4); - border-radius: 9px; - overflow: hidden; - font-family: var(--dsw-font-family); -} - -.main, -.chevron { - display: inline-flex; - align-items: center; - gap: 3px; - border: 0; - background: none; - color: var(--dsw-alias-label-primary); - font-size: 11px; - font-weight: 400; - line-height: 16px; - cursor: pointer; - white-space: nowrap; -} - -.main { - padding: 3px 5px; -} - -.main:hover:not(:disabled), -.main:focus-visible, -.chevron:hover, -.chevron:focus-visible { - background: var(--dsw-alias-interactive-bg-hover); -} - -.main:disabled { - color: var(--dsw-alias-label-dimmed); - cursor: wait; -} - -.main[data-state='error'] { - color: var(--dsw-alias-state-error-primary); - box-shadow: inset 0 0 0 1px var(--dsw-alias-state-error-primary); -} - -.chevron { - padding: 3px 4px 3px 3px; - border-left: 0.5px solid var(--dsw-alias-border-l4); - color: var(--dsw-alias-label-secondary); -} - -.icon { - flex: none; -} - -img.icon { - display: block; - object-fit: contain; - user-select: none; -} diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx index 78f547a802..a58ed63933 100644 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx @@ -1,10 +1,8 @@ -import { useEffect, useRef, useState } from 'react' -import { IconChevronDownOutlineRegular, Menu, Tooltip, type MenuItem } from '@deepseek-ai/dsh-client-ui-primitives' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { NS, type OpenInAppKey } from './locales.ts' -import css from './OpenInAppAction.module.css' +import { OpenTargetButton } from './OpenTargetButton.tsx' /** Browser operations and state injected into the Session Header contribution. */ export interface OpenInAppActionInjected { @@ -65,162 +63,36 @@ const APP_LABEL_KEY: Record = { konsole: 'app.konsole', } -/** App ids whose icon image already failed this page; a 404 icon is fetched once, not per menu open. */ -const failedIcons = new Set() - /** - * One application's real bundle icon (host-served PNG) with an inline generic - * app-square fallback while the host has none. - * @param props - catalog id, host icon URL, and rendered size. - * @returns the icon image or its fallback glyph. - */ -function AppIcon({ id, url, size }: { id: string; url: string; size: number }): React.JSX.Element { - const [failed, setFailed] = useState(failedIcons.has(id)) - if (failed) { - return ( - - - - ) - } - return ( - { - failedIcons.add(id) - setFailed(true) - }} - /> - ) -} - -/** - * Quick launches settle well under this delay, so their busy dress never - * paints — the visible dim-and-wait treatment is reserved for launches that - * are actually taking a while, instead of flashing on every click. - */ -const BUSY_DRESS_DELAY_MS = 250 - -/** - * Session-header split button: the main button opens the session's workspace - * directory in the remembered application, the chevron opens the menu of - * every application the host probed as installed. It renders nothing until - * the host reported at least one nameable application and the session has a - * known workspace directory, so a host without the capability never grows - * the control. - * @param props - session runtime, injected controller face, and localized copy. - * @returns the split button and its menu, or null when there is nothing to offer. + * Adapt the installed directory catalog to the shared opening control. + * @param props - workspace state, installed catalog, and launch operations. + * @returns the shared control, or null without an eligible application and directory. */ export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element | null { const { sessionId, useSessions, useOpenInAppApps, useOpenInAppChoice, t } = props const cwd = useSessions(state => state.byId[sessionId]?.cwd) const available = useOpenInAppApps(apps => apps) const choice = useOpenInAppChoice(id => id) - const [open, setOpen] = useState(false) - const [phase, setPhase] = useState<'idle' | 'busy' | 'error'>('idle') - const inFlight = useRef(false) - const busyTimer = useRef | undefined>(undefined) - const errorTimer = useRef | undefined>(undefined) - - useEffect(() => () => { - clearTimeout(busyTimer.current) - clearTimeout(errorTimer.current) - }, []) - - const apps = (available ?? []) - .map(id => ({ id, labelKey: APP_LABEL_KEY[id] })) - .filter((entry): entry is { id: string; labelKey: OpenInAppKey } => entry.labelKey !== undefined) - const currentEntry = apps.find(entry => entry.id === choice) ?? apps[0] - if (currentEntry === undefined || cwd === undefined || cwd === '') return null - - const current = currentEntry.id - const currentLabel = t(currentEntry.labelKey) - const title = phase === 'error' ? t('open.error') : t('open.title', { app: currentLabel }) - - const launch = (appId: string): void => { - if (inFlight.current) return - inFlight.current = true - // A pending error decay must not flip the button back to idle mid-launch. - clearTimeout(errorTimer.current) - clearTimeout(busyTimer.current) - busyTimer.current = setTimeout(() => { setPhase('busy') }, BUSY_DRESS_DELAY_MS) - props.launch(appId, cwd).then(() => { - inFlight.current = false - clearTimeout(busyTimer.current) - setPhase('idle') - }, () => { - inFlight.current = false - clearTimeout(busyTimer.current) - setPhase('error') - clearTimeout(errorTimer.current) - errorTimer.current = setTimeout(() => { setPhase('idle') }, 2_000) - }) - } - - const items: MenuItem[] = apps.map(entry => ({ - id: entry.id, - label: t(entry.labelKey), - icon: , - })) - + const apps = (available ?? []).flatMap((id) => { + const key = APP_LABEL_KEY[id] + return key === undefined ? [] : [{ id, name: t(key), icon: props.iconUrl(id) }] + }) + const preferred = apps.find(app => app.id === choice) ?? apps[0] + if (preferred === undefined || cwd === undefined || cwd === '') return null return ( - { setOpen(false) }} - items={items} - selectedId={current} - onSelect={(id) => { - setOpen(false) - // A pick while a launch is in flight is ignored whole: persisting the - // choice without launching would leave the button naming an app the - // gesture never opened. - if (inFlight.current) return - props.choose(id) - launch(id) + { + const id = operation.kind === 'application' ? operation.id : preferred.id + try { + await props.launch(id, cwd) + } catch (_error) { + // Native launch failure is announced by the shared control. + return 'openError' + } + if (operation.kind === 'application') props.choose(id) + return null }} - anchor={( -
      - - - - -
      - )} /> ) } diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx index bc12a61103..317f392740 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx @@ -1,21 +1,13 @@ -/** - * Document-header split button: the main button opens the previewed file in - * its default application, the chevron's menu adds the file-manager reveal. - * Both path controls share the gesture hook declared here. - */ -import { useEffect, useRef, useState } from 'react' +/** File association adapter for the shared file/directory opening control. */ +import { useEffect, useState } from 'react' import type { ReactNode } from 'react' -import { - IconChevronDownOutlineRegular, IconFolderOpenOutlineRegular, IconRightUpOutlineRegular, Menu, Tooltip, -} from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client' import type { OpenInAppPathAction, OpenInAppPathFailure } from './open-path.ts' -import { useOpenFailureToast } from './open-failure-toast.tsx' +import { OpenTargetButton } from './OpenTargetButton.tsx' import type { NS } from './locales.ts' -import css from './OpenPathAction.module.css' /** Desktop availability and the gesture carrier injected into both path controls. */ export interface OpenPathInjected { @@ -33,130 +25,52 @@ export type OpenPathActionProps = & PropsLocale & InjectFace -/** What one path control needs from its props: the file, the injected face, and copy. */ -type PathGestureProps = Pick & { - t: TranslateNS +/** File inputs shared by the document header and its unpreviewable state. */ +type FileOpenTargetProps = Pick & { + empty?: boolean } /** - * Desktop availability, this control's own pending state, and a gesture - * runner announcing failures through this control's toast. Only the control - * that ran the gesture disables while it settles; failure never persists on - * the control. - * @param props - the file's Host path, the injected face, and copy. - * @returns availability, pending, the toast to render, and the runner. + * Resolve file associations and adapt operations without embedding platform behavior in the control. + * @param props - verified file path, desktop query, native operations, and display variant. + * @returns the shared opening control, or null without a desktop. */ -export function usePathGesture({ absolutePath, useOpenInAppDesktop, loadDesktop, openPath, t }: PathGestureProps): { - available: boolean - pending: boolean - toast: ReactNode - act: (action: OpenInAppPathAction, application?: string) => void -} { - const desktop = useOpenInAppDesktop(value => value) - const [pending, setPending] = useState(false) - const inFlight = useRef(false) - const { toast, show } = useOpenFailureToast() - useEffect(() => { - if (desktop === null) void loadDesktop() - }, [desktop, loadDesktop]) - return { - available: desktop === true, - pending, - toast, - act: (action, application) => { - if (inFlight.current) return - inFlight.current = true - setPending(true) - void openPath(absolutePath, action, application).then((failure) => { - if (failure !== null) show(t(`path.${failure}`)) - }).finally(() => { inFlight.current = false; setPending(false) }) - }, - } -} - -/** - * Render the split button, or nothing until the Host reports a desktop. - * @param props - the previewed file, the injected face, and copy. - * @returns the split button with its menu and toast, or null. - */ -export function OpenPathAction(props: OpenPathActionProps): ReactNode { - const { t } = props - const [menuOpen, setMenuOpen] = useState(false) - const { available, pending, toast, act } = usePathGesture(props) +export function FileOpenTarget(props: FileOpenTargetProps): ReactNode { + const desktop = props.useOpenInAppDesktop(value => value) + const [revision, setRevision] = useState(0) const [association, setAssociation] = useState<{ path: string apps: readonly SessionWorkspacePathApplication[] | null } | null>(null) useEffect(() => { - if (!available) return + if (desktop === null) void props.loadDesktop() + }, [desktop, props.loadDesktop]) + useEffect(() => { + if (desktop !== true) return const controller = new AbortController() void props.applications(props.absolutePath, controller.signal).then((apps) => { if (!controller.signal.aborted) setAssociation({ path: props.absolutePath, apps }) }) return () => { controller.abort() } - }, [available, props.absolutePath, props.applications, menuOpen]) + }, [desktop, props.absolutePath, props.applications, revision]) + if (desktop !== true) return null const apps = association?.path === props.absolutePath ? association.apps : null - const preferred = apps?.find(app => app.default) - if (!available) return null - const run = (action: OpenInAppPathAction, application?: string): void => { - setMenuOpen(false) - act(action, application) - } return ( - <> - { setMenuOpen(false) }} - items={[ - { id: 'open', icon: , label: t('path.defaultApp') }, - ...(apps ?? []).map(app => ({ - id: `app:${app.id}`, - icon: app.icon === null ? : , - label: app.default ? t('path.appDefault', { app: app.name }) : app.name, - })), - ...(association?.path === props.absolutePath && apps === null - ? [{ id: 'unavailable', label: t('path.appsError'), disabled: true }] : []), - { id: 'reveal', icon: , label: t('path.reveal') }, - ]} - onSelect={(id) => { - if (id.startsWith('app:')) run('open', id.slice(4)) - else run(id === 'reveal' ? 'reveal' : 'open') - }} - anchor={( -
      - - - - -
      - )} - /> - {toast} - + app.default)?.id} + failed={association?.path === props.absolutePath && apps === null} empty={props.empty} t={props.t} + refresh={() => { setRevision(value => value + 1) }} + execute={operation => props.openPath(props.absolutePath, operation.kind === 'reveal' ? 'reveal' : 'open', + operation.kind === 'application' ? operation.id : undefined)} + /> ) } + +/** + * Render the file adapter in the document header. + * @param props - document owner inputs and injected opening capabilities. + * @returns the shared split button. + */ +export function OpenPathAction(props: OpenPathActionProps): ReactNode { + return +} diff --git a/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css b/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css deleted file mode 100644 index cf9036e0d6..0000000000 --- a/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css +++ /dev/null @@ -1,26 +0,0 @@ -/* The empty state's capsule, sized like the preview's own Retry - (ui-sidebar-documentpreview TextPreview `.retry`) so the two recourses read - as one family. */ -.open { - display: inline-flex; - flex: none; - gap: 6px; - align-items: center; - height: 32px; - padding: 0 12px 0 14px; - color: var(--dsw-alias-label-primary); - font-size: var(--dsh-content-font-size-secondary, 13px); - font-family: inherit; - background: transparent; - border: 0.5px solid var(--dsw-alias-border-l2); - border-radius: 16px; - cursor: pointer; -} - -.open:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -.open:disabled { - cursor: default; -} diff --git a/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx b/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx index bf86006ed5..8294a7ab19 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx @@ -1,11 +1,9 @@ -/** Empty-state contribution: open a file the document preview cannot render in its default application. */ +/** File opening action in an unpreviewable document's empty state. */ import type { ReactNode } from 'react' -import { IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client' import type { NS } from './locales.ts' -import { usePathGesture, type OpenPathInjected } from './OpenPathAction.tsx' -import css from './OpenPathEmptyAction.module.css' +import { FileOpenTarget, type OpenPathInjected } from './OpenPathAction.tsx' /** Full props of the unpreviewable empty-state contribution. */ export type OpenPathEmptyActionProps = @@ -14,27 +12,10 @@ export type OpenPathEmptyActionProps = & InjectFace /** - * Render the default-application open button, or nothing until the Host reports a desktop. - * @param props - the unpreviewable file, the injected face, and copy. - * @returns the open button and its toast, or null. + * Render the same file default action without a dropdown. + * @param props - unpreviewable file and injected opening capabilities. + * @returns the shared file opening action. */ export function OpenPathEmptyAction(props: OpenPathEmptyActionProps): ReactNode { - const { t } = props - const { available, pending, toast, act } = usePathGesture(props) - if (!available) return null - return ( - <> - - {toast} - - ) + return } diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.module.css b/packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css similarity index 50% rename from packages/client/ui-open-in-app/src/client/OpenPathAction.module.css rename to packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css index 2c770905cf..66a890bfa2 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.module.css +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css @@ -1,4 +1,4 @@ -/* Sized to sit in the document header's 28px control row beside its round +/* Sized to sit in the document header's 24px control row beside its round icon buttons: a hairline pill with a 12px label and a narrow chevron half. */ .menuAnchor { @@ -11,10 +11,10 @@ display: inline-flex; align-items: stretch; box-sizing: border-box; - height: 28px; + height: 24px; overflow: hidden; - border: 0.5px solid var(--dsw-alias-border-l3); - border-radius: 14px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 9px; font-family: var(--dsw-font-family); } @@ -26,7 +26,7 @@ border: 0; background: none; color: var(--dsw-alias-label-primary); - font-size: 12px; + font-size: 11px; line-height: 16px; white-space: nowrap; cursor: pointer; @@ -34,12 +34,12 @@ .main { gap: 4px; - padding: 5px 8px 5px 10px; + padding: 3px 5px; } .chevron { - padding: 5px 6px 5px 4px; - border-left: 0.5px solid var(--dsw-alias-border-l3); + padding: 3px 4px 3px 3px; + border-left: 0.5px solid var(--dsw-alias-border-l4); color: var(--dsw-alias-label-secondary); } @@ -57,7 +57,32 @@ } .appIcon { - width: 16px; - height: 16px; object-fit: contain; } + +/* The empty state's capsule, sized like the preview's own Retry + (ui-sidebar-documentpreview TextPreview `.retry`) so the two recourses read + as one family. */ +.empty { + display: inline-flex; + flex: none; + gap: 6px; + align-items: center; + height: 32px; + padding: 0 12px 0 14px; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size-secondary, 13px); + font-family: inherit; + background: transparent; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 16px; + cursor: pointer; +} + +.empty:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.empty:disabled { + cursor: default; +} diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx new file mode 100644 index 0000000000..570e8f4914 --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -0,0 +1,143 @@ +/** Shared file and directory opener: one default action, application menu, and per-gesture feedback. */ +import { useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { + IconChevronDownOutlineRegular, IconFolderOpenOutlineRegular, IconRightUpOutlineRegular, Menu, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { OpenInAppPathFailure } from './open-path.ts' +import { useOpenFailureToast } from './open-failure-toast.tsx' +import type { NS } from './locales.ts' +import css from './OpenTargetButton.module.css' + +/** Application metadata supplied by either the directory catalog or a file association query. */ +export interface OpenTargetApplication { + readonly id: string + readonly name: string + readonly icon: string | null +} + +/** Opening intent; default selection remains owned by the target's adapter. */ +export type OpenTargetOperation = { readonly kind: 'default' } | { readonly kind: 'application'; readonly id: string } | { readonly kind: 'reveal' } + +/** Inputs shared by both target adapters and the file empty-state action. */ +export interface OpenTargetButtonProps { + readonly kind: 'file' | 'directory' + readonly applications: readonly OpenTargetApplication[] + readonly defaultId: string | undefined + readonly failed: boolean + readonly empty?: boolean + readonly t: TranslateNS + readonly execute: (operation: OpenTargetOperation) => Promise + readonly refresh?: () => void +} + +/** + * Serialize gestures and announce their failures through the initiating control's toast. + * @param execute - target adapter that returns the failure to announce, or null. + * @param t - localized control copy. + * @returns the pending state, feedback, and guarded action callback. + */ +export function useOpenTargetGesture(execute: OpenTargetButtonProps['execute'], t: TranslateNS): { + pending: boolean + toast: ReactNode + act: (operation: OpenTargetOperation) => void +} { + const [pending, setPending] = useState(false) + const inFlight = useRef(false) + const { toast, show } = useOpenFailureToast() + return { + pending, + toast, + act: (operation) => { + if (inFlight.current) return + inFlight.current = true + setPending(true) + void execute(operation).then((failure) => { + if (failure !== null) show(t(`path.${failure}`)) + }).finally(() => { inFlight.current = false; setPending(false) }) + }, + } +} + +/** One application image with a per-image fallback, shared by main and menu buttons. */ +function ApplicationIcon({ source, size = 14 }: { source: string | null; size?: number }): ReactNode { + const [failed, setFailed] = useState(false) + return source === null || failed + ? + : { setFailed(true) }} /> +} + +/** + * Render identical split buttons for files and directories. File reveal always stays last and becomes the default when no app is selected. + * @param props - target applications, default selection, and operations. + * @returns the control and its transient failure feedback. + */ +export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { + const { applications, defaultId, kind, t } = props + const [menuOpen, setMenuOpen] = useState(false) + const { pending, toast, act } = useOpenTargetGesture(props.execute, t) + const preferred = applications.find(app => app.id === defaultId) + const revealDefault = kind === 'file' && preferred === undefined + const primaryLabel = revealDefault ? t('path.reveal') : t('open.title', { app: preferred?.name ?? '' }) + const run = (operation: OpenTargetOperation): void => { setMenuOpen(false); act(operation) } + const primary = (): void => { run({ kind: revealDefault ? 'reveal' : 'default' }) } + const icon = revealDefault + ? + : + if (props.empty === true) return ( + <> + + {toast} + + ) + return ( + <> + { setMenuOpen(false) }} + items={[ + ...applications.map(app => ({ + id: `app:${app.id}`, + icon: , + label: app.id === defaultId ? t('path.appDefault', { app: app.name }) : app.name, + })), + ...(props.failed ? [{ id: 'unavailable', label: t('path.appsError'), disabled: true }] : []), + ...(kind === 'file' ? [{ + id: 'reveal', icon: , + label: revealDefault ? t('path.appDefault', { app: t('path.reveal') }) : t('path.reveal'), + }] : []), + ]} + onSelect={(id) => { run(id === 'reveal' ? { kind: 'reveal' } : { kind: 'application', id: id.slice(4) }) }} + anchor={( +
      + + + + +
      + )} + /> + {toast} + + ) +} diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts index bb198d640c..77d24914f3 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -39,7 +39,7 @@ const PRODUCT_NAMES = { /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'open.title': '在 {app} 中打开工作目录', + 'open.title': '在 {app} 中打开', 'open.tooltip': '在本地打开', 'open.error': '打开失败', 'menu.toggle': '选择打开方式', @@ -63,7 +63,7 @@ export const zh = { /** English dictionary, key-identical to the Chinese source of truth. */ export const en: Record = { - 'open.title': 'Open workspace in {app}', + 'open.title': 'Open in {app}', 'open.tooltip': 'Open locally', 'open.error': 'Failed to open', 'menu.toggle': 'Choose an app to open in', diff --git a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx index 62f1ba2295..0116963d6e 100644 --- a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor, act } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, act } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' @@ -37,7 +37,7 @@ function bench(over: { projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined, - } as unknown as SessionListState + } as SessionListState const apps = createSnapshotStore(over.apps ?? null) const choice = createSnapshotStore(over.choice ?? '') const launch = vi.fn(over.launch ?? (async () => {})) @@ -57,7 +57,7 @@ function bench(over: { choose, iconUrl: (appId: string) => `open-in-app/icon/${appId}`, t, - } as unknown as OpenInAppActionProps + } as OpenInAppActionProps return { props, launch, choose } } @@ -88,156 +88,57 @@ describe('OpenInAppAction visibility', () => { }) describe('OpenInAppAction launching', () => { - it('launches without painting the busy dress when the launch settles quickly', async () => { - let resolve: () => void = () => {} - const b = bench({ - apps: ['finder'], - cwd: '/w/dir', - launch: () => new Promise((r) => { resolve = r }), - }) + it('disables both buttons and ignores duplicate gestures until the launch settles', async () => { + const pending = Promise.withResolvers() + const b = bench({ apps: ['finder', 'cursor'], cwd: '/w/dir', launch: () => pending.promise }) + const view = render() + const main = screen.getByRole('button', { name: t('open.title', { app: zh['app.finder'] }) }) + fireEvent.click(main) + expect(main).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: zh['path.more'] })).toHaveProperty('disabled', true) + fireEvent.click(main) + expect(b.launch).toHaveBeenCalledOnce() + expect(view.container.querySelector('[data-open-target]')?.getAttribute('data-state')).toBe('busy') + await act(async () => { pending.resolve(undefined) }) + expect(main).toHaveProperty('disabled', false) + }) + + it('announces launch failure without changing the remembered application', async () => { + const b = bench({ apps: ['finder', 'cursor'], cwd: '/w', launch: async () => { throw new Error('gone') } }) render() - const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) }) - fireEvent.click(main) - expect(b.launch).toHaveBeenCalledWith('finder', '/w/dir') - // No flash: the button keeps its idle dress while the launch is fast. - expect((main as HTMLButtonElement).disabled).toBe(false) - expect(main.getAttribute('data-state')).toBe('idle') - // A second click while in flight is ignored rather than double-launching. - fireEvent.click(main) - expect(b.launch).toHaveBeenCalledTimes(1) - - resolve() - await waitFor(() => { - fireEvent.click(main) - expect(b.launch).toHaveBeenCalledTimes(2) - }) + fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Cursor' })) }) + expect(screen.getByRole('alert').textContent).toContain(zh['path.openError']) + expect(b.choose).not.toHaveBeenCalled() }) - it('dresses a slow launch as busy, then shows the error state on failure', async () => { - vi.useFakeTimers() - let reject: (error: Error) => void = () => {} - const b = bench({ - apps: ['finder'], - cwd: '/w/dir', - launch: () => new Promise((_, r) => { reject = r }), - }) - render() - const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) }) - fireEvent.click(main) - - // The busy dress appears only after the launch has taken a while. - act(() => { vi.advanceTimersByTime(300) }) - expect((main as HTMLButtonElement).disabled).toBe(true) - expect(main.getAttribute('data-state')).toBe('busy') - - act(() => { reject(new Error('launch failed')) }) - await act(async () => { await vi.runOnlyPendingTimersAsync() }) - expect(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })).toBeDefined() - }) - - it('shows the error state and decays back to idle after a fast failure', async () => { - const b = bench({ - apps: ['finder'], - cwd: '/w/dir', - launch: () => Promise.reject(new Error('launch failed')), - }) - render() - const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) }) - fireEvent.click(main) - await waitFor(() => { - expect(screen.getByRole('button', { name: zh['open.error'] })).toBeDefined() - }) - // The error state decays back to idle. - await waitFor(() => { - expect(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })).toBeDefined() - }, { timeout: 4_000 }) - }) - - it('shows the product tooltip on hover instead of a native title', async () => { - render() - const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) }) - expect(main.getAttribute('title')).toBeNull() - fireEvent.mouseEnter(main) - expect(await screen.findByText(zh['open.tooltip'])).toBeDefined() - fireEvent.mouseLeave(main) - await waitFor(() => { - expect(screen.queryByText(zh['open.tooltip'])).toBeNull() - }) - }) - - it('opens the menu from the chevron, launches and persists a picked app', async () => { + it('lists only applications, marks the remembered default, and remembers a successful choice', async () => { const b = bench({ apps: ['finder', 'cursor', 'terminal'], cwd: '/w/dir' }) render() - fireEvent.click(screen.getByRole('button', { name: zh['menu.toggle'] })) - const cursorItem = await screen.findByText('Cursor') - fireEvent.click(cursorItem) - expect(b.choose).toHaveBeenCalledWith('cursor') + fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['访达(默认)', 'Cursor', '终端']) + expect(screen.queryByText(zh['path.reveal'])).toBeNull() + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Cursor' })) }) expect(b.launch).toHaveBeenCalledWith('cursor', '/w/dir') + expect(b.choose).toHaveBeenCalledWith('cursor') }) - it('ignores a menu pick while a launch is in flight', async () => { - let resolve: () => void = () => {} - const b = bench({ - apps: ['finder', 'cursor'], - cwd: '/w/dir', - launch: () => new Promise((r) => { resolve = r }), - }) - render() - fireEvent.click(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })) - expect(b.launch).toHaveBeenCalledTimes(1) - fireEvent.click(screen.getByRole('button', { name: zh['menu.toggle'] })) - fireEvent.click(await screen.findByText('Cursor')) - // Mid-flight the pick is ignored whole: no persisted choice, no launch. - expect(b.choose).not.toHaveBeenCalled() - expect(b.launch).toHaveBeenCalledTimes(1) - resolve() - await act(async () => {}) - }) - - it('clears a pending error decay when a retry starts', async () => { - vi.useFakeTimers() - const outcomes: Array<() => Promise> = [ - () => Promise.reject(new Error('launch failed')), - // The retry stays in flight past the original decay deadline. - () => new Promise(() => {}), - ] - const b = bench({ - apps: ['finder'], - cwd: '/w/dir', - launch: () => (outcomes.shift() ?? (() => Promise.resolve()))(), - }) - render() - fireEvent.click(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })) - await act(async () => {}) - fireEvent.click(screen.getByRole('button', { name: zh['open.error'] })) - // Past the first failure's 2s decay: the stale timer must not flip the - // in-flight retry's busy dress back to a clickable idle button. - act(() => { vi.advanceTimersByTime(2_500) }) - const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) }) - expect(main.getAttribute('data-state')).toBe('busy') - expect((main as HTMLButtonElement).disabled).toBe(true) - }) - - it('closes an open menu on Escape without launching', async () => { - const b = bench({ apps: ['finder', 'terminal'], cwd: '/w/dir' }) - render() - fireEvent.click(screen.getByRole('button', { name: zh['menu.toggle'] })) - await screen.findByText(zh['app.terminal']) - fireEvent.keyDown(document, { key: 'Escape' }) - await waitFor(() => { - expect(screen.queryByText(zh['app.terminal'])).toBeNull() - }) + it('closes on Escape without launching and falls back after an icon fails', async () => { + const b = bench({ apps: ['finder', 'terminal'], cwd: '/w' }) + const view = render() + fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() expect(b.launch).not.toHaveBeenCalled() + const image = view.container.querySelector('img')! + fireEvent.error(image) + expect(view.container.querySelector('img')).toBeNull() + expect(view.container.querySelector('svg')).not.toBeNull() }) - it('falls back to the generic icon after a failed image load', async () => { - const b = bench({ apps: ['terminal'], cwd: '/w/dir' }) - const { container } = render() - const img = container.querySelector('img') - if (img !== null) fireEvent.error(img) - await waitFor(() => { - expect(container.querySelector('img')).toBeNull() - expect(container.querySelector('svg path[fill-opacity="0.05"]')).not.toBeNull() - }) + it('uses the shared product tooltip', async () => { + render() + fireEvent.mouseEnter(screen.getByRole('button', { name: t('open.title', { app: zh['app.finder'] }) })) + expect(await screen.findByText(zh['open.tooltip'])).toBeTruthy() }) }) diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index e6c7c90b64..961b066a3a 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -8,9 +8,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { OpenPathAction, usePathGesture, type OpenPathActionProps } from '../src/client/OpenPathAction.tsx' +import { OpenPathAction, type OpenPathActionProps } from '../src/client/OpenPathAction.tsx' import { OpenPathEmptyAction, type OpenPathEmptyActionProps } from '../src/client/OpenPathEmptyAction.tsx' import type { OpenInAppPathAction, OpenInAppPathFailure } from '../src/client/open-path.ts' +import { useOpenTargetGesture } from '../src/client/OpenTargetButton.tsx' import { zh } from '../src/client/locales.ts' afterEach(() => { @@ -40,7 +41,7 @@ function bench(over: { desktop?: boolean | null; openPath?: Bench['openPath'] } absolutePath: ABSOLUTE_PATH, useOpenInAppDesktop: useSelector(desktop), loadDesktop, - applications: vi.fn(async () => []), + applications: vi.fn(async () => [{ id: '/Music.app', name: 'Music', default: true, icon: null }]), openPath, t, } as Bench['props'] @@ -64,7 +65,7 @@ describe('OpenPathAction gestures', () => { it('opens the file in its default application from the main button and reveals it from the menu', async () => { const b = bench() render() - const main = screen.getByRole('button', { name: zh['path.open'] }) + const main = await screen.findByRole('button', { name: t('open.title', { app: 'Music' }) }) await act(async () => { fireEvent.click(main) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) @@ -76,6 +77,7 @@ describe('OpenPathAction gestures', () => { it('offers the default application from the menu too, and Escape closes the menu without a gesture', async () => { const b = bench() render() + await act(async () => {}) const more = screen.getByRole('button', { name: zh['path.more'] }) fireEvent.click(more) expect(more.getAttribute('aria-expanded')).toBe('true') @@ -83,15 +85,15 @@ describe('OpenPathAction gestures', () => { expect(screen.queryByRole('menu')).toBeNull() expect(b.openPath).not.toHaveBeenCalled() fireEvent.click(more) - await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.defaultApp'] })) }) - expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Music(默认)' })) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', '/Music.app') }) it('disables both halves while its own gesture settles and leaves no failure on the control', async () => { const settled = Promise.withResolvers() const b = bench({ openPath: vi.fn(() => settled.promise) }) const { container } = render() - const main = screen.getByRole('button', { name: zh['path.open'] }) + const main = await screen.findByRole('button', { name: t('open.title', { app: 'Music' }) }) fireEvent.click(main) expect(main).toHaveProperty('disabled', true) expect(screen.getByRole('button', { name: zh['path.more'] })).toHaveProperty('disabled', true) @@ -112,7 +114,8 @@ describe('OpenPathEmptyAction', () => { cleanup() const b = bench() render() - await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.unpreviewable'] })) }) + await act(async () => {}) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) expect(b.openPath).toHaveBeenCalledWith(ABSOLUTE_PATH, 'open', undefined) expect(screen.queryByRole('alert')).toBeNull() }) @@ -121,7 +124,8 @@ describe('OpenPathEmptyAction', () => { vi.useFakeTimers() const b = bench({ openPath: vi.fn(async () => 'openError' as const) }) render() - await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.unpreviewable'] })) }) + await act(async () => {}) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) expect(screen.getAllByRole('alert')).toHaveLength(1) expect(screen.getByRole('alert').textContent).toContain(zh['path.openError']) act(() => { vi.advanceTimersByTime(4_000) }) @@ -133,9 +137,11 @@ it('dismisses a failure on schedule even when its owner rerenders', async () => vi.useFakeTimers() const b = bench({ openPath: vi.fn(async () => 'openError' as const) }) const view = render() - await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.unpreviewable'] })) }) + await act(async () => {}) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) act(() => { vi.advanceTimersByTime(2_000) }) view.rerender() + await act(async () => {}) act(() => { vi.advanceTimersByTime(1_999) }) expect(screen.getByRole('alert')).toBeTruthy() act(() => { vi.advanceTimersByTime(1) }) @@ -157,7 +163,7 @@ it('shows the default icon and current application list, and selects an applicat expect(screen.getByRole('menuitem', { name: 'Music(默认)' })).toBeTruthy() await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Player' })) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', '/Player.app') - await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: t('open.title', { app: 'Music' }) })) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) }) @@ -178,21 +184,34 @@ it('ignores an obsolete file query and refreshes the default application when th expect(applications).toHaveBeenLastCalledWith('/other.mp3', expect.any(AbortSignal)) }) -it('keeps default opening available when application discovery fails', async () => { +it('keeps reveal last and makes it the default when the file query fails', async () => { const b = bench() render( null} />) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) - expect(screen.getByRole('menuitem', { name: zh['path.appsError'] }).hasAttribute('disabled')).toBe(true) - await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.defaultApp'] })) }) - expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) + const items = screen.getAllByRole('menuitem') + expect(items.map(item => item.textContent)).toEqual([zh['path.appsError'], '显示文件位置(默认)']) + expect(items[0]).toHaveProperty('disabled', true) + await act(async () => { fireEvent.click(items[1]!) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined) +}) + +it.each([[], [{ id: '/Player.app', name: 'Player', default: false, icon: null }]])('uses reveal as the default after the available applications when none is default', async (apps) => { + const b = bench() + render( apps} />) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) + expect(screen.getAllByRole('menuitem').at(-1)?.textContent).toBe('显示文件位置(默认)') + expect(screen.queryByText('用默认应用打开')).toBeNull() + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.reveal'] })) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined) }) it('coalesces repeated gestures before the busy state renders', async () => { const pending = Promise.withResolvers() const b = bench({ openPath: vi.fn(() => pending.promise) }) - const { result } = renderHook(() => usePathGesture(b.props)) - act(() => { result.current.act('open'); result.current.act('open') }) + const { result } = renderHook(() => useOpenTargetGesture(() => b.openPath(ABSOLUTE_PATH, 'open'), t)) + act(() => { result.current.act({ kind: 'default' }); result.current.act({ kind: 'default' }) }) expect(b.openPath).toHaveBeenCalledOnce() await act(async () => { pending.resolve(null) }) expect(result.current.pending).toBe(false) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 6f389f9866..154c57b993 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -628,10 +628,6 @@ "eee35b4d8641824c6eaacfe03ed60d353d81d5257d1111132f34d46a7b13ef35": 2, "ff65b54ee69109a8b059fc2a2136c71a5476b47115120c7c92a01171d517f3d7": 1 }, - "packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx": { - "d6ca469f487b05b19564776f9a8f8288b1f0d9fb5b7fb6681e68f063fb021f38": 1, - "fdc0ec545dc17a50bdc1c64102915706cfbfdbb65a2165ae4691f29d577063cd": 1 - }, "packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts": { "dc36dd8f678695e3b84ce0573968470941c5b6fc5efe529cba17acb1f2799d1c": 1 }, diff --git a/snapshots/web/document-preview/applications.expected.md b/snapshots/web/document-preview/applications.expected.md index 9a6667e3e4..d70632d545 100644 --- a/snapshots/web/document-preview/applications.expected.md +++ b/snapshots/web/document-preview/applications.expected.md @@ -1,5 +1,4 @@ - menu: - - menuitem "Open in default app" - menuitem "Test Player (default)" - menuitem "Other Player" - menuitem "Show file location" diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index e92c8cf01f..d6b251223d 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -81,5 +81,5 @@ - State: unsupported - Line: Preview is not available for this file type yet. -- Header control: Open -- Empty-state control: Open in default app +- Header control has no text: true +- Empty-state control: Open From 5c409c39bcd5bbcf3551ed5f52ecc7cea1454651 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 11:48:58 +0800 Subject: [PATCH 101/284] fix: type shared opening fixtures and document default actions --- packages/client/ui-open-in-app/README.i18n.yaml | 4 ++-- packages/client/ui-open-in-app/README.md | 8 ++++---- packages/client/ui-open-in-app/README.zh.md | 8 ++++---- .../client/ui-open-in-app/src/client/OpenPathAction.tsx | 2 +- .../tests/open-in-app-action.client.spec.tsx | 8 +++----- .../ui-open-in-app/tests/open-path-action.client.spec.tsx | 2 +- 6 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index cc15c6a7eb..e0ff0d0e57 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -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/ui-open-in-app/README.md -README.md: a21a0b3bf3cc5dabe79dc2aa907640d66c8b25c5 -README.zh.md: cbbefaa2d619f591167f7b7775b2eb4c2c945ff1 +README.md: e38909417c715e6b953dfe54c24b85dda351045c +README.zh.md: 301e9cf9bba736c9056a5df3cb59b320f998f500 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index a21a0b3bf3..e38909417c 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -29,11 +29,11 @@ Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../h ### What to expect -The main button shows the remembered application's icon — the real application icon wherever the host extracts one (macOS bundle icons, Windows executable icons, Linux theme icons), a generic glyph where it serves none — and a design-system tooltip ("Open locally"); clicking launches immediately. The chevron opens a dense menu of the installed applications with the remembered one marked by a filled row. Availability is read once per page from the host; the last chosen application persists in the browser (`dsh.open-in-app.choice`), and a choice that is no longer installed falls back to the first available entry. A launch that finishes quickly leaves the button untouched — the dimmed busy treatment appears only after 250 ms in flight — and a failed launch shows the error tooltip and a red outline for two seconds. All copy lives in the bilingual `open-in-app` locale namespace; an application id the dictionaries cannot name is not offered. +The Session header and document header use the same 24px-high, icon-only split button with 9px corners. Both show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application. -In the document preview, the header's "Open ▾" split button opens the previewed file in the Host's default application from its main button, and its menu offers that action and "Show file location" (the file manager's reveal). A file the preview cannot render, whether an unsupported suffix such as video or an archive or a file the reader rejected as non-text or oversized, shows an "Open in default app" button in its empty state where Retry would otherwise stand. Both controls hand the Host the absolute path the file's metadata reports, appear only after the Host answered that a desktop is available, disable only while their own gesture settles, and announce a failed gesture once through a transient toast ("Could not open. Try again." or "Could not show the file location. Try again."); nothing stays on the control afterwards. +File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” always stays last. When no default application is identified, that row reads “Show file location (default)” and the main button reveals the file. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file’s “Open” button uses the same default action. -The document header shows the default application’s icon when available. Its menu lists the file’s OS-registered applications and marks the default. Choosing an application opens this file once; the main button continues to use the system default. Associations refresh when the file or menu visibility changes; cancelled queries cannot replace another file’s results. A failed query displays a menu message while default opening and reveal remain available. Platform coverage follows [native-command](../../util/native-command/README.md). +File associations refresh when the file changes or its menu opens; cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control. ----- @@ -45,7 +45,7 @@ The document header shows the default application’s icon when available. Its m The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. -The file controls register on the document preview's `sidebar.right.tab.document.actions` and `sidebar.right.tab.document.unpreviewable` child slots, whose owner props carry the file’s absolute execution-environment path. A second page-lifetime controller ([`src/client/open-path.ts`](src/client/open-path.ts)) reads the Session Remote's `session.canOpenWorkspacePath` once per page into a desktop snapshot store both controls share, and runs each gesture through `session.openWorkspacePath` with the path and, for reveal, `action: 'reveal'`; a refused or rejected call resolves as the failure kind the control announces. The controls share one gesture hook that owns the pending flag and the toast, so a gesture from one control never restyles the other. The node half is an empty `apply` that keeps the plugin on the host roster. +The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, sizing, and gesture feedback. The file header and empty state share `FileOpenTarget`, while their native operations pass through [`open-path.ts`](src/client/open-path.ts) and the Session Remote. The directory adapter keeps the existing catalog routes. A failed or unavailable file query therefore needs no platform-specific UI implementation. diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index cbbefaa2d6..301e9cf9bb 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -29,11 +29,11 @@ kind: "package-reference" ### 预期行为 -主按钮显示记住的应用图标——凡主机能提取的都是应用真实图标(macOS bundle 图标、Windows 可执行文件图标、Linux 主题图标),提取不到时是通用占位图形——并带设计系统 tooltip(「在本地打开」);点击立即启动。下拉箭头打开已安装应用的紧凑菜单,记住的条目以整行填充标记。可用性每页读取一次;上次选择的应用持久化在浏览器中(`dsh.open-in-app.choice`),不再安装的选择回退到第一个可用条目。快速完成的启动不改变按钮外观——变暗的等待态只在飞行超过 250 毫秒后出现——失败的启动显示错误 tooltip 与红色描边两秒。所有文案在双语 `open-in-app` locale 命名空间中;词典无法命名的应用 id 不会被提供。 +会话标题栏和文档标题栏共用同一个高 24px、圆角 9px 的纯图标分体按钮。两者都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 -在文档预览里,头部的「打开 ▾」分体按钮的主按钮用 Host 默认应用打开当前文件,菜单提供同一动作和「显示文件位置」(文件管理器的定位)。预览无法渲染的文件,无论是视频、压缩包这类不支持的后缀,还是被读取器判为非文本或过大的文件,都会在空态里原本放重试的位置显示「用默认应用打开」按钮。两个控件都把文件元数据报出的绝对路径交给 Host,只在 Host 回答有桌面之后出现,只在自己的手势结算期间禁用,失败时通过短暂 toast 提示一次(「打开失败,请重试」或「无法显示文件位置,请重试」),之后控件上不残留任何状态。 +文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”始终放在最后。未识别到默认应用时,该项显示为“显示文件位置(默认)”,主按钮也执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态中的“打开”按钮也使用相同的默认动作。 -文档标题栏在可用时显示默认应用图标。菜单列出系统为该文件注册的应用,并标记默认项。选择应用只用于本次打开,主按钮始终使用系统默认应用。文件或菜单显示状态变化时会重新查询关联应用;已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,默认打开和定位仍可使用。平台支持范围见 [native-command](../../util/native-command/README.zh.md)。 +文件变化或打开菜单时会重新查询文件关联;已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。 ----- @@ -45,7 +45,7 @@ kind: "package-reference" 插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。 -文件控件注册在文档预览的 `sidebar.right.tab.document.actions` 与 `sidebar.right.tab.document.unpreviewable` 子 slot 上,owner props 携带文件在执行环境中的绝对路径。第二个页面生命周期的 controller([`src/client/open-path.ts`](src/client/open-path.ts))每页读取一次 Session Remote 的 `session.canOpenWorkspacePath`,写进两个控件共用的桌面 snapshot store,并把每个手势交给 `session.openWorkspacePath`,带上路径,定位时再带 `action: 'reveal'`;被拒绝或抛错的调用解析为控件要提示的失败种类。两个控件共用一个手势 hook,由它持有 pending 标志和 toast,所以一个控件的手势不会改变另一个控件的样子。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。 +目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标、尺寸和操作反馈。文件标题栏和空态共用 `FileOpenTarget`,原生操作通过 [`open-path.ts`](src/client/open-path.ts) 与 Session Remote 执行。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx index 317f392740..d6c7da7f7a 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx @@ -58,7 +58,7 @@ export function FileOpenTarget(props: FileOpenTargetProps): ReactNode { return ( app.default)?.id} - failed={association?.path === props.absolutePath && apps === null} empty={props.empty} t={props.t} + failed={association?.path === props.absolutePath && apps === null} empty={props.empty === true} t={props.t} refresh={() => { setRevision(value => value + 1) }} execute={operation => props.openPath(props.absolutePath, operation.kind === 'reveal' ? 'reveal' : 'open', operation.kind === 'application' ? operation.id : undefined)} diff --git a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx index 0116963d6e..1611bff570 100644 --- a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx @@ -29,15 +29,13 @@ function bench(over: { cwd?: string launch?: (appId: string, path: string) => Promise } = {}): Bench { - const state = { + const state: SessionListState = { ids: [SESSION], - byId: over.cwd === undefined ? {} : { [SESSION]: { cwd: over.cwd } }, - current: SESSION, + byId: over.cwd === undefined ? {} : { [SESSION]: { id: SESSION, displayTitle: 'Workspace', cwd: over.cwd, running: false, retainedBy: {}, blank: false, updatedAt: 0 } }, phase: 'ready', projectionsBySession: {}, jobsBySession: {}, - currentAddress: undefined, - } as SessionListState + } const apps = createSnapshotStore(over.apps ?? null) const choice = createSnapshotStore(over.choice ?? '') const launch = vi.fn(over.launch ?? (async () => {})) diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index 961b066a3a..6fc0b23be2 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -195,7 +195,7 @@ it('keeps reveal last and makes it the default when the file query fails', async expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined) }) -it.each([[], [{ id: '/Player.app', name: 'Player', default: false, icon: null }]])('uses reveal as the default after the available applications when none is default', async (apps) => { +it.each([{ apps: [] }, { apps: [{ id: '/Player.app', name: 'Player', default: false, icon: null }] }])('uses reveal as the default after the available applications when none is default', async ({ apps }) => { const b = bench() render( apps} />) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) From 6111f879fd9a7fe9354f86fc846f00997269517c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 11:50:40 +0800 Subject: [PATCH 102/284] test: verify shared opener menu toggling and SSH visibility --- apps/web/tests/open-in-app-ssh.e2e.ts | 3 +-- .../ui-open-in-app/src/client/OpenTargetButton.tsx | 2 +- .../tests/open-path-action.client.spec.tsx | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/open-in-app-ssh.e2e.ts b/apps/web/tests/open-in-app-ssh.e2e.ts index 7323495984..ff57285d88 100644 --- a/apps/web/tests/open-in-app-ssh.e2e.ts +++ b/apps/web/tests/open-in-app-ssh.e2e.ts @@ -60,8 +60,7 @@ describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => { ]) expect(response.status()).toBe(200) expect(await response.json()).toEqual({ apps: [] }) - expect(await page.getByRole('button', { name: /^Open workspace in / }).count()).toBe(0) - expect(await page.getByRole('button', { name: 'Choose an app to open in', exact: true }).count()).toBe(0) + expect(await page.locator('[data-open-target="directory"]').count()).toBe(0) expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"vscode"') const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index 570e8f4914..b96675d8ae 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -79,7 +79,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { const { pending, toast, act } = useOpenTargetGesture(props.execute, t) const preferred = applications.find(app => app.id === defaultId) const revealDefault = kind === 'file' && preferred === undefined - const primaryLabel = revealDefault ? t('path.reveal') : t('open.title', { app: preferred?.name ?? '' }) + const primaryLabel = preferred === undefined ? t('path.reveal') : t('open.title', { app: preferred.name }) const run = (operation: OpenTargetOperation): void => { setMenuOpen(false); act(operation) } const primary = (): void => { run({ kind: revealDefault ? 'reveal' : 'default' }) } const icon = revealDefault diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index 6fc0b23be2..15df920d7c 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -216,3 +216,17 @@ it('coalesces repeated gestures before the busy state renders', async () => { await act(async () => { pending.resolve(null) }) expect(result.current.pending).toBe(false) }) + + +it('closes the application menu from its chevron without querying again', async () => { + const b = bench() + render() + await act(async () => {}) + const more = screen.getByRole('button', { name: zh['path.more'] }) + await act(async () => { fireEvent.click(more) }) + expect(screen.getByRole('menu')).toBeTruthy() + const calls = vi.mocked(b.props.applications).mock.calls.length + await act(async () => { fireEvent.click(more) }) + expect(screen.queryByRole('menu')).toBeNull() + expect(b.props.applications).toHaveBeenCalledTimes(calls) +}) From 71b7bbeb88a1916b9a27018d53ac9d0067e6989f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 11:55:48 +0800 Subject: [PATCH 103/284] fix: wait for file associations before enabling the default action --- .../src/client/OpenPathAction.tsx | 3 ++- .../src/client/OpenTargetButton.tsx | 14 ++++++++------ .../tests/open-path-action.client.spec.tsx | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx index d6c7da7f7a..d7bf5aca23 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx @@ -58,7 +58,8 @@ export function FileOpenTarget(props: FileOpenTargetProps): ReactNode { return ( app.default)?.id} - failed={association?.path === props.absolutePath && apps === null} empty={props.empty === true} t={props.t} + loading={association?.path !== props.absolutePath} failed={association?.path === props.absolutePath && apps === null} + empty={props.empty === true} t={props.t} refresh={() => { setRevision(value => value + 1) }} execute={operation => props.openPath(props.absolutePath, operation.kind === 'reveal' ? 'reveal' : 'open', operation.kind === 'application' ? operation.id : undefined)} diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index b96675d8ae..207fee182f 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -26,6 +26,7 @@ export interface OpenTargetButtonProps { readonly applications: readonly OpenTargetApplication[] readonly defaultId: string | undefined readonly failed: boolean + readonly loading?: boolean readonly empty?: boolean readonly t: TranslateNS readonly execute: (operation: OpenTargetOperation) => Promise @@ -78,6 +79,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { const [menuOpen, setMenuOpen] = useState(false) const { pending, toast, act } = useOpenTargetGesture(props.execute, t) const preferred = applications.find(app => app.id === defaultId) + const disabled = pending || props.loading === true const revealDefault = kind === 'file' && preferred === undefined const primaryLabel = preferred === undefined ? t('path.reveal') : t('open.title', { app: preferred.name }) const run = (operation: OpenTargetOperation): void => { setMenuOpen(false); act(operation) } @@ -87,7 +89,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { : if (props.empty === true) return ( <> - {toast} @@ -97,7 +99,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { <> { run(id === 'reveal' ? { kind: 'reveal' } : { kind: 'application', id: id.slice(4) }) }} anchor={( -
      +
      - - {toast} - - ) + ? + : return ( <> { run(id === 'reveal' ? { kind: 'reveal' } : { kind: 'application', id: id.slice(4) }) }} anchor={( -
      +
      -
      {collapsible &&
      -
      - - { setMenuOpen(false) }} - anchor={} - items={[ - { id: 'open', icon: , - label: t('presented.defaultApp') }, - { id: 'reveal', icon: , - label: t(`presented.${reveal}`) }, - ]} - onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} /> -
      +
      {actions}
      } diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 01ac90f1fa..c51efeeaef 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -7,11 +7,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode, UIEvent } from 'react' import { Button, IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular, - IconNowrapFillRegular, IconRightUpOutlineRegular, IconWrapFillRegular, Menu, Tooltip, + IconNowrapFillRegular, IconWrapFillRegular, Menu, Tooltip, languageForPath, useCodeHighlighter, } from '@deepseek-ai/dsh-client-ui-primitives' import type { CodeHighlighter, HighlightSpan } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceChangedFile, WorkspaceDiffHunk } from '@deepseek-ai/dsh-workspace-changes/types' @@ -45,7 +45,7 @@ export interface ReviewInjected { /** The body's composed props: the tab it draws, its store, its injected face, and its copy. */ export type ReviewTabProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsStore> - & InjectFace & PropsLocale + & InjectFace & PropsLocale & PropsRenderSlots<'deliverables.file.actions'> /** One drawn line of a hunk with its line numbers on each side. */ export interface DiffRow { @@ -189,7 +189,7 @@ function Counts({ file, t }: { file: WorkspaceChangedFile } & PropsLocale { tab.actions.openResource(fileAddressFor(sessionId, cwd, file.path)) }}> } - {file !== undefined && native && - - } + {file !== undefined && renderSlot('deliverables.file.actions', { + actionUrl: changedFileUrl(sessionId, seq, index), available: native, + pending: phase === 'opening' || phase === 'revealing', + onAction: (action, application) => openChanged(sessionId, seq, index, action, application), + })}
      {summaryState === 'loading' &&

      {t('diff.loading')}

      } diff --git a/packages/client/ui-deliverables/src/client/file-actions.ts b/packages/client/ui-deliverables/src/client/file-actions.ts new file mode 100644 index 0000000000..b39ab16a41 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/file-actions.ts @@ -0,0 +1,21 @@ +/** Native file-action contribution shared by delivery cards and change review. */ +import type { PresentedOpenFailure } from './present-open.ts' +import type { PresentedAction } from '../presented.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** Open one file through its authorized Session event coordinates. */ + 'deliverables.file.actions': { + kind: 'list' + scope: 'session' + owner: { + /** Authenticated document-relative action route carrying Session event coordinates. */ + readonly actionUrl: string + readonly available: boolean + readonly pending: boolean + /** Execute the selected native action and publish status on the owning file surface. */ + readonly onAction: (action: PresentedAction, application?: string) => Promise + } + } + } +} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index e61596ea4b..da1e38c5e6 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -8,6 +8,7 @@ * cap, and copy — so composing this plugin out of cordis.yml removes every * surface; the owning view renders an empty list and inert prose at zero cost. */ +import './file-actions.ts' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-connection/client' @@ -63,13 +64,14 @@ export function apply(ctx: ClientContext): void { name: 'conversation.chat.turnTail', id: '@deepseek-ai/dsh-client-ui-deliverables', locale: NS, + children: { 'deliverables.file.actions': { kind: 'list', scope: 'session' } }, inject: (): DeliverablesInjected => ({ hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state, showCodeDiff: ctx.settingsScope.developerTools.enabled }, reloadPresentedHost: () => opener.loadHost(), loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq), - openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action), - openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index), + openPresented: (sessionId, seq, index, action, application) => opener.open(sessionId, seq, index, action, application), + openChanged: (sessionId, seq, index, action, application) => opener.openChanged(sessionId, seq, index, action, application), openChangesReview: (coordinates, index) => { ctx.sidebarRight.openResource(changesReviewAddress(coordinates), { params: { index } }) }, @@ -84,12 +86,13 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( { name: 'sidebar.right.pane.tab', key: CHANGES_REVIEW_ID, locale: NS, store: createReviewStore(), + children: { 'deliverables.file.actions': { kind: 'list', scope: 'session' } }, inject: (): ReviewInjected => ({ hooks: { changesSummary: summaries.state, changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host }, loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq), loadChangesDiff: (sessionId, seq, index) => diffs.load(sessionId, seq, index), reloadPresentedHost: () => opener.loadHost(), - openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index), + openChanged: (sessionId, seq, index, action, application) => opener.openChanged(sessionId, seq, index, action, application), }), }, ReviewTab, diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 7eacc52c34..4ed0595bc3 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -15,12 +15,6 @@ export const zh = { 'presented.unavailable': '此主机没有可用的桌面,无法使用外部程序打开文件或文件夹;文件仍可在侧边栏预览', 'presented.retry': '重试', 'presented.hostError': '无法读取主机桌面信息', - 'presented.directory': '打开所在文件夹', - 'presented.explorer': '在文件资源管理器中显示', - 'presented.finder': '在 Finder 中显示', - 'presented.defaultApp': '用默认应用打开', - 'presented.more': '{name} 的更多文件操作', - 'presented.action': '打开', 'presented.preview': '在侧边栏预览', 'presented.previewButton': '在侧边栏打开 {name}', 'presented.previewCard': '在侧边栏预览 {name}', @@ -59,7 +53,6 @@ export const zh = { 'review.wrapAria': '自动换行', 'review.openFile': '在侧边栏打开整个文件', 'review.openFileAria': '在侧边栏打开 {name}', - 'diff.openNativeAria': '用默认应用打开 {name}', 'diff.loading': '正在读取改动…', 'diff.missing': '这轮改动的内容已不可用', 'diff.error': '无法读取改动', @@ -70,8 +63,6 @@ export const zh = { 'diff.unchanged': '两侧内容相同', 'diff.coarse': '逐行对比超时,按整个文件替换显示', 'diff.truncated': '只显示前 {count} 行', - 'diff.openNative': '用默认应用打开', - 'diff.openNativeError': '打开失败,点击重试', } /** English dictionary (same key set). */ @@ -86,12 +77,6 @@ export const en: Record = { 'presented.unavailable': 'This Host has no desktop available to open files or folders in external apps. Files can still be previewed in the sidebar.', 'presented.retry': 'Retry', 'presented.hostError': 'Could not read the Host desktop information', - 'presented.directory': 'Open containing folder', - 'presented.explorer': 'Show in File Explorer', - 'presented.finder': 'Show in Finder', - 'presented.defaultApp': 'Open in default app', - 'presented.more': 'More file actions for {name}', - 'presented.action': 'Open', 'presented.preview': 'Preview in sidebar', 'presented.previewButton': 'Open {name} in sidebar', 'presented.previewCard': 'Preview {name} in sidebar', @@ -130,7 +115,6 @@ export const en: Record = { 'review.wrapAria': 'Line wrap', 'review.openFile': 'Open the whole file in the sidebar', 'review.openFileAria': 'Open {name} in sidebar', - 'diff.openNativeAria': 'Open {name} in default app', 'diff.loading': 'Reading changes…', 'diff.missing': 'The contents of this turn’s changes are no longer available', 'diff.error': 'Could not read the changes', @@ -141,8 +125,6 @@ export const en: Record = { 'diff.unchanged': 'Both sides hold the same lines', 'diff.coarse': 'Line comparison timed out; shown as a whole-file replacement', 'diff.truncated': 'Showing the first {count} lines', - 'diff.openNative': 'Open in default app', - 'diff.openNativeError': 'Could not open. Click to retry.', } /** Union of this namespace's dictionary keys. */ diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index d724816d19..92764884ef 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -4,7 +4,10 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { changedFileUrl } from '../changes.ts' import { presentedFileUrl, PRESENT_HOST_ROUTE, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' -/** State of the latest explicit open gesture for one saved file. */ +/** Failure feedback for the shared native opening control. */ +export type PresentedOpenFailure = 'openError' | 'revealError' | null + +/** State published on the owning file card. */ export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' | 'nativeUnavailable' /** One browser plugin's file-open requests, cancelled when that plugin is disposed. */ @@ -16,7 +19,7 @@ export class PresentedOpenController { private loading: Promise | undefined private metadata = new AbortController() private readonly lifetime = new AbortController() - private readonly pending = new Set>() + private readonly pending = new Set>() /** * Open a declared file once while a request for the same coordinates is pending. @@ -25,10 +28,13 @@ export class PresentedOpenController { * @param seq - durable delivery event sequence. * @param index - original file index within that event. * @param action - default application open or file-manager reveal. - * @returns after the Host acknowledges opening or the error state is published. + * @param application - registered handler identifier for an explicit application choice. + * @returns null after a successful handoff, or the failure to announce after publishing card status. */ - open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise { - return this.openUrl(presentedFileUrl(sessionId, seq, index), action) + open( + sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open', application?: string, + ): Promise { + return this.openUrl(presentedFileUrl(sessionId, seq, index), action, application) } /** @@ -36,20 +42,24 @@ export class PresentedOpenController { * @param sessionId - viewed Session. * @param seq - durable workspace/changes event sequence. * @param index - original file index within that event. - * @returns after the Host acknowledges opening or the error state is published. + * @param action - application open or file-manager reveal. + * @param application - registered handler identifier for an explicit application choice. + * @returns null after a successful handoff, or the failure to announce after publishing card status. */ - openChanged(sessionId: SessionId, seq: number, index: number): Promise { - return this.openUrl(changedFileUrl(sessionId, seq, index), 'open') + openChanged( + sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open', application?: string, + ): Promise { + return this.openUrl(changedFileUrl(sessionId, seq, index), action, application) } - private async openUrl(url: string, action: PresentedAction): Promise { + private async openUrl(url: string, action: PresentedAction, application?: string): Promise { const phase = this.state.getSnapshot()[url] - if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return + if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return null this.state.update((state) => { state[url] = action === 'open' ? 'opening' : 'revealing' }) - const task = this.request(url, action) + const task = this.request(url, action, application) this.pending.add(task) try { - await task + return await task } finally { this.pending.delete(task) } @@ -103,16 +113,19 @@ export class PresentedOpenController { await Promise.all(this.pending) } - private async request(url: string, action: PresentedAction): Promise { + private async request(url: string, action: PresentedAction, application?: string): Promise { const failure = action === 'open' ? 'error' : 'revealError' let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed' try { - const response = await fetch(action === 'open' ? url : `${url}&action=reveal`, { method: 'POST', signal: this.lifetime.signal }) + const target = action === 'reveal' ? `${url}&action=reveal` + : application === undefined ? url : `${url}&application=${encodeURIComponent(application)}` + const response = await fetch(target, { method: 'POST', signal: this.lifetime.signal }) if (!response.ok) phase = response.status === 422 ? 'nativeUnavailable' : failure } catch { // Transport failures share the retryable card state with Host open failures. phase = failure } if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = phase }) + return phase === 'opened' || phase === 'revealed' ? null : action === 'reveal' ? 'revealError' : 'openError' } } diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index 56b88f0651..6023625644 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -34,12 +34,12 @@ export function registerPresentOpen(ctx: Context): void { await Promise.allSettled(pending) }) const routes = [ - [PRESENT_OPEN_PATH, 'POST', handlePresentOpen], [CHANGES_OPEN_PATH, 'POST', handleChangesOpen], [CHANGES_DIFF_PATH, 'GET', handleChangesDiff], + [PRESENT_OPEN_PATH, ['GET', 'POST'], handlePresentOpen], [CHANGES_OPEN_PATH, ['GET', 'POST'], handleChangesOpen], [CHANGES_DIFF_PATH, ['GET'], handleChangesDiff], ] as const - for (const [path, method, handler] of routes) { + for (const [path, methods, handler] of routes) { ctx.connection.fetch.register({ path, - methods: [method], + methods: [...methods], requestBody: 'buffered', fetch: (request) => { const task = handler(ctx, new Request(request, { @@ -91,7 +91,13 @@ async function openVerified(ctx: Context, request: Request, path: string, action return new Response('Path has no verified Host path.', { status: 422 }) } request.signal.throwIfAborted() - await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action } : {}) }, request.signal) + if (request.method === 'GET') { + return Response.json(await ctx.sessionController.workspacePathApplications({ path }, request.signal), + { headers: { 'cache-control': 'no-store' } }) + } + const application = new URL(request.url).searchParams.get('application') + await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action } + : application === null ? {} : { application }) }, request.signal) return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } }) } @@ -159,6 +165,8 @@ async function handleChangesDiff(ctx: Context, request: Request): Promise { + const action = new URL(request.url).searchParams.get('action') ?? 'open' + if (action !== 'open' && action !== 'reveal') return new Response('Invalid file action.', { status: 400 }) const coordinates = changedFileCoordinates(request) if (coordinates instanceof Response) return coordinates const { id, seq, index } = coordinates @@ -171,7 +179,7 @@ async function handleChangesOpen(ctx: Context, request: Request): Promise ({ opened: true as const })) - ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) + const applications = vi.fn(async () => [{ id: 'player', name: 'Player', default: true, icon: null }]) + ctx.provide('sessionController', { workspacePathApplications: applications, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) await ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy', 'workspaceChanges'], @@ -71,7 +72,7 @@ async function fixture() { const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' })) const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`)) const compare = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_DIFF_PATH}${query}`)) - return { root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, opener, outside, summary } + return { handler, applications, root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, opener, outside, summary } } describe('change summary route', () => { @@ -199,3 +200,19 @@ describe('changed files native open route', () => { expect(isChangesEvent(null)).toBe(false) }) }) + + +it('queries handlers only after file authorization and forwards explicit application choices', async () => { + const { handler, applications, opener, open, ctx } = await fixture() + const url = 'http://localhost/api/changes.open?sessionId=owner&seq=9&index=0' + const response = await handler.fetch(new Request(url)) + expect(response.status).toBe(200) + expect(await response.json()).toEqual([{ id: 'player', name: 'Player', default: true, icon: null }]) + expect(opener).not.toHaveBeenCalled() + expect((await open('?sessionId=owner&seq=9&index=0&application=player')).status).toBe(204) + expect(opener.mock.lastCall?.[0]).toMatchObject({ application: 'player' }) + expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')))).status).toBe(404) + vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + expect((await handler.fetch(new Request(url))).status).toBe(422) + expect(applications).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index c6cb256373..65ead43368 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -6,6 +6,7 @@ * registrations' fiber-teardown removal (HMR safety) against the real * SlotRegistry. */ +import { renderFileActions } from './file-actions.tsx' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Context } from '@deepseek-ai/cordis' import { cleanup, fireEvent, render, within } from '@testing-library/react' @@ -41,6 +42,7 @@ function openProps(controller = new PresentedOpenController(), summaries = new C controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, jobsBySession: {} } return { + renderSlot: renderFileActions, useShowCodeDiff: (select: (value: boolean) => T): T => select(true), useSessions: (select: (state: SessionListState) => T): T => select(sessions), reloadPresentedHost: vi.fn(() => controller.loadHost()), @@ -494,7 +496,7 @@ describe('ChangedFiles card', () => { controller = new PresentedOpenController(), locale = en, matched = { changes, presented: [] as never[] }, summaries = servedStore(), ) { const props = openProps(controller, summaries) - props.openChanged.mockResolvedValue(undefined) + props.openChanged.mockResolvedValue(null) const openFile = vi.fn<(path: string) => void>() const view = render() return { props, openFile, view } @@ -845,7 +847,7 @@ describe('presented files', () => { const owner = tailOwner(deliverablesOf(value), 3, preview) const matched = selectDeliverables(owner)! const props = openProps() - props.openPresented.mockResolvedValue(undefined) + props.openPresented.mockResolvedValue(null) const view = render() expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) const expand = view.getByRole('button', { name: 'Show all 8 delivered files' }) @@ -855,12 +857,10 @@ describe('presented files', () => { expect(view.getByRole('button', { name: 'Collapse delivered files' }).getAttribute('aria-expanded')).toBe('true') expect(view.queryByRole('link')).toBeNull() fireEvent.click(view.getByRole('button', { name: 'Preview report-0.docx in sidebar' })) - fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in sidebar' })) - expect(preview).toHaveBeenCalledTimes(2) + expect(preview).toHaveBeenCalledTimes(1) expect(preview).toHaveBeenLastCalledWith('report-0.docx') - fireEvent.click(view.getByRole('button', { name: 'More file actions for report-0.docx' })) - fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' })) - expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open') + fireEvent.click(view.getAllByRole('button', { name: 'Native file action' })[0]!) + expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open', undefined) fireEvent.click(view.getByRole('button', { name: 'Collapse delivered files' })) expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) expect(view.container.querySelector('[data-changed-files]')).toBeNull() @@ -934,7 +934,7 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits { path: 'report.txt', seq: 2, index: 0 }, ] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) expect(view.getByText(en[`presented.${phase}`])).toBeTruthy() - expect((view.getByRole('button', { name: 'More file actions for report.txt' }) as HTMLButtonElement).disabled).toBe(phase === 'opening') + expect((view.getByRole('button', { name: 'Native file action' }) as HTMLButtonElement).disabled).toBe(phase === 'opening') }) diff --git a/packages/client/ui-deliverables/tests/file-actions.tsx b/packages/client/ui-deliverables/tests/file-actions.tsx new file mode 100644 index 0000000000..964b7b88b1 --- /dev/null +++ b/packages/client/ui-deliverables/tests/file-actions.tsx @@ -0,0 +1,8 @@ +/** Capture delivery action ownership without duplicating the opening-control implementation. */ +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '../src/client/index.ts' + +/** Slot renderer exposing the owning route and gesture for component tests. */ +export const renderFileActions: PropsRenderSlots<'deliverables.file.actions'>['renderSlot'] = (_name, props) => + props.available ? : null diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index 072529cdde..b4e80a0b0e 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -46,7 +46,8 @@ async function fixture() { }) ctx.provide('sessionQuery', { readEvent } as never) const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const })) - ctx.provide('sessionController', { resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) + const applications = vi.fn(async () => [{ id: 'player', name: 'Player', default: true, icon: null }]) + ctx.provide('sessionController', { workspacePathApplications: applications, resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'], apply: registerPresentOpen }) await fiber @@ -54,7 +55,7 @@ async function fixture() { const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request( `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null }, )) - return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent } + return { applications, root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent } } describe('Presented workspace file native open route', () => { @@ -233,3 +234,19 @@ it('uses the deployment workspace root when the viewed Session has no cwd', asyn expect((await open()).status).toBe(204) expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(cwd, file.path))) }) + + +it('queries handlers only after file authorization and forwards explicit application choices', async () => { + const { handler, applications, opener, open, ctx } = await fixture() + const url = 'http://localhost/api/present.open?sessionId=owner&seq=7&index=0' + const response = await handler.fetch(new Request(url)) + expect(response.status).toBe(200) + expect(await response.json()).toEqual([{ id: 'player', name: 'Player', default: true, icon: null }]) + expect(opener).not.toHaveBeenCalled() + expect((await open('?sessionId=owner&seq=7&index=0&application=player')).status).toBe(204) + expect(opener.mock.lastCall?.[0]).toMatchObject({ application: 'player' }) + expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')))).status).toBe(404) + vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + expect((await handler.fetch(new Request(url))).status).toBe(422) + expect(applications).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx index 28207b5a29..62a017f33a 100644 --- a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -13,66 +13,9 @@ const props = () => ({ host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const }, phase: undefined, onPreview: vi.fn(), - onAction: vi.fn(), + actions: , t: makeTranslate(en), }) - -it.each([ - ['finder', 'Show in Finder'], ['explorer', 'Show in File Explorer'], ['directory', 'Open containing folder'], -] as const)('uses the Host %s action and closes the menu after selection', (fileManager, label) => { - const p = props() - const view = render() - fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) - fireEvent.click(view.getByRole('menuitem', { name: new RegExp(label) })) - expect(p.onAction).toHaveBeenCalledWith('reveal') - expect(view.queryByRole('menu')).toBeNull() - fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) - fireEvent.click(view.getByRole('menuitem', { name: /Open in default app/ })) - expect(p.onAction).toHaveBeenLastCalledWith('open') - expect(p.onAction).toHaveBeenCalledTimes(2) -}) - -it('dismisses the menu with Escape or an outside click without launching anything', () => { - const p = props() - const view = render() - const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' }) - fireEvent.click(trigger) - fireEvent.keyDown(document, { key: 'Escape' }) - expect(view.queryByRole('menu')).toBeNull() - fireEvent.click(trigger) - fireEvent.pointerDown(document.body) - expect(view.queryByRole('menu')).toBeNull() - expect(p.onAction).not.toHaveBeenCalled() -}) - -it.each(['opening', 'revealing'] as const)('keeps sidebar previews available while the native action is %s', (phase) => { - const p = props() - const view = render() - expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) - fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) - fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })) - expect(p.onPreview).toHaveBeenCalledTimes(2) - expect(p.onAction).not.toHaveBeenCalled() -}) - -it('keeps the native menu disabled until a desktop is available', () => { - const p = props() - const view = render() - expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) - expect((view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) as HTMLButtonElement).disabled).toBe(false) - view.rerender() - expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) -}) - -it('opens the right sidebar from either the card or its primary button', () => { - const p = props() - const view = render() - fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) - fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })) - expect(p.onPreview).toHaveBeenCalledTimes(2) - expect(p.onAction).not.toHaveBeenCalled() -}) - it('localizes reveal failures and accurately reports a directory-only action', () => { const p = props() const view = render() @@ -84,36 +27,12 @@ it('localizes reveal failures and accurately reports a directory-only action', ( }) -it('supports keyboard selection and returns focus to the trigger on Escape', () => { - const view = render() - const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' }) - fireEvent.click(trigger) - const items = view.getAllByRole('menuitem') - expect(document.activeElement).toBe(items[0]) - fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) - expect(document.activeElement).toBe(items[1]) - fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) - expect(document.activeElement).toBe(items[0]) - fireEvent.keyDown(document.activeElement!, { key: 'End' }) - expect(document.activeElement).toBe(items[1]) - fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' }) - expect(document.activeElement).toBe(items[0]) - fireEvent.keyDown(document.activeElement!, { key: 'Home' }) - expect(document.activeElement).toBe(items[0]) - fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) - expect(document.activeElement).toBe(trigger) -}) - - it('shows the basename while retaining the full location for hover and actions', () => { const p = props() const path = '/work/reports/result.pdf' const view = render() expect(view.getByTitle(path)).toBeTruthy() expect(view.getByText('result.pdf')).toBeTruthy() - fireEvent.click(view.getByRole('button', { name: `More file actions for ${path}` })) - fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' })) - expect(p.onAction).toHaveBeenCalledWith('open') view.rerender() expect(view.getByTitle('/work/out/report.pdf')).toBeTruthy() expect(view.getByText('report.pdf')).toBeTruthy() @@ -130,31 +49,6 @@ it.each([ }) -it('does not reopen a menu after a shared native request settles', () => { - const p = props() - const view = render() - fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) - expect(view.getByRole('menu')).toBeTruthy() - view.rerender() - expect(view.queryByRole('menu')).toBeNull() - view.rerender() - expect(view.queryByRole('menu')).toBeNull() -}) - -it('keeps focus on the available preview button after selecting a native action', () => { - const p = props() - const view = render() - fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) - const item = view.getByRole('menuitem', { name: 'Show in Finder' }) - item.focus() - fireEvent.click(item) - view.rerender() - const preview = view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) - expect(document.activeElement).toBe(preview) - view.rerender() - expect(document.activeElement).toBe(preview) -}) - it.each([en, zh])('distinguishes directory-only progress and errors in each locale', (dictionary) => { const p = { ...props(), t: makeTranslate(dictionary) } const view = render() @@ -164,3 +58,15 @@ it.each([en, zh])('distinguishes directory-only progress and errors in each loca view.rerender() expect(view.getByText(dictionary['presented.directoryError'])).toBeTruthy() }) + + +it('renders the supplied action independently from the card preview', () => { + const p = props() + const native = vi.fn() + const view = render(Native file action} />) + fireEvent.click(view.getByRole('button', { name: 'Native file action' })) + expect(native).toHaveBeenCalledOnce() + expect(p.onPreview).not.toHaveBeenCalled() + fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) + expect(p.onPreview).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 9cdc89ec65..7d412a09cc 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom /** The review tab type: its addresses, its store, the row pairing of the split view, and the states its body draws. */ +import { renderFileActions } from './file-actions.tsx' import { useSyncExternalStore } from 'react' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -158,11 +159,17 @@ describe('ReviewTab', () => { loadChangesSummary: vi.fn(() => Promise.resolve()), loadChangesDiff: vi.fn(() => Promise.resolve()), reloadPresentedHost: vi.fn(() => Promise.resolve()), - openChanged: vi.fn(() => Promise.resolve()), + openChanged: vi.fn(() => Promise.resolve(null)), + } + const sessions: SessionListState = { + ids: [SESSION], + byId: { [SESSION]: { id: SESSION, displayTitle: 'Workspace', cwd: options.cwd ?? '/work/app', + running: false, retainedBy: {}, blank: false, updatedAt: 0 } }, + phase: 'ready', projectionsBySession: {}, jobsBySession: {}, } - const sessions = { byId: { [SESSION]: { cwd: options.cwd ?? '/work/app' } } } as unknown as SessionListState const navigation = { address: options.address ?? ADDRESS, params: options.params, revision: options.revision ?? 1 } const runtime = { + renderSlot: renderFileActions, useTabInfo: () => ({ sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane-1' }, tab: { id: TAB, kind: 'changes-review', contentId: options.address ?? ADDRESS, title: 'Review', visible: true, navigation, signal: aborter.signal, actions: tabActions }, @@ -177,7 +184,7 @@ describe('ReviewTab', () => { usePresentedHost: hookOf(controller.host), t: makeTranslate(options.locale ?? en), ...injected, - } as unknown as ReviewTabProps + } as ReviewTabProps const view = render() return { view, injected, tabActions, store, aborter, summaries, diffs, controller, @@ -313,19 +320,18 @@ describe('ReviewTab', () => { expect(view.container.querySelector('[data-review-tool="open-file"] svg')?.getAttribute('width')).toBe('12') fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in sidebar' })) expect(tabActions.openResource).toHaveBeenCalledWith(fileAddressFor(SESSION, '/work/app', '/tmp/out/big.bin')) - fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' })) - expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1) + fireEvent.click(view.getByRole('button', { name: 'Native file action' })) + expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1, 'open', undefined) act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' }) }) - expect((view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }) as HTMLButtonElement).disabled).toBe(true) + expect((view.getByRole('button', { name: 'Native file action' }) as HTMLButtonElement).disabled).toBe(true) act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'error' }) }) - expect(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }).hasAttribute('data-error')).toBe(true) act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' }) }) - expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull() + expect(view.queryByRole('button', { name: 'Native file action' })).toBeNull() act(() => { controller.state.set({}) controller.host.set({ name: 'server', available: false, fileManager: null }) }) - expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull() + expect(view.queryByRole('button', { name: 'Native file action' })).toBeNull() expect(view.getByText(en['changes.oversized'])).toBeTruthy() }) diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index a52f9a0e85..9f87aff18b 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -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/ui-open-in-app/README.md -README.md: 035315fa4c770b51614c7cf90064bed9ee00951e -README.zh.md: 248ed5335a30e5101fb10aa95a7a77a6eb73725e +README.md: 010f86f19297db33511b40129d6caf36c71e49f9 +README.zh.md: ec0fe7741356c71b73f4d5de6f5dc0bb1b3be887 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index 035315fa4c..010f86f192 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -45,7 +45,7 @@ File associations refresh when the file changes or its menu opens; cancelled que The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. -The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, sizing, and gesture feedback. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. A failed or unavailable file query therefore needs no platform-specific UI implementation. +The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, sizing, and gesture feedback. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation. diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index 248ed5335a..ec0fe77413 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" 插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。 -目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标、尺寸和操作反馈。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 +目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标、尺寸和操作反馈。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index 3b9e784afb..fde52fc3e9 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -70,6 +70,8 @@ "@deepseek-ai/dsh-host-open-in-app": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^" } } diff --git a/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx b/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx new file mode 100644 index 0000000000..287b4b3956 --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx @@ -0,0 +1,37 @@ +/** Adapt authorized delivery and changed-file routes to the shared opening control. */ +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-deliverables/client' +import { parseNativeFileApplications } from '@deepseek-ai/dsh-native-command/types' +import type { SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types' +import { useFileApplications } from './file-applications.ts' +import { OpenTargetButton } from './OpenTargetButton.tsx' +import type { NS } from './locales.ts' + +async function queryRoute(url: string, signal: AbortSignal): Promise { + try { + const response = await fetch(url, { signal }) + if (!response.ok) return null + return parseNativeFileApplications(await response.json()) + } catch (_error) { + // Unavailable routes and malformed responses leave file reveal as the fallback. + return null + } +} + +/** + * Render file actions without bypassing the owning Session's authorization route. + * @param props - authenticated route, desktop availability, and native gesture callback. + * @returns the shared compact control, or null without a desktop. + */ +export function FileRouteAction( + props: Pick, 'actionUrl' | 'available' | 'pending' | 'onAction'> & PropsLocale, +) { + const association = useFileApplications(props.actionUrl, queryRoute, props.available) + if (!props.available) return null + return app.default)?.id} failed={association.failed} + loading={association.loading || props.pending} refresh={association.refresh} t={props.t} + execute={async (operation) => { + return props.onAction(operation.kind === 'reveal' ? 'reveal' : 'open', operation.kind === 'application' ? operation.id : undefined) + }} /> +} diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx index 869aa8e041..2b6008d9b1 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx @@ -1,11 +1,12 @@ /** File association adapter for the shared file/directory opening control. */ -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import type { ReactNode } from 'react' import type { SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client' import type { OpenInAppPathAction, OpenInAppPathFailure } from './open-path.ts' +import { useFileApplications } from './file-applications.ts' import { OpenTargetButton } from './OpenTargetButton.tsx' import type { NS } from './locales.ts' @@ -37,30 +38,18 @@ type FileOpenTargetProps = Pick value) - const [revision, setRevision] = useState(0) - const [association, setAssociation] = useState<{ - path: string - apps: readonly SessionWorkspacePathApplication[] | null - } | null>(null) + const association = useFileApplications(props.absolutePath, props.applications, desktop === true) useEffect(() => { if (desktop === null) void props.loadDesktop() }, [desktop, props.loadDesktop]) - useEffect(() => { - if (desktop !== true) return - const controller = new AbortController() - void props.applications(props.absolutePath, controller.signal).then((apps) => { - if (!controller.signal.aborted) setAssociation({ path: props.absolutePath, apps }) - }) - return () => { controller.abort() } - }, [desktop, props.absolutePath, props.applications, revision]) if (desktop !== true) return null - const apps = association?.path === props.absolutePath ? association.apps : null + const { apps } = association return ( app.default)?.id} - loading={association?.path !== props.absolutePath} failed={association?.path === props.absolutePath && apps === null} + key={props.absolutePath} kind="file" applications={apps} defaultId={apps.find(app => app.default)?.id} + loading={association.loading} failed={association.failed} prominent={props.empty === true} t={props.t} - refresh={() => { setRevision(value => value + 1) }} + refresh={association.refresh} execute={operation => props.openPath(props.absolutePath, operation.kind === 'reveal' ? 'reveal' : 'open', operation.kind === 'application' ? operation.id : undefined)} /> diff --git a/packages/client/ui-open-in-app/src/client/file-applications.ts b/packages/client/ui-open-in-app/src/client/file-applications.ts new file mode 100644 index 0000000000..00bb9331f2 --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/file-applications.ts @@ -0,0 +1,37 @@ +/** File association reads shared by path previews and authorized delivery routes. */ +import { useEffect, useState } from 'react' +import type { SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types' + +/** + * Refresh associations on target changes and explicit menu opening; discard cancelled reads. + * @param target - path or authenticated route identifying the current file. + * @param query - target-specific association reader. + * @param enabled - whether a native desktop is available. + * @returns metadata, initial loading state, failure state, and a refresh callback. + */ +export function useFileApplications( + target: string, + query: (target: string, signal: AbortSignal) => Promise, + enabled: boolean, +) { + const [revision, setRevision] = useState(0) + const [association, setAssociation] = useState<{ + target: string + apps: readonly SessionWorkspacePathApplication[] | null + } | null>(null) + useEffect(() => { + if (!enabled) return + const controller = new AbortController() + void query(target, controller.signal).then((apps) => { + if (!controller.signal.aborted) setAssociation({ target, apps }) + }) + return () => { controller.abort() } + }, [enabled, target, query, revision]) + const apps = association?.target === target ? association.apps : null + return { + apps: apps ?? [], + loading: association?.target !== target, + failed: association?.target === target && apps === null, + refresh: () => { setRevision(value => value + 1) }, + } +} diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index 44572cdf3d..e80e58189a 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -22,6 +22,7 @@ import { OpenInAppController } from './controller.ts' import { OpenInAppAction, type OpenInAppActionInjected } from './OpenInAppAction.tsx' import { OpenInAppPathController } from './open-path.ts' import { OpenPathAction, type OpenPathInjected } from './OpenPathAction.tsx' +import { FileRouteAction } from './FileRouteAction.tsx' import { OpenPathEmptyAction } from './OpenPathEmptyAction.tsx' import { en, NS, zh, type OpenInAppKey } from './locales.ts' @@ -83,4 +84,7 @@ export function apply(ctx: ClientContext): void { locale: NS, inject: pathInjected, }, OpenPathEmptyAction)) + ctx.slots.inject('deliverables.file.actions', () => ctx.slots.register({ + name: 'deliverables.file.actions', id: 'open-in-app', locale: NS, + }, FileRouteAction)) } diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts index 29845dc875..e8aedbdbf9 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -40,17 +40,11 @@ const PRODUCT_NAMES = { /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'open.title': '用 {app} 打开', - 'open.error': '打开失败', - 'menu.toggle': '选择打开方式', - 'menu.aria': '打开方式', 'path.appDefault': '{app}(默认)', 'path.appsError': '无法获取应用列表', 'path.open': '打开', - 'path.open.tooltip': '用默认应用打开', 'path.more': '更多打开方式', - 'path.defaultApp': '用默认应用打开', 'path.reveal': '显示文件位置', - 'path.unpreviewable': '用默认应用打开', 'path.openError': '打开失败,请重试', 'path.revealError': '无法显示文件位置,请重试', ...PRODUCT_NAMES, @@ -63,17 +57,11 @@ export const zh = { /** English dictionary, key-identical to the Chinese source of truth. */ export const en: Record = { 'open.title': 'Open in {app}', - 'open.error': 'Failed to open', - 'menu.toggle': 'Choose an app to open in', - 'menu.aria': 'Open in', 'path.appDefault': '{app} (default)', 'path.appsError': 'Could not load applications', 'path.open': 'Open', - 'path.open.tooltip': 'Open in default app', 'path.more': 'More ways to open', - 'path.defaultApp': 'Open in default app', 'path.reveal': 'Show file location', - 'path.unpreviewable': 'Open in default app', 'path.openError': 'Could not open. Try again.', 'path.revealError': 'Could not show the file location. Try again.', ...PRODUCT_NAMES, diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index da5f8f1c9b..e538dc4fd2 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -40,6 +40,7 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null) ctx.provide('sessions', {}) @@ -67,6 +68,7 @@ describe('open-in-app browser half', () => { const empty = ctx.slots.entries('sidebar.right.tab.document.unpreviewable')[0] expect(header?.component).toBe(OpenPathAction) expect(empty?.component).toBe(OpenPathEmptyAction) + expect(ctx.slots.entries('deliverables.file.actions')).toHaveLength(1) expect(header?.options).toMatchObject({ id: 'open-in-app' }) const face = (header?.inject as unknown as () => OpenPathInjected)() const emptyFace = (empty?.inject as unknown as () => OpenPathInjected)() @@ -83,6 +85,7 @@ describe('open-in-app browser half', () => { await fiber.dispose() expect(ctx.slots.entries('sidebar.right.tab.document.actions').map(entry => entry.options.id)).not.toContain('open-in-app') expect(ctx.slots.entries('sidebar.right.tab.document.unpreviewable').map(entry => entry.options.id)).not.toContain('open-in-app') + expect(ctx.slots.entries('deliverables.file.actions')).toHaveLength(0) }) it('registers the header split button, and fiber teardown removes it (HMR safety)', async () => { @@ -147,11 +150,11 @@ describe('open-in-app browser half', () => { const { ctx, fiber } = await bench() ctx.locale.setLocale('zh') const translate = ctx.locale.bind(NS) - expect(translate('menu.aria')).toBe(zh['menu.aria']) + expect(translate('path.more')).toBe(zh['path.more']) ctx.locale.setLocale('en') - expect(translate('menu.aria')).toBe(en['menu.aria']) + expect(translate('path.more')).toBe(en['path.more']) await fiber.dispose() - expect(translate('menu.aria')).not.toBe(en['menu.aria']) + expect(translate('path.more')).not.toBe(en['path.more']) }) it('keeps the English dictionary key-identical to the Chinese source of truth', () => { diff --git a/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx new file mode 100644 index 0000000000..6404ec8b9e --- /dev/null +++ b/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +/** Authorized file routes use the same application menu and failure feedback as previews. */ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { FileRouteAction } from '../src/client/FileRouteAction.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(() => { cleanup(); vi.unstubAllGlobals() }) +const apps = [{ id: 'music', name: 'Music', default: true, icon: null }] +const props = () => ({ actionUrl: 'api/present.open?sessionId=s&seq=2&index=0', available: true, pending: false, + onAction: vi.fn(async () => null), t: makeTranslate(en) }) + +it('queries the authorized route, selects a handler, and keeps reveal last', async () => { + const fetcher = vi.fn(async () => Response.json(apps)) + vi.stubGlobal('fetch', fetcher) + const p = props() + render() + await act(async () => {}) + expect(fetcher).toHaveBeenCalledWith(p.actionUrl, { signal: expect.any(AbortSignal) }) + fireEvent.mouseEnter(screen.getByRole('button', { name: 'Open in Music' })) + expect(await screen.findByRole('tooltip')).toHaveProperty('textContent', 'Open in Music') + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en['path.more'] })) }) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['Music (default)', 'Show file location']) + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Music (default)' })) }) + expect(p.onAction).toHaveBeenLastCalledWith('open', 'music') + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Open in Music' })) }) + expect(p.onAction).toHaveBeenLastCalledWith('open', undefined) +}) + +it.each(['http', 'transport', 'invalid'] as const)('keeps reveal available after a %s query failure', async (failure) => { + vi.stubGlobal('fetch', vi.fn(async () => { + if (failure === 'transport') throw new Error('offline') + return failure === 'http' ? new Response(null, { status: 404 }) : Response.json({ invalid: true }) + })) + const p = props() + render() + await act(async () => {}) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en['path.reveal'] })) }) + expect(p.onAction).toHaveBeenCalledWith('reveal', undefined) +}) + +it('does not query without a desktop and honors another pending gesture', async () => { + const fetcher = vi.fn(async () => Response.json(apps)) + vi.stubGlobal('fetch', fetcher) + const p = props() + const view = render() + expect(view.container.innerHTML).toBe('') + expect(fetcher).not.toHaveBeenCalled() + view.rerender() + await act(async () => {}) + expect(screen.getByRole('button', { name: 'Open in Music' })).toHaveProperty('disabled', true) +}) diff --git a/packages/client/ui-open-in-app/tsconfig.json b/packages/client/ui-open-in-app/tsconfig.json index 1f0c094732..e37925c8a4 100644 --- a/packages/client/ui-open-in-app/tsconfig.json +++ b/packages/client/ui-open-in-app/tsconfig.json @@ -46,6 +46,12 @@ }, { "path": "../ui-slots" + }, + { + "path": "../ui-deliverables/tsconfig.client.json" + }, + { + "path": "../../util/native-command" } ] } diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 9d8b9b731d..08f13fbf40 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1564,6 +1564,65 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-conversation/src/client/contract/slots.ts:181', }, + { + key: 'deliverables.file.actions', + kind: 'list', + scope: 'session', + summary: 'Open one file through its authorized Session event coordinates.', + doc: 'Open one file through its authorized Session event coordinates.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** Native file action selected by an explicit user gesture. */\nexport type PresentedAction = \'open\' | \'reveal\'', + '/** Failure feedback for the shared native opening control. */\nexport type PresentedOpenFailure = \'openError\' | \'revealError\' | null', + ], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'conversation.chat.turnTail\' (client-ui-deliverables), so it exists while that entry is mounted', + occupants: [ + 'client-ui-open-in-app FileRouteAction id \'open-in-app\'', + ], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'deliverables.file.actions\', () => ctx.slots.register(\n { name: \'deliverables.file.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-deliverables/src/client/file-actions.ts:8', + }, { key: 'main', kind: 'keyed', diff --git a/packages/util/native-command/src/file-applications.ts b/packages/util/native-command/src/file-applications.ts index 9b834ad8dd..7a9b7f9ea8 100644 --- a/packages/util/native-command/src/file-applications.ts +++ b/packages/util/native-command/src/file-applications.ts @@ -4,7 +4,7 @@ import { linuxFileApplications } from './file-applications-linux.ts' import { windowsFileApplications } from './file-applications-windows.ts' import { nativeFileManager } from './path-opener.ts' import type { PathOpenerInternals } from './path-opener.ts' -import type { NativeFileApplication } from './types.ts' +import { parseNativeFileApplications, type NativeFileApplication } from './types.ts' /** AppKit runs inside the system JXA host; paths arrive as argv, never executable source. */ const MAC_APPLICATIONS = ` @@ -67,21 +67,7 @@ async function queryFileApplications( } else if (target.platform === 'win32') { stdout = await windowsFileApplications(target.path, null, signal, run) } else return [] - const value: unknown = JSON.parse(stdout) - if (!Array.isArray(value)) throw new Error('Invalid native application list') - const applications: NativeFileApplication[] = [] - const entries: readonly unknown[] = value - for (const entry of entries) { - if (typeof entry !== 'object' || entry === null - || !('id' in entry) || !('name' in entry) || !('default' in entry) || !('icon' in entry) - || typeof entry.id !== 'string' || entry.id.length === 0 - || typeof entry.name !== 'string' || typeof entry.default !== 'boolean' - || !(entry.icon === null || (typeof entry.icon === 'string' && /^data:image\/(?:png|svg\+xml);base64,[A-Za-z0-9+/=]+$/.test(entry.icon)))) { - throw new Error('Invalid native application entry') - } - applications.push({ id: entry.id, name: entry.name, default: entry.default, icon: entry.icon }) - } - return applications + return parseNativeFileApplications(JSON.parse(stdout)) } /** diff --git a/packages/util/native-command/src/types.ts b/packages/util/native-command/src/types.ts index 1e93b93bc5..5af42bb996 100644 --- a/packages/util/native-command/src/types.ts +++ b/packages/util/native-command/src/types.ts @@ -9,3 +9,26 @@ export interface NativeFileApplication { /** PNG or SVG data URL, or null when the desktop supplies no icon. */ readonly icon: string | null } + +/** + * Validate file association metadata received from a native process or authenticated Host. + * @param value - decoded application list. + * @returns validated application metadata. + * @throws Error for malformed entries or unsupported icon URLs. + */ +export function parseNativeFileApplications(value: unknown): readonly NativeFileApplication[] { + if (!Array.isArray(value)) throw new Error('Invalid native application list') + const applications: NativeFileApplication[] = [] + const entries: readonly unknown[] = value + for (const entry of entries) { + if (typeof entry !== 'object' || entry === null + || !('id' in entry) || !('name' in entry) || !('default' in entry) || !('icon' in entry) + || typeof entry.id !== 'string' || entry.id.length === 0 + || typeof entry.name !== 'string' || typeof entry.default !== 'boolean' + || !(entry.icon === null || (typeof entry.icon === 'string' && /^data:image\/(?:png|svg\+xml);base64,[A-Za-z0-9+/=]+$/.test(entry.icon)))) { + throw new Error('Invalid native application entry') + } + applications.push({ id: entry.id, name: entry.name, default: entry.default, icon: entry.icon }) + } + return applications +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc16be7fbf..f4f06bf314 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3575,6 +3575,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-deliverables': + specifier: workspace:^ + version: link:../ui-deliverables '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives @@ -3593,6 +3596,9 @@ importers: '@deepseek-ai/dsh-host-open-in-app': specifier: workspace:^ version: link:../../host/open-in-app + '@deepseek-ai/dsh-native-command': + specifier: workspace:^ + version: link:../../util/native-command '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 154c57b993..9bf0476844 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -594,10 +594,6 @@ "packages/client/ui-deliverables/tests/present-open.host.spec.ts": { "556e0d31b686cc80ef5c9562dad5534556f1b55ce1ce20491aa39eda98255364": 1 }, - "packages/client/ui-deliverables/tests/review-tab.client.spec.tsx": { - "52e0f8e51efd9a65974af6ad6868c7e01fff0d6faeb669e9da1bf91de8a2d422": 1, - "56f9870142343b23a01725140b72d040d1b9071dd30227271518f50cfa614b6c": 1 - }, "packages/client/ui-goal/tests/browser-plugin.client.spec.tsx": { "04984a2ce1486250236e638e0197da0a1a840855ed3975c59130a85358c7e5fe": 1, "5f1a0867b42cf35dac7fdf5839e3a39c2384c5b3b03142e799a36733ad96b9da": 1, From b80722812b1ff35ce1d44d73d4853987cc76936b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 13:55:27 +0800 Subject: [PATCH 121/284] fix: scope shared file actions to their owning views --- apps/web/tests/present-svg.e2e.ts | 2 +- apps/web/tests/present.e2e.ts | 28 +++++++++++-------- .../ui-deliverables/src/client/ReviewTab.tsx | 4 +-- .../src/client/file-actions.ts | 2 ++ .../ui-deliverables/src/client/index.ts | 2 +- .../ui-deliverables/tests/file-actions.tsx | 2 +- .../tests/present-open.host.spec.ts | 2 +- .../tests/turn-tail-spacing.client.spec.ts | 1 - .../client/ui-open-in-app/src/client/index.ts | 3 ++ 9 files changed, 27 insertions(+), 19 deletions(-) diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index 7c856df6f9..461b71e0e3 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -128,7 +128,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => { await assertFinalWorkspaceSnapshot(DIR, cwd) - await expect.poll(() => page.getByRole('button', { name: `${FILE} 的更多文件操作`, exact: true }).isDisabled()).toBe(true) + expect(await page.locator('[data-presented-file] [data-open-target]').count()).toBe(0) // Delivery owns the transcript; navigation and composer chrome have separate scenarios. const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 1c4c5fe44f..0fa592a09a 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -57,6 +57,13 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), }) + // File associations belong to the desktop rather than the recorded Session. + const controller = scaffold.ctx.get('sessionController') + if (controller === undefined) throw new Error('present requires Session Controller') + const nativeQuery: unknown = Reflect.get(controller, 'fileApplications') + if (typeof nativeQuery !== 'function') throw new Error('present requires native association discovery') + Reflect.set(controller, 'fileApplications', async () => [{ id: 'test-editor', name: 'Test Editor', default: true, icon: null }]) + scaffold.ctx.effect(() => () => { Reflect.set(controller, 'fileApplications', nativeQuery) }, 'present: native association fixture') disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true }) scaffold.ctx.on('session/event', (_session, event) => { events.push(event) }) browser = await chromium.launch() @@ -121,7 +128,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con } const row = page.locator('[data-presented-files-row]') await row.waitFor() - expect(await row.getByRole('button', { name: /More file actions/ }).count()).toBe(2) + expect(await row.getByRole('button', { name: 'More ways to open' }).count()).toBe(2) expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt') const beforePreview = (await opened()).length const column = page.locator('[data-rightbar-col]') @@ -139,19 +146,16 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(downloads).toEqual([]) await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() const beforeReveal = (await opened()).length - await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() + await row.locator('[data-presented-file]').filter({ hasText: 'report.txt' }).getByRole('button', { name: 'More ways to open', exact: true }).click() const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') - await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click() + await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click() expect((await revealResponse).status()).toBe(204) - expect(await row.getByRole('button', { name: 'Open report.txt in sidebar', exact: true }) - .evaluate(button => button === document.activeElement)).toBe(true) await expect.poll(opened).toHaveLength(beforeReveal + 1) expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) }) for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { const count = (await opened()).length const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') - await row.getByRole('button', { name: `More file actions for ${name}`, exact: true }).click() - await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click() + await row.locator('[data-presented-file]').filter({ hasText: name }).getByRole('button', { name: 'Open in Test Editor', exact: true }).click() expect((await response).status()).toBe(204) await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect(await opened()).toHaveLength(count + 1) @@ -217,7 +221,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con ) const description = requiredElement(report.querySelector('span[role="status"]'), 'report status') const open = requiredElement( - report.querySelector('button[aria-label="Open report.txt in sidebar"]'), + report.querySelector('[data-open-target] button'), 'report open action', ) const icon = requiredElement(report.querySelector('svg'), 'report icon') @@ -250,7 +254,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(geometry.iconWidth).toBe('20') expect(geometry.titleFontSize).toBe('13px') expect(geometry.descriptionFontSize).toBe('10px') - expect(geometry.openFontSize).toBe('12px') + expect(geometry.openFontSize).toBe('11px') await page.setViewportSize({ width: 480, height: 900 }) const row = page.locator('[data-presented-files-row]') await row.scrollIntoViewIfNeeded() @@ -263,9 +267,9 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con } const beforeDelete = (await opened()).length await unlink(join(cwd, 'report.txt')) - const missing = page.waitForResponse(response => response.url().includes('/api/present.open?')) - await page.locator('[data-presented-files-row]').getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() - await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click() + const missing = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') + await page.locator('[data-presented-file]').filter({ hasText: 'report.txt' }) + .getByRole('button', { name: 'Open in Test Editor', exact: true }).click() expect((await missing).status()).toBe(404) await page.getByText('Could not open. Click to retry.', { exact: true }).waitFor() expect(await opened()).toHaveLength(beforeDelete) diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index c51efeeaef..362f88ec9d 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -45,7 +45,7 @@ export interface ReviewInjected { /** The body's composed props: the tab it draws, its store, its injected face, and its copy. */ export type ReviewTabProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsStore> - & InjectFace & PropsLocale & PropsRenderSlots<'deliverables.file.actions'> + & InjectFace & PropsLocale & PropsRenderSlots<'deliverables.review.file.actions'> /** One drawn line of a hunk with its line numbers on each side. */ export interface DiffRow { @@ -266,7 +266,7 @@ export function ReviewTab({ } - {file !== undefined && renderSlot('deliverables.file.actions', { + {file !== undefined && renderSlot('deliverables.review.file.actions', { actionUrl: changedFileUrl(sessionId, seq, index), available: native, pending: phase === 'opening' || phase === 'revealing', onAction: (action, application) => openChanged(sessionId, seq, index, action, application), diff --git a/packages/client/ui-deliverables/src/client/file-actions.ts b/packages/client/ui-deliverables/src/client/file-actions.ts index b39ab16a41..e4f9dedde8 100644 --- a/packages/client/ui-deliverables/src/client/file-actions.ts +++ b/packages/client/ui-deliverables/src/client/file-actions.ts @@ -17,5 +17,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { readonly onAction: (action: PresentedAction, application?: string) => Promise } } + /** The same file actions owned by a changed-file review tab. */ + 'deliverables.review.file.actions': SlotMap['deliverables.file.actions'] } } diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index da1e38c5e6..f000547756 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -86,7 +86,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( { name: 'sidebar.right.pane.tab', key: CHANGES_REVIEW_ID, locale: NS, store: createReviewStore(), - children: { 'deliverables.file.actions': { kind: 'list', scope: 'session' } }, + children: { 'deliverables.review.file.actions': { kind: 'list', scope: 'session' } }, inject: (): ReviewInjected => ({ hooks: { changesSummary: summaries.state, changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host }, loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq), diff --git a/packages/client/ui-deliverables/tests/file-actions.tsx b/packages/client/ui-deliverables/tests/file-actions.tsx index 964b7b88b1..7d857bf6fc 100644 --- a/packages/client/ui-deliverables/tests/file-actions.tsx +++ b/packages/client/ui-deliverables/tests/file-actions.tsx @@ -3,6 +3,6 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '../src/client/index.ts' /** Slot renderer exposing the owning route and gesture for component tests. */ -export const renderFileActions: PropsRenderSlots<'deliverables.file.actions'>['renderSlot'] = (_name, props) => +export const renderFileActions: PropsRenderSlots<'deliverables.file.actions' | 'deliverables.review.file.actions'>['renderSlot'] = (_name, props) => props.available ? : null diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index b4e80a0b0e..b313ee0aa8 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -63,7 +63,7 @@ describe('Presented workspace file native open route', () => { const { cwd, open, file, fiber, opener, handler, ctx } = await fixture() const source = await realpath(join(cwd, file.path)) expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe('api/present.open?sessionId=owner&seq=7&index=0') - expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(404) + expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(400) expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404) for (const contents of ['current source', 'edited source']) { await writeFile(source, contents) diff --git a/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts b/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts index 6bd7af1bc5..9d9bb1cbc4 100644 --- a/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts +++ b/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts @@ -18,7 +18,6 @@ describe('deliverables layout', () => { expect(read('PresentedFileCard.tsx')).toMatch(//) expect(deliveries).toMatch(/\.fileName\s*\{[^}]*font-size:\s*13px;[^}]*line-height:\s*20px/s) expect(deliveries).toMatch(/\.description\s*\{[^}]*font-size:\s*10px;[^}]*line-height:\s*16px/s) - expect(deliveries).toMatch(/\.open\s*\{[^}]*padding:\s*4px 8px;[^}]*font-size:\s*12px;[^}]*line-height:\s*18px/s) expect(deliveries).toMatch(/\.presented\s*\{[^}]*gap:\s*10px/s) }) diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index e80e58189a..c92aff20f5 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -87,4 +87,7 @@ export function apply(ctx: ClientContext): void { ctx.slots.inject('deliverables.file.actions', () => ctx.slots.register({ name: 'deliverables.file.actions', id: 'open-in-app', locale: NS, }, FileRouteAction)) + ctx.slots.inject('deliverables.review.file.actions', () => ctx.slots.register({ + name: 'deliverables.review.file.actions', id: 'open-in-app', locale: NS, + }, FileRouteAction)) } From 59fdc2604d083008ad9935c9213b901865f0939f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 13:56:09 +0800 Subject: [PATCH 122/284] test: provide the file action slot runtime in delivery fixtures --- .../ui-deliverables/tests/deliverables.client.spec.tsx | 1 + packages/client/ui-deliverables/tests/file-actions.tsx | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index 65ead43368..ebc34d72e7 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -42,6 +42,7 @@ function openProps(controller = new PresentedOpenController(), summaries = new C controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, jobsBySession: {} } return { + SessionProvider: ({ children }: { children?: import('react').ReactNode }) => <>{children}, renderSlot: renderFileActions, useShowCodeDiff: (select: (value: boolean) => T): T => select(true), useSessions: (select: (state: SessionListState) => T): T => select(sessions), diff --git a/packages/client/ui-deliverables/tests/file-actions.tsx b/packages/client/ui-deliverables/tests/file-actions.tsx index 7d857bf6fc..55e29618d6 100644 --- a/packages/client/ui-deliverables/tests/file-actions.tsx +++ b/packages/client/ui-deliverables/tests/file-actions.tsx @@ -1,8 +1,12 @@ /** Capture delivery action ownership without duplicating the opening-control implementation. */ -import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '../src/client/index.ts' /** Slot renderer exposing the owning route and gesture for component tests. */ -export const renderFileActions: PropsRenderSlots<'deliverables.file.actions' | 'deliverables.review.file.actions'>['renderSlot'] = (_name, props) => - props.available ? : null +} From 28089001a27d912c428287be7369a933344eaca0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 13:59:00 +0800 Subject: [PATCH 123/284] fix: complete file action slot contracts and branch coverage --- .../src/client/file-actions.ts | 6 +- .../tests/changes-open.host.spec.ts | 7 +++ .../ui-deliverables/tests/file-actions.tsx | 8 +-- .../tests/present-open.client.spec.ts | 10 ++++ .../src/client/file-applications.ts | 2 +- .../tests/browser-plugin.client.spec.ts | 1 + .../src/client/slot-catalog.ts | 60 +++++++++++++++++++ 7 files changed, 87 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-deliverables/src/client/file-actions.ts b/packages/client/ui-deliverables/src/client/file-actions.ts index e4f9dedde8..1326f80b76 100644 --- a/packages/client/ui-deliverables/src/client/file-actions.ts +++ b/packages/client/ui-deliverables/src/client/file-actions.ts @@ -18,6 +18,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } /** The same file actions owned by a changed-file review tab. */ - 'deliverables.review.file.actions': SlotMap['deliverables.file.actions'] + 'deliverables.review.file.actions': { + kind: 'list' + scope: 'session' + owner: SlotMap['deliverables.file.actions']['owner'] + } } } diff --git a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts index 443287ed97..6fda6fed4a 100644 --- a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts @@ -216,3 +216,10 @@ it('queries handlers only after file authorization and forwards explicit applica expect((await handler.fetch(new Request(url))).status).toBe(422) expect(applications).toHaveBeenCalledOnce() }) + + +it('rejects an unsupported changed-file action before invoking the desktop', async () => { + const { open, opener } = await fixture() + expect((await open('?sessionId=owner&seq=9&index=0&action=remove')).status).toBe(400) + expect(opener).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-deliverables/tests/file-actions.tsx b/packages/client/ui-deliverables/tests/file-actions.tsx index 55e29618d6..5efa0ec4db 100644 --- a/packages/client/ui-deliverables/tests/file-actions.tsx +++ b/packages/client/ui-deliverables/tests/file-actions.tsx @@ -1,12 +1,10 @@ /** Capture delivery action ownership without duplicating the opening-control implementation. */ -import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsRenderSlots, SlotMap } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '../src/client/index.ts' /** Slot renderer exposing the owning route and gesture for component tests. */ -export function renderFileActions( - _name: 'deliverables.file.actions' | 'deliverables.review.file.actions', - props: SlotMap['deliverables.file.actions']['owner'], -) { +export const renderFileActions: PropsRenderSlots<'deliverables.file.actions' | 'deliverables.review.file.actions'>['renderSlot'] = (_name, input) => { + const props = input as SlotMap['deliverables.file.actions']['owner'] return props.available ? : null } diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index 35fd278616..6d6d4c4760 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -174,3 +174,13 @@ it.each(['open', 'reveal'] as const)('reports an unavailable Host path for %s wh expect(controller.state.getSnapshot()[url]).toBe('nativeUnavailable') await controller.dispose() }) + + +it('encodes an explicit application identifier without changing the file coordinates', async () => { + const fetcher = vi.fn(async () => new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + expect(await controller.open(id, 2, 1, 'open', '/Apps/A&B.app')).toBeNull() + expect(fetcher).toHaveBeenCalledWith(`${url}&application=%2FApps%2FA%26B.app`, { method: 'POST', signal: expect.any(AbortSignal) }) + await controller.dispose() +}) diff --git a/packages/client/ui-open-in-app/src/client/file-applications.ts b/packages/client/ui-open-in-app/src/client/file-applications.ts index 00bb9331f2..b4f27e3340 100644 --- a/packages/client/ui-open-in-app/src/client/file-applications.ts +++ b/packages/client/ui-open-in-app/src/client/file-applications.ts @@ -13,7 +13,7 @@ export function useFileApplications( target: string, query: (target: string, signal: AbortSignal) => Promise, enabled: boolean, -) { +): { apps: readonly SessionWorkspacePathApplication[]; loading: boolean; failed: boolean; refresh: () => void } { const [revision, setRevision] = useState(0) const [association, setAssociation] = useState<{ target: string diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index e538dc4fd2..43ccf7df46 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -41,6 +41,7 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null) ctx.provide('sessions', {}) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 08f13fbf40..7d50d6f71a 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1623,6 +1623,66 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'deliverables.file.actions\', () => ctx.slots.register(\n { name: \'deliverables.file.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-deliverables/src/client/file-actions.ts:8', }, + { + key: 'deliverables.review.file.actions', + kind: 'list', + scope: 'session', + summary: 'The same file actions owned by a changed-file review tab.', + doc: 'The same file actions owned by a changed-file review tab.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */\nexport interface SlotMap {}', + ], + ownerPropsReferences: [ + 'SlotEntryDef', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-deliverables), so it exists while that entry is mounted', + occupants: [ + 'client-ui-open-in-app FileRouteAction id \'open-in-app\'', + ], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'deliverables.review.file.actions\', () => ctx.slots.register(\n { name: \'deliverables.review.file.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-deliverables/src/client/file-actions.ts:21', + }, { key: 'main', kind: 'keyed', From 1f7e4915262008b3bc5fcd2970403157c9343975 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 14:00:41 +0800 Subject: [PATCH 124/284] docs: describe shared delivery and review file actions --- packages/client/ui-deliverables/README.i18n.yaml | 4 ++-- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- packages/client/ui-deliverables/tests/file-actions.tsx | 2 +- packages/client/ui-open-in-app/README.i18n.yaml | 4 ++-- packages/client/ui-open-in-app/README.md | 4 ++-- packages/client/ui-open-in-app/README.zh.md | 4 ++-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 8f8941ac01..85892e110d 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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/ui-deliverables/README.md -README.md: 9073659f5b6aeb11112aaa1c4e7834bf055944e7 -README.zh.md: 7a622b6f0bcf4bc75027542cc90fe6c2e2b86471 +README.md: a8e80ede3380cef7e0550a0a63aa195642ed4e12 +README.zh.md: 929ab7f93f6ff995586bd850b655eb2e7a31ac93 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 9073659f5b..a8e80ede33 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -32,7 +32,7 @@ Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](. The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/tool-present/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Duplicate declarations use the last description before the closing reply. -The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. +The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. ### The changed-files card diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 7a622b6f0b..929ab7f93f 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -32,7 +32,7 @@ kind: "package-reference" Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。重复声明使用收尾回复之前最近一次的说明。 -`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 +`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 ### 改动文件卡片 diff --git a/packages/client/ui-deliverables/tests/file-actions.tsx b/packages/client/ui-deliverables/tests/file-actions.tsx index 5efa0ec4db..d1904bea6a 100644 --- a/packages/client/ui-deliverables/tests/file-actions.tsx +++ b/packages/client/ui-deliverables/tests/file-actions.tsx @@ -3,7 +3,7 @@ import type { PropsRenderSlots, SlotMap } from '@deepseek-ai/dsh-client-ui-slots import type {} from '../src/client/index.ts' /** Slot renderer exposing the owning route and gesture for component tests. */ -export const renderFileActions: PropsRenderSlots<'deliverables.file.actions' | 'deliverables.review.file.actions'>['renderSlot'] = (_name, input) => { +export const renderFileActions: PropsRenderSlots<'deliverables.file.actions' | 'deliverables.review.file.actions'>['renderSlot'] = (_name: string, input: object) => { const props = input as SlotMap['deliverables.file.actions']['owner'] return props.available ? : null diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index 9f87aff18b..977cb96683 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -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/ui-open-in-app/README.md -README.md: 010f86f19297db33511b40129d6caf36c71e49f9 -README.zh.md: ec0fe7741356c71b73f4d5de6f5dc0bb1b3be887 +README.md: 7840efdf1bb092cd538865f2b9b74b0911cfb06f +README.zh.md: 0774e5228551b8463de129f49be2ac30f05f1273 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index 010f86f192..7840efdf1b 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -45,7 +45,7 @@ File associations refresh when the file changes or its menu opens; cancelled que The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. -The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, sizing, and gesture feedback. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation. +The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, sizing, and gesture feedback. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation. @@ -92,4 +92,4 @@ The feature-level decisions, including the split into the host package and this -**Runtime invariant:** No companion is published. The plugin registers one dictionary effect and three slot entries whose disposal the HMR-safety spec proves; application availability, the choice, and the desktop answer live in the controllers' snapshot stores with no second copy to diverge. +**Runtime invariant:** No companion is published. The plugin registers one dictionary effect and five slot entries whose disposal the HMR-safety spec proves; application availability, the choice, and the desktop answer live in the controllers' snapshot stores with no second copy to diverge. diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index ec0fe77413..0774e52285 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" 插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。 -目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标、尺寸和操作反馈。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 +目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标、尺寸和操作反馈。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 @@ -92,4 +92,4 @@ Host 在打开或定位前通过当前文件系统验证路径。没有对应 Ho -**运行时不变式:** 不发布伴生入口。插件注册一个词典 effect 和三个 slot 条目,HMR 安全性 spec 证明它们都会在资源释放时撤销;应用可用性、选择与桌面回答存储在控制器的快照存储中,不存在可能与之分歧的第二份副本。 +**运行时不变式:** 不发布伴生入口。插件注册一个词典 effect 和五个 slot 条目,HMR 安全性 spec 证明它们都会在资源释放时撤销;应用可用性、选择与桌面回答存储在控制器的快照存储中,不存在可能与之分歧的第二份副本。 From 03fde971e2c4c16de9617861192875d2985a28f9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 14:02:18 +0800 Subject: [PATCH 125/284] build: allow browser-safe file association metadata validation --- packages/client/tsdown.client.ts | 2 +- scripts/client-bundle-purity.spec.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index b8468c0957..5e9592ba70 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -61,7 +61,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index ed3ca6a0a8..cf706b334c 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -141,6 +141,8 @@ describe('client bundle purity gate', () => { expect(() => resolveId('@deepseek-ai/dsh-token-meter')).toThrow(/purity/) expect(() => resolveId('@deepseek-ai/dsh-token-meter/client/internal')).toThrow(/purity/) expect(resolveId('@deepseek-ai/dsh-host-open-in-app/shared')).toBeNull() + expect(resolveId('@deepseek-ai/dsh-native-command/types')).toBeNull() + expect(() => resolveId('@deepseek-ai/dsh-native-command')).toThrow('client bundle purity') expect(() => resolveId('@deepseek-ai/dsh-host-open-in-app')).toThrow(/purity/) expect(resolveId('@deepseek-ai/dsh-plugin-manager/registry')).toBeNull() expect(() => resolveId('@deepseek-ai/dsh-plugin-manager')).toThrow(/purity/) From 6f1857ae1bd58480f447d2101190ffaee3099d4e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 14:04:54 +0800 Subject: [PATCH 126/284] test: refresh delivery snapshots for shared file controls --- apps/web/tests/present.e2e.ts | 2 +- .../tests/present-open.client.spec.ts | 2 +- .../tests/file-route-action.client.spec.tsx | 2 +- snapshots/web/present-svg/session.v4.jsonl | 29 +++++++++++++++++++ snapshots/web/present-svg/ui.expected.md | 2 -- snapshots/web/present/session.v4.jsonl | 29 +++++++++++++++++++ 6 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 snapshots/web/present-svg/session.v4.jsonl create mode 100644 snapshots/web/present/session.v4.jsonl diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 0fa592a09a..125c9f6ebe 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -128,7 +128,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con } const row = page.locator('[data-presented-files-row]') await row.waitFor() - expect(await row.getByRole('button', { name: 'More ways to open' }).count()).toBe(2) + await expect.poll(() => row.getByRole('button', { name: 'More ways to open' }).count()).toBe(2) expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt') const beforePreview = (await opened()).length const column = page.locator('[data-rightbar-col]') diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index 6d6d4c4760..468e065ad4 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -181,6 +181,6 @@ it('encodes an explicit application identifier without changing the file coordin vi.stubGlobal('fetch', fetcher) const controller = new PresentedOpenController() expect(await controller.open(id, 2, 1, 'open', '/Apps/A&B.app')).toBeNull() - expect(fetcher).toHaveBeenCalledWith(`${url}&application=%2FApps%2FA%26B.app`, { method: 'POST', signal: expect.any(AbortSignal) }) + expect(fetcher).toHaveBeenCalledWith(`${url}&application=%2FApps%2FA%26B.app`, { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) await controller.dispose() }) diff --git a/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx index 6404ec8b9e..d67cf7127c 100644 --- a/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx @@ -17,7 +17,7 @@ it('queries the authorized route, selects a handler, and keeps reveal last', asy const p = props() render() await act(async () => {}) - expect(fetcher).toHaveBeenCalledWith(p.actionUrl, { signal: expect.any(AbortSignal) }) + expect(fetcher).toHaveBeenCalledWith(p.actionUrl, { signal: expect.any(AbortSignal) as AbortSignal }) fireEvent.mouseEnter(screen.getByRole('button', { name: 'Open in Music' })) expect(await screen.findByRole('tooltip')).toHaveProperty('textContent', 'Open in Music') await act(async () => { fireEvent.click(screen.getByRole('button', { name: en['path.more'] })) }) diff --git a/snapshots/web/present-svg/session.v4.jsonl b/snapshots/web/present-svg/session.v4.jsonl new file mode 100644 index 0000000000..d844e49889 --- /dev/null +++ b/snapshots/web/present-svg/session.v4.jsonl @@ -0,0 +1,29 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1788926678903,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"standard"} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg"}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg"}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} +{"type":"session/title","data":{"title":"简单画一个 SVG 表示冯诺依曼","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses.\n\nLet me write the file."},{"type":"text","text":"我来创建一个冯诺依曼架构的 SVG 示意图。"},{"type":"tool-call","id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","arguments":"{\"file_path\": \"{{cwd}}/von-neumann.svg\", \"content\": \"\\n 冯诺依曼架构\\n\\n \\n \\n \\n \\n \\n\\n \\n \\n 冯诺依曼体系结构 (Von Neumann Architecture)\\n\\n \\n \\n 中央处理器 CPU\\n\\n \\n \\n 控制单元\\n CU\\n 指令译码 · 时序控制\\n\\n \\n \\n 算术逻辑单元\\n ALU\\n 算术运算 · 逻辑运算\\n\\n \\n \\n 寄存器组\\n Registers / 缓存\\n\\n \\n \\n 内存储器 Memory\\n 存储单元: 指令和数据\\n (存放程序与数据,同一总线存取)\\n\\n \\n \\n 输入设备\\n Input\\n 键盘 · 鼠标 · 磁盘\\n\\n \\n \\n 输出设备\\n Output\\n 显示器 · 打印机\\n\\n \\n \\n \\n 地址总线\\n\\n \\n \\n ← 数据总线 / 控制总线 →\\n\\n \\n \\n \\n 数据(输入)\\n 控制\\n\\n \\n \\n \\n 数据(输出)\\n 控制\\n\\n \\n \\n 总线\\n\\n \\n 总线\\n\\n \\n 核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”\\n\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":7197,"outputTokens":2153,"totalTokens":10502,"cacheReadTokens":1152,"reasoningTokens":55},"stream":[{"type":"chunk","time":1789457782571,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457782571,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," a"," simple"," SVG"," diagram"," of"," the"," von"," Neumann"," architecture",","," saved"," as"," von","-","neum","ann",".svg","."," Let"," me"," create"," a"," clean"," SVG"," showing"," CPU"," (","control"," unit",","," AL","U","),"," memory",","," input",","," and"," output"," with"," bidirectional"," data","/","control"," buses",".\n\n","Let"," me"," write"," the"," file","."]},{"type":"chunk","time":1789457782572,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789457782572,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0],"texts":["我来","创建一个","冯","诺","依","曼","架构","的"," SVG"," ","示意图","。"]},{"type":"chunk","time":1789457782572,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789457782572,"index":2,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","args":["","{","\"","file","_path","\"",": ","\"","/","private","/","var","/f","old","ers","/n","5","/f","339","h","wh","15","_g","f","4","gk","8","ns","rt","9","qm","400","00","gn","/T","/d","sh","-web","-e","2","e","-w","s","-g","Ou","Z","JC","/","works","pace","/v","on","-","neum","ann",".svg","\"",", ","\"","content","\"",": ","\"","<","svg"," xmlns","=\\\"","http","://","www",".w","3",".org","/","200","0","/s","vg","\\\""," view","Box","=\\\"","0"," ","0"," ","900"," ","560","\\\""," width","=\\\"","900","\\\""," height","=\\\"","560","\\\""," font","-family","=\\\"","Hel","vetica",","," A","rial",","," sans","-serif","\\\">\\n"," "," <","title",">","冯","诺","依","曼","架构","\\n\\n"," "," <","def","s",">\\n"," "," <","mark","er"," id","=\\\"","arrow","\\\""," marker","Width","=\\\"","10","\\\""," marker","Height","=\\\"","10","\\\""," ref","X","=\\\"","8","\\\""," ref","Y","=\\\"","3","\\\""," orient","=\\\"","auto","\\\">\\n"," "," <","path"," d","=\\\"","M","0",",","0"," L","8",",","3"," L","0",",","6"," Z","\\\""," fill","=\\\"#","555","\\\"/>\\n"," "," \\n"," "," \\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","0","\\\""," y","=\\\"","0","\\\""," width","=\\\"","900","\\\""," height","=\\\"","560","\\\""," fill","=\\\"#","f","af","bf","c","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","34","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","22","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","222","\\\">","冯","诺","依","曼","体系","结构"," (","Von"," Neumann"," Architecture",")\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","300","\\\""," y","=\\\"","70","\\\""," width","=\\\"","300","\\\""," height","=\\\"","240","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","1","f","6","f","eb","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","98","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","17","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","1","f","6","f","eb","\\\">","中央","处理器"," CPU","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","325","\\\""," y","=\\\"","118","\\\""," width","=\\\"","120","\\\""," height","=\\\"","160","\\\""," rx","=\\\"","8","\\\""," fill","=\\\"#","d","df","4","ff","\\\""," stroke","=\\\"#","54","ae","ff","\\\""," stroke","-width","=\\\"","1",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","142","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12",".","5","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","096","9","da","\\\">","控制","单元","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","158","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","0","b","539","4","\\\">","CU","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","262","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","指令","译","码"," ·"," ","时序","控制","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","455","\\\""," y","=\\\"","118","\\\""," width","=\\\"","120","\\\""," height","=\\\"","160","\\\""," rx","=\\\"","8","\\\""," fill","=\\\"#","fff","8","c","5","\\\""," stroke","=\\\"#","d","4","a","72","c","\\\""," stroke","-width","=\\\"","1",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","142","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12",".","5","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","9","a","670","0","\\\">","算术","逻辑","单元","\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","158","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","7","a","520","0","\\\">","ALU","\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","262","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","算术","运算"," ·"," ","逻辑","运算","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","330","\\\""," y","=\\\"","186","\\\""," width","=\\\"","110","\\\""," height","=\\\"","56","\\\""," rx","=\\\"","6","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","2","\\\""," stroke","-d","ash","array","=\\\"","4",",","3","\\\"/>\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","208","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","寄存器","组","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","224","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","Reg","isters"," /"," ","缓存","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","300","\\\""," y","=\\\"","400","\\\""," width","=\\\"","300","\\\""," height","=\\\"","110","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","cf","222","e","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","428","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","17","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","cf","222","e","\\\">","内","存储器"," Memory","\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","452","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12","\\\""," fill","=\\\"#","444","\\\">","存储","单元",":"," ","指令","和数据","\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","470","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","666","\\\">","(","存放","程序","与","数据",",","同一","总线","存取",")\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","40","\\\""," y","=\\\"","150","\\\""," width","=\\\"","170","\\\""," height","=\\\"","120","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","1","a","7","f","37","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","186","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","15","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","1","a","7","f","37","\\\">","输入","设备","\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","206","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","Input","\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","238","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","键盘"," ·"," ","鼠标"," ·"," ","磁盘","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","690","\\\""," y","=\\\"","150","\\\""," width","=\\\"","170","\\\""," height","=\\\"","120","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","bf","398","9","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","186","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","15","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","bf","398","9","\\\">","输出","设备","\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","206","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","Output","\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","238","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","显示器"," ·"," ","打印机","\\n\\n"," "," \\n"," "," \\n"," "," <","path"," d","=\\\"","M","450",",","320"," L","450",",","398","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","cf","222","e","\\\""," stroke","-width","=\\\"","3","\\\"/>\\n"," "," <","text"," x","=\\\"","420","\\\""," y","=\\\"","362","\\\""," text","-an","chor","=\\\"","end","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","cf","222","e","\\\""," transform","=\\\"","rotate","(-","90"," ","420"," ","362",")","\\\">","地址","总线","\\n\\n"," "," \\n"," "," <","line"," x","1","=\\\"","300","\\\""," y","1","=\\\"","355","\\\""," x","2","=\\\"","600","\\\""," y","2","=\\\"","355","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","1","\\\""," stroke","-d","ash","array","=\\\"","2",",","3","\\\""," opacity","=\\\"","0",".","0","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","353","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","555","\\\">","←"," ","数据","总线"," /"," ","控制","总线"," →","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","212",",","210"," L","298",",","210","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","1","a","7","f","37","\\\""," stroke","-width","=\\\"","3","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","path"," d","=\\\"","M","212",",","196"," L","298",",","196","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","2","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","255","\\\""," y","=\\\"","184","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10",".","5","\\\""," fill","=\\\"#","1","a","7","f","37","\\\">","数据","(","输入",")\\n"," "," <","text"," x","=\\\"","255","\\\""," y","=\\\"","236","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","控制","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","602",",","210"," L","688",",","210","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","bf","398","9","\\\""," stroke","-width","=\\\"","3","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","path"," d","=\\\"","M","602",",","196"," L","688",",","196","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","2","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","645","\\\""," y","=\\\"","184","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10",".","5","\\\""," fill","=\\\"#","bf","398","9","\\\">","数据","(","输出",")\\n"," "," <","text"," x","=\\\"","645","\\\""," y","=\\\"","236","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","控制","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","125",",","272"," L","125",",","300"," Q","125",",","340"," ","175",",","340"," L","298",",","340","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","8","\\\""," stroke","-d","ash","array","=\\\"","6",",","4","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","160","\\\""," y","=\\\"","328","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\""," transform","=\\\"","rotate","(-","8"," ","160"," ","328",")","\\\">","总线","\\n\\n"," "," <","path"," d","=\\\"","M","775",",","272"," L","775",",","300"," Q","775",",","340"," ","725",",","340"," L","602",",","340","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","8","\\\""," stroke","-d","ash","array","=\\\"","6",",","4","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","740","\\\""," y","=\\\"","328","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\""," transform","=\\\"","rotate","(","8"," ","740"," ","328",")","\\\">","总线","\\n\\n"," "," \\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","530","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11",".","5","\\\""," fill","=\\\"#","666","\\\">","核心","思想",":"," ","指令","与","数据","以","二进制","形式","统一","存放在","存储器","中"," ——"," “","存储","程序","”","\\n","\\n","\"","}"]},{"type":"chunk","time":1789457782585,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses.\n\nLet me write the file."}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"我来创建一个冯诺依曼架构的 SVG 示意图。"}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","arguments":"{\"file_path\": \"{{cwd}}/von-neumann.svg\", \"content\": \"\\n 冯诺依曼架构\\n\\n \\n \\n \\n \\n \\n\\n \\n \\n 冯诺依曼体系结构 (Von Neumann Architecture)\\n\\n \\n \\n 中央处理器 CPU\\n\\n \\n \\n 控制单元\\n CU\\n 指令译码 · 时序控制\\n\\n \\n \\n 算术逻辑单元\\n ALU\\n 算术运算 · 逻辑运算\\n\\n \\n \\n 寄存器组\\n Registers / 缓存\\n\\n \\n \\n 内存储器 Memory\\n 存储单元: 指令和数据\\n (存放程序与数据,同一总线存取)\\n\\n \\n \\n 输入设备\\n Input\\n 键盘 · 鼠标 · 磁盘\\n\\n \\n \\n 输出设备\\n Output\\n 显示器 · 打印机\\n\\n \\n \\n \\n 地址总线\\n\\n \\n \\n ← 数据总线 / 控制总线 →\\n\\n \\n \\n \\n 数据(输入)\\n 控制\\n\\n \\n \\n \\n 数据(输出)\\n 控制\\n\\n \\n \\n 总线\\n\\n \\n 总线\\n\\n \\n 核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”\\n\\n\"}"}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"usage","usage":{"inputTokens":7197,"outputTokens":2153,"totalTokens":10502,"cacheReadTokens":1152,"reasoningTokens":55}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","arguments":"{\"file_path\": \"{{cwd}}/von-neumann.svg\", \"content\": \"\\n 冯诺依曼架构\\n\\n \\n \\n \\n \\n \\n\\n \\n \\n 冯诺依曼体系结构 (Von Neumann Architecture)\\n\\n \\n \\n 中央处理器 CPU\\n\\n \\n \\n 控制单元\\n CU\\n 指令译码 · 时序控制\\n\\n \\n \\n 算术逻辑单元\\n ALU\\n 算术运算 · 逻辑运算\\n\\n \\n \\n 寄存器组\\n Registers / 缓存\\n\\n \\n \\n 内存储器 Memory\\n 存储单元: 指令和数据\\n (存放程序与数据,同一总线存取)\\n\\n \\n \\n 输入设备\\n Input\\n 键盘 · 鼠标 · 磁盘\\n\\n \\n \\n 输出设备\\n Output\\n 显示器 · 打印机\\n\\n \\n \\n \\n 地址总线\\n\\n \\n \\n ← 数据总线 / 控制总线 →\\n\\n \\n \\n \\n 数据(输入)\\n 控制\\n\\n \\n \\n \\n 数据(输出)\\n 控制\\n\\n \\n \\n 总线\\n\\n \\n 总线\\n\\n \\n 核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”\\n\\n\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_yxav8LdZ9iwWpgkUnspF6361"},"toolCallId":"call_00_yxav8LdZ9iwWpgkUnspF6361","content":[{"type":"text","text":"{{cwd}}/von-neumann.svg\nfile\n\nCreated file\n"}],"isError":false,"id":"{{message:5}}"},"meta":{"operation":"create","diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file is created. Let me present it as a deliverable."},{"type":"tool-call","id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","arguments":"{\"files\": [{\"path\": \"von-neumann.svg\", \"description\": \"冯诺依曼架构示意图 SVG\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":85,"outputTokens":80,"totalTokens":10661,"cacheReadTokens":10496,"reasoningTokens":14},"stream":[{"type":"chunk","time":1789457782632,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457782632,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," is"," created","."," Let"," me"," present"," it"," as"," a"," deliver","able","."]},{"type":"chunk","time":1789457782632,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789457782632,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","args":["","{","\"","files","\"",": ","[","{\"","path","\":"," \"","von","-","neum","ann",".svg","\","," \"","description","\":"," \"","冯","诺","依","曼","架构","示意图"," SVG","\"","}]","}"]},{"type":"chunk","time":1789457782632,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file is created. Let me present it as a deliverable."}}},{"type":"chunk","time":1789457782632,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","arguments":"{\"files\": [{\"path\": \"von-neumann.svg\", \"description\": \"冯诺依曼架构示意图 SVG\"}]}"}}},{"type":"chunk","time":1789457782632,"chunk":{"type":"usage","usage":{"inputTokens":85,"outputTokens":80,"totalTokens":10661,"cacheReadTokens":10496,"reasoningTokens":14}}},{"type":"chunk","time":1789457782632,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","arguments":"{\"files\": [{\"path\": \"von-neumann.svg\", \"description\": \"冯诺依曼架构示意图 SVG\"}]}"}} +{"type":"deliverables/presented","data":{"turn":1,"callId":"call_00_otuUentgajQb2RkFVoKT3699","files":[{"path":"von-neumann.svg","description":"冯诺依曼架构示意图 SVG"}]}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_otuUentgajQb2RkFVoKT3699"},"toolCallId":"call_00_otuUentgajQb2RkFVoKT3699","content":[{"type":"text","text":"Presented von-neumann.svg"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"step/start","data":{"turn":1,"step":3}} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"已创建 `von-neumann.svg`,一个简洁的冯诺依曼架构示意图,包含:\n\n- **CPU(中央处理器)**:内含控制单元 CU、算术逻辑单元 ALU 和寄存器组\n- **内存储器 Memory**:标注\"指令和数据统一存放\"(存储程序思想)\n- **输入设备 / 输出设备**:分别用绿色和粉色表示数据流向\n- **总线连接**:CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向\n\n下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":56,"outputTokens":145,"totalTokens":10825,"cacheReadTokens":10624,"reasoningTokens":0},"stream":[{"type":"chunk","time":1789457782649,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1789457782649,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["已","创建"," `","von","-","neum","ann",".svg","`",",","一个","简洁","的","冯","诺","依","曼","架构","示意图",",","包含",":\n\n","-"," **","CPU","(","中央","处理器",")","**",":","内含","控制","单元"," CU","、","算术","逻辑","单元"," AL","U"," ","和","寄存器","组","\n","-"," **","内","存储器"," Memory","**",":","标注","\"","指令","和数据","统一","存放","\"","(","存储","程序","思想",")\n","-"," **","输入","设备"," /"," ","输出","设备","**",":","分别","用","绿色","和","粉色","表示","数据","流向","\n","-"," **","总线","连接","**",":","CPU"," ","与","内存","间的","地址","总线",",","以及"," CPU"," ","与"," I","/O"," ","设备","间的","数据","/","控制","线",",","箭头","标明","数据","流向","\n\n","下方","附","一行","核心","思想","说明",":","指令","与","数据","以","二进制","统一","存储在","存储器","中","。","用","浏览器","打开","该"," SVG"," ","文件","即可","查看","。"]},{"type":"chunk","time":1789457782650,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"已创建 `von-neumann.svg`,一个简洁的冯诺依曼架构示意图,包含:\n\n- **CPU(中央处理器)**:内含控制单元 CU、算术逻辑单元 ALU 和寄存器组\n- **内存储器 Memory**:标注\"指令和数据统一存放\"(存储程序思想)\n- **输入设备 / 输出设备**:分别用绿色和粉色表示数据流向\n- **总线连接**:CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向\n\n下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。"}}},{"type":"chunk","time":1789457782650,"chunk":{"type":"usage","usage":{"inputTokens":56,"outputTokens":145,"totalTokens":10825,"cacheReadTokens":10624,"reasoningTokens":0}}},{"type":"chunk","time":1789457782650,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":3}} +{"type":"workspace/changes","data":{"turn":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index c867d85c45..47b47909c6 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -37,8 +37,6 @@ - text: 此主机没有可用的桌面,无法使用外部程序打开文件或文件夹;文件仍可在侧边栏预览 - button "在侧边栏预览 von-neumann.svg" - text: von-neumann.svg 冯诺依曼架构示意图 SVG -- button "在侧边栏打开 von-neumann.svg": 打开 -- button "von-neumann.svg 的更多文件操作" [disabled] - button "复制" - button "好的回答" - button "有问题的回答" diff --git a/snapshots/web/present/session.v4.jsonl b/snapshots/web/present/session.v4.jsonl new file mode 100644 index 0000000000..442cd7b022 --- /dev/null +++ b/snapshots/web/present/session.v4.jsonl @@ -0,0 +1,29 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1788883598275,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"ptc"} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. Use bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`. Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program. Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. Use bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`. Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program. Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} +{"type":"session/title","data":{"title":"Use one run_code program to","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants one run_code program that:\n1. Calls present for missing.txt and catches its error without creating that file.\n2. Uses bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`.\n3. Calls present for report.txt and 说明.txt.\n4. After present succeeds, deliberately throws the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program.\n5. Does not retry the program or create other files.\n6. Finishes by mentioning report.txt and 说明.txt in inline code, and put PRESENT_DONE in a separate paragraph.\n\nLet me write the program.\n\npresent tool args: { files: [{ path, description? }] }.\n\nSo first: try present for missing.txt — catch error. It should error because the file doesn't exist (present requires existing workspace file).\n\nThen bash run exact command. Note the command uses `;` inside a single string. Run as a single command string: `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`.\n\nWait — printf interprets the arguments... In bash, `printf \"DELIVERED_REPORT\\n\"` with the format string containing \\n — printf will interpret \\n as newline. Good.\n\nThen present for report.txt and 说明.txt.\n\nThen throw \"AFTER_PRESENT\" string. In TS async function body, `throw \"AFTER_PRESENT\"` works.\n\nLet me write it."},{"type":"tool-call","id":"call_00_GgvYPol45qxGf6GezK3L3590","name":"run_code","arguments":"{\"code\": \"// Step 1: present for missing.txt — must fail, catch the error, don't create the file\\ntry {\\n await tools.present({ files: [{ path: \\\"missing.txt\\\", description: \\\"must not exist\\\" }] });\\n console.log(\\\"UNEXPECTED: present succeeded for missing.txt\\\");\\n} catch (err) {\\n console.log(\\\"Caught present error for missing.txt:\\\", typeof err, String(err).slice(0, 200));\\n}\\n\\n// Step 2: run the exact bash command to create the two report files\\nconst res = await tools.bash({\\n command: 'printf \\\"DELIVERED_REPORT\\\\n\\\" > report.txt; printf \\\"DELIVERED_NOTE\\\\n\\\" > 说明.txt',\\n description: \\\"Write DELIVERED_REPORT and DELIVERED_NOTE to files\\\",\\n});\\nconsole.log(\\\"bash exit:\\\", res.exitCode);\\n\\n// Step 3: present for report.txt and 说明.txt (must succeed)\\nconst p2 = await tools.present({\\n files: [\\n { path: \\\"report.txt\\\", description: \\\"delivered report\\\" },\\n { path: \\\"说明.txt\\\", description: \\\"delivered note\\\" },\\n ],\\n});\\nconsole.log(\\\"present ok, files:\\\", p2.files.map((f) => f.path).join(\\\", \\\"));\\n\\n// Step 4: deliberately throw a plain string AFTER_PRESENT (not an Error)\\nthrow \\\"AFTER_PRESENT\\\";\", \"description\": \"Present files then deliberately throw AFTER_PRESENT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":981,"outputTokens":695,"totalTokens":9356,"cacheReadTokens":7680,"reasoningTokens":329},"stream":[{"type":"chunk","time":1788883599120,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788883599120,"index":0,"dt":[0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," one"," run","_code"," program"," that",":\n","1","."," Calls"," present"," for"," missing",".txt"," and"," catches"," its"," error"," without"," creating"," that"," file",".\n","2","."," Uses"," bash"," to"," run"," exactly"," `","printf"," \"","DEL","IVER","ED","_RE","PORT","\\n","\""," >"," report",".txt",";"," printf"," \"","DEL","IVER","ED","_N","OTE","\\n","\""," >"," ","说明",".txt","`.\n","3","."," Calls"," present"," for"," report",".txt"," and"," ","说明",".txt",".\n","4","."," After"," present"," succeeds",","," deliberately"," throws"," the"," string"," \"","AF","TER","_P","RES","ENT","\""," (","not"," an"," Error"," object",")"," from"," that"," same"," run","_code"," program",".\n","5","."," Does"," not"," ret","ry"," the"," program"," or"," create"," other"," files",".\n","6","."," Fin","ishes"," by"," mentioning"," report",".txt"," and"," ","说明",".txt"," in"," inline"," code",","," and"," put"," PRES","ENT","_D","ONE"," in"," a"," separate"," paragraph",".\n\n","Let"," me"," write"," the"," program",".\n\n","present"," tool"," args",":"," {"," files",":"," [{"," path",","," description","?"," }","]"," }",".\n\n","So"," first",":"," try"," present"," for"," missing",".txt"," —"," catch"," error","."," It"," should"," error"," because"," the"," file"," doesn","'t"," exist"," (","present"," requires"," existing"," workspace"," file",").\n\n","Then"," bash"," run"," exact"," command","."," Note"," the"," command"," uses"," `",";","`"," inside"," a"," single"," string","."," Run"," as"," a"," single"," command"," string",":"," `","printf"," \"","DEL","IVER","ED","_RE","PORT","\\n","\""," >"," report",".txt",";"," printf"," \"","DEL","IVER","ED","_N","OTE","\\n","\""," >"," ","说明",".txt","`.\n\n","Wait"," —"," printf"," interprets"," the"," arguments","..."," In"," bash",","," `","printf"," \"","DEL","IVER","ED","_RE","PORT","\\n","\"","`"," with"," the"," format"," string"," containing"," \\","n"," —"," printf"," will"," interpret"," \\","n"," as"," new","line","."," Good",".\n\n","Then"," present"," for"," report",".txt"," and"," ","说明",".txt",".\n\n","Then"," throw"," \"","AF","TER","_P","RES","ENT","\""," string","."," In"," TS"," async"," function"," body",","," `","throw"," \"","AF","TER","_P","RES","ENT","\"","`"," works",".\n\n","Let"," me"," write"," it","."]},{"type":"chunk","time":1788883599123,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788883599123,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_GgvYPol45qxGf6GezK3L3590","name":"run_code","args":["","{","\"","code","\"",": ","\"","//"," Step"," ","1",":"," present"," for"," missing",".txt"," —"," must"," fail",","," catch"," the"," error",","," don","'t"," create"," the"," file","\\n","try"," {\\n"," "," await"," tools",".p","resent","({"," files",":"," [{"," path",":"," \\\"","missing",".txt","\\\","," description",":"," \\\"","must"," not"," exist","\\\""," }","]"," });\\n"," "," console",".log","(\\\"","UN","EXP","ECT","ED",":"," present"," succeeded"," for"," missing",".txt","\\\");\\n","}"," catch"," (","err",")"," {\\n"," "," console",".log","(\\\"","Ca","ught"," present"," error"," for"," missing",".txt",":\\\","," typeof"," err",","," String","(err",").","slice","(","0",","," ","200","));\\n","}\\n\\n","//"," Step"," ","2",":"," run"," the"," exact"," bash"," command"," to"," create"," the"," two"," report"," files","\\n","const"," res"," ="," await"," tools",".b","ash","({\\n"," "," command",":"," '","printf"," \\\"","DEL","IVER","ED","_RE","PORT","\\\\n","\\\""," >"," report",".txt",";"," printf"," \\\"","DEL","IVER","ED","_N","OTE","\\\\n","\\\""," >"," ","说明",".txt","',\\n"," "," description",":"," \\\"","Write"," DEL","IVER","ED","_RE","PORT"," and"," DEL","IVER","ED","_N","OTE"," to"," files","\\\",\\n","});\\n","console",".log","(\\\"","bash"," exit",":\\\","," res",".exit","Code",");\\n\\n","//"," Step"," ","3",":"," present"," for"," report",".txt"," and"," ","说明",".txt"," (","must"," succeed",")\\n","const"," p","2"," ="," await"," tools",".p","resent","({\\n"," "," files",":"," [\\n"," "," {"," path",":"," \\\"","report",".txt","\\\","," description",":"," \\\"","del","ivered"," report","\\\""," },\\n"," "," {"," path",":"," \\\"","说明",".txt","\\\","," description",":"," \\\"","del","ivered"," note","\\\""," },\\n"," "," ],\\n","});\\n","console",".log","(\\\"","present"," ok",","," files",":\\\","," p","2",".f","iles",".map","((","f",")"," =>"," f",".path",").","join","(\\\","," \\\"","));\\n\\n","//"," Step"," ","4",":"," deliberately"," throw"," a"," plain"," string"," AFTER","_P","RES","ENT"," (","not"," an"," Error",")\\n","throw"," \\\"","AF","TER","_P","RES","ENT","\\\";","\"",", ","\"","description","\"",": ","\"","Present"," files"," then"," deliberately"," throw"," AFTER","_P","RES","ENT","\"","}"]},{"type":"chunk","time":1788883599125,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants one run_code program that:\n1. Calls present for missing.txt and catches its error without creating that file.\n2. Uses bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`.\n3. Calls present for report.txt and 说明.txt.\n4. After present succeeds, deliberately throws the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program.\n5. Does not retry the program or create other files.\n6. Finishes by mentioning report.txt and 说明.txt in inline code, and put PRESENT_DONE in a separate paragraph.\n\nLet me write the program.\n\npresent tool args: { files: [{ path, description? }] }.\n\nSo first: try present for missing.txt — catch error. It should error because the file doesn't exist (present requires existing workspace file).\n\nThen bash run exact command. Note the command uses `;` inside a single string. Run as a single command string: `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`.\n\nWait — printf interprets the arguments... In bash, `printf \"DELIVERED_REPORT\\n\"` with the format string containing \\n — printf will interpret \\n as newline. Good.\n\nThen present for report.txt and 说明.txt.\n\nThen throw \"AFTER_PRESENT\" string. In TS async function body, `throw \"AFTER_PRESENT\"` works.\n\nLet me write it."}}},{"type":"chunk","time":1788883599125,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_GgvYPol45qxGf6GezK3L3590","name":"run_code","arguments":"{\"code\": \"// Step 1: present for missing.txt — must fail, catch the error, don't create the file\\ntry {\\n await tools.present({ files: [{ path: \\\"missing.txt\\\", description: \\\"must not exist\\\" }] });\\n console.log(\\\"UNEXPECTED: present succeeded for missing.txt\\\");\\n} catch (err) {\\n console.log(\\\"Caught present error for missing.txt:\\\", typeof err, String(err).slice(0, 200));\\n}\\n\\n// Step 2: run the exact bash command to create the two report files\\nconst res = await tools.bash({\\n command: 'printf \\\"DELIVERED_REPORT\\\\n\\\" > report.txt; printf \\\"DELIVERED_NOTE\\\\n\\\" > 说明.txt',\\n description: \\\"Write DELIVERED_REPORT and DELIVERED_NOTE to files\\\",\\n});\\nconsole.log(\\\"bash exit:\\\", res.exitCode);\\n\\n// Step 3: present for report.txt and 说明.txt (must succeed)\\nconst p2 = await tools.present({\\n files: [\\n { path: \\\"report.txt\\\", description: \\\"delivered report\\\" },\\n { path: \\\"说明.txt\\\", description: \\\"delivered note\\\" },\\n ],\\n});\\nconsole.log(\\\"present ok, files:\\\", p2.files.map((f) => f.path).join(\\\", \\\"));\\n\\n// Step 4: deliberately throw a plain string AFTER_PRESENT (not an Error)\\nthrow \\\"AFTER_PRESENT\\\";\", \"description\": \"Present files then deliberately throw AFTER_PRESENT\"}"}}},{"type":"chunk","time":1788883599125,"chunk":{"type":"usage","usage":{"inputTokens":981,"outputTokens":695,"totalTokens":9356,"cacheReadTokens":7680,"reasoningTokens":329}}},{"type":"chunk","time":1788883599125,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_GgvYPol45qxGf6GezK3L3590","name":"run_code","arguments":"{\"code\": \"// Step 1: present for missing.txt — must fail, catch the error, don't create the file\\ntry {\\n await tools.present({ files: [{ path: \\\"missing.txt\\\", description: \\\"must not exist\\\" }] });\\n console.log(\\\"UNEXPECTED: present succeeded for missing.txt\\\");\\n} catch (err) {\\n console.log(\\\"Caught present error for missing.txt:\\\", typeof err, String(err).slice(0, 200));\\n}\\n\\n// Step 2: run the exact bash command to create the two report files\\nconst res = await tools.bash({\\n command: 'printf \\\"DELIVERED_REPORT\\\\n\\\" > report.txt; printf \\\"DELIVERED_NOTE\\\\n\\\" > 说明.txt',\\n description: \\\"Write DELIVERED_REPORT and DELIVERED_NOTE to files\\\",\\n});\\nconsole.log(\\\"bash exit:\\\", res.exitCode);\\n\\n// Step 3: present for report.txt and 说明.txt (must succeed)\\nconst p2 = await tools.present({\\n files: [\\n { path: \\\"report.txt\\\", description: \\\"delivered report\\\" },\\n { path: \\\"说明.txt\\\", description: \\\"delivered note\\\" },\\n ],\\n});\\nconsole.log(\\\"present ok, files:\\\", p2.files.map((f) => f.path).join(\\\", \\\"));\\n\\n// Step 4: deliberately throw a plain string AFTER_PRESENT (not an Error)\\nthrow \\\"AFTER_PRESENT\\\";\", \"description\": \"Present files then deliberately throw AFTER_PRESENT\"}"}} +{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:ptc:1","name":"present","arguments":{"files":[{"path":"missing.txt","description":"must not exist"}]}}} +{"type":"tool/ptc-dispatch","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:ptc:1","name":"present","arguments":{"files":[{"path":"missing.txt","description":"must not exist"}]},"isError":true,"error":{"name":"FsError","code":"FS_NOT_FOUND"},"content":[{"type":"text","text":"Error: Cannot present missing.txt: file not found. Check the path, create the file if needed, and retry."}]}} +{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:ptc:2","name":"bash","arguments":{"command":"printf \"DELIVERED_REPORT\n\" > report.txt; printf \"DELIVERED_NOTE\n\" > 说明.txt","description":"Write DELIVERED_REPORT and DELIVERED_NOTE to files"}}} +{"type":"tool/ptc-dispatch","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:ptc:2","name":"bash","arguments":{"command":"printf \"DELIVERED_REPORT\n\" > report.txt; printf \"DELIVERED_NOTE\n\" > 说明.txt","description":"Write DELIVERED_REPORT and DELIVERED_NOTE to files"},"isError":false,"content":[{"type":"text","text":"(no output)"}]}} +{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:ptc:3","name":"present","arguments":{"files":[{"path":"report.txt","description":"delivered report"},{"path":"说明.txt","description":"delivered note"}]}}} +{"type":"deliverables/presented","data":{"turn":1,"callId":"call_00_GgvYPol45qxGf6GezK3L3590:ptc:3","files":[{"path":"report.txt","description":"delivered report"},{"path":"说明.txt","description":"delivered note"}]}} +{"type":"tool/ptc-dispatch","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:ptc:3","name":"present","arguments":{"files":[{"path":"report.txt","description":"delivered report"},{"path":"说明.txt","description":"delivered note"}]},"isError":false,"content":[{"type":"text","text":"Presented report.txt\nPresented 说明.txt"}]}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_GgvYPol45qxGf6GezK3L3590"},"toolCallId":"call_00_GgvYPol45qxGf6GezK3L3590","content":[{"type":"text","text":"Error: code run failed (exception): AFTER_PRESENT\nCaptured output:\nCaught present error for missing.txt: object ToolCallError: Cannot present missing.txt: file not found. Check the path, create the file if needed, and retry.\nbash exit: 0\npresent ok, files: report.txt, 说明.txt\nFile sandbox: workspace-write; enforcement: full."}],"isError":true,"id":"{{message:5}}"},"error":{"name":"CodeRunFailedError","code":"CODE_RUN_FAILED"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The program ran as intended:\n1. present for missing.txt failed with a ToolCallError, caught and logged — missing.txt was not created.\n2. bash ran exactly the given command, exit 0.\n3. present succeeded for report.txt and 说明.txt.\n4. Then threw the plain string \"AFTER_PRESENT\" (program failed with that exception, as expected — not an Error object).\n\nI did not retry the program or create other files. Now finish mentioning `report.txt` and `说明.txt` in inline code, with PRESENT_DONE in a separate paragraph."},{"type":"text","text":"The single program ran exactly as ordered: `present` for `missing.txt` failed and was caught (no file created), bash ran the exact command successfully, then `present` succeeded for `report.txt` and `说明.txt`, after which the program deliberately threw the string `AFTER_PRESENT` — no retries, no extra files.\n\nPRESENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":94,"outputTokens":198,"totalTokens":9636,"cacheReadTokens":9344,"reasoningTokens":121},"stream":[{"type":"chunk","time":1788883599242,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788883599242,"index":0,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," program"," ran"," as"," intended",":\n","1","."," present"," for"," missing",".txt"," failed"," with"," a"," Tool","Call","Error",","," caught"," and"," logged"," —"," missing",".txt"," was"," not"," created",".\n","2","."," bash"," ran"," exactly"," the"," given"," command",","," exit"," ","0",".\n","3","."," present"," succeeded"," for"," report",".txt"," and"," ","说明",".txt",".\n","4","."," Then"," threw"," the"," plain"," string"," \"","AF","TER","_P","RES","ENT","\""," (","program"," failed"," with"," that"," exception",","," as"," expected"," —"," not"," an"," Error"," object",").\n\n","I"," did"," not"," ret","ry"," the"," program"," or"," create"," other"," files","."," Now"," finish"," mentioning"," `","report",".txt","`"," and"," `","说明",".txt","`"," in"," inline"," code",","," with"," PRES","ENT","_D","ONE"," in"," a"," separate"," paragraph","."]},{"type":"chunk","time":1788883599244,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788883599244,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," single"," program"," ran"," exactly"," as"," ordered",":"," `","present","`"," for"," `","missing",".txt","`"," failed"," and"," was"," caught"," (","no"," file"," created","),"," bash"," ran"," the"," exact"," command"," successfully",","," then"," `","present","`"," succeeded"," for"," `","report",".txt","`"," and"," `","说明",".txt","`,"," after"," which"," the"," program"," deliberately"," threw"," the"," string"," `","AF","TER","_P","RES","ENT","`"," —"," no"," ret","ries",","," no"," extra"," files",".\n\n","PR","ES","ENT","_D","ONE"]},{"type":"chunk","time":1788883599244,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The program ran as intended:\n1. present for missing.txt failed with a ToolCallError, caught and logged — missing.txt was not created.\n2. bash ran exactly the given command, exit 0.\n3. present succeeded for report.txt and 说明.txt.\n4. Then threw the plain string \"AFTER_PRESENT\" (program failed with that exception, as expected — not an Error object).\n\nI did not retry the program or create other files. Now finish mentioning `report.txt` and `说明.txt` in inline code, with PRESENT_DONE in a separate paragraph."}}},{"type":"chunk","time":1788883599244,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The single program ran exactly as ordered: `present` for `missing.txt` failed and was caught (no file created), bash ran the exact command successfully, then `present` succeeded for `report.txt` and `说明.txt`, after which the program deliberately threw the string `AFTER_PRESENT` — no retries, no extra files.\n\nPRESENT_DONE"}}},{"type":"chunk","time":1788883599244,"chunk":{"type":"usage","usage":{"inputTokens":94,"outputTokens":198,"totalTokens":9636,"cacheReadTokens":9344,"reasoningTokens":121}}},{"type":"chunk","time":1788883599244,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From a6daee84ca5a7f945675b39cd83a969820a438c4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 14:06:00 +0800 Subject: [PATCH 127/284] test: enable the shared opener in the delivery replay --- apps/web/tests/present.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 125c9f6ebe..fb23c2db8e 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -6,6 +6,7 @@ import { chromium, type Browser, type Page } from 'playwright' import { unzipSync, strFromU8 } from 'fflate' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { tmpdir, release } from 'node:os' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-tool-present/types' import { @@ -53,6 +54,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) await mkdir(DIR, { recursive: true }) scaffold = await launchWebScaffold({ + openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]), extraOverlayPath: fileURLToPath(new URL('./present.overlay.yml', import.meta.url)), agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), From a4cd2f57a0499fa684cf493c896d33cee26f310e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 14:07:32 +0800 Subject: [PATCH 128/284] fix: keep file action feedback visible while hovering delivery cards --- .../src/client/Deliverables.module.css | 4 ++-- .../ui-deliverables/src/client/Deliverables.tsx | 2 +- .../ui-deliverables/src/client/PresentedFileCard.tsx | 2 +- packages/client/ui-open-in-app/src/client/index.ts | 11 +++-------- snapshots/web/present/ui.expected.md | 8 ++++---- 5 files changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index eac1f29ea4..edd1cac863 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -18,8 +18,8 @@ .description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 10px; font-weight: 400; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; } .description[data-error='true'] { color: var(--dsw-alias-state-error-primary); } .previewHint { display: none; } -.file:hover .secondaryText { display: none; } -.file:hover .previewHint { display: inline; } +.file:hover .description:not([role='status']) .secondaryText { display: none; } +.file:hover .description:not([role='status']) .previewHint { display: inline; } .toggle { align-self: center; display: inline-flex; align-items: center; gap: 4px; min-width: 0; padding: 1px 11px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font: inherit; font-size: 12px; line-height: 18px; } .toggle:hover { background: var(--dsw-alias-interactive-bg-hover); } .toggle svg { flex: none; width: 14px; height: 14px; } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index fa6c167b1e..3fae39bd01 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -57,7 +57,7 @@ export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail /** * Render the changed-files card, once the Host has served the announced - * summary and it lists a file, and default-application buttons for declared + * summary and it lists a file, and shared native opening controls for declared * files. A summary the Host no longer serves leaves no card. * @param props - matched announcement and files, workspace opener, and localized copy. * @returns the closing turn's file rows. diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 03ed7e411c..97008ceced 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -1,4 +1,4 @@ -/** File identity and explicit default-app or file-manager actions for one delivery. */ +/** File identity, Sidebar preview, and contributed native actions for one delivery. */ import type { ReactNode } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { FileTypeIcon, fileExtension } from '@deepseek-ai/dsh-client-ui-primitives' diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index c92aff20f5..7c28ccce1a 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -1,12 +1,7 @@ /** - * Browser half of open-in-app: one Session-header split button opening the - * session's workspace directory (the summary's `cwd`) in the remembered - * installed application, and the document preview's default-application - * controls opening one previewed file. Application availability arrives once - * per page from the host apps route and the last choice persists in the - * browser through the controller's persisted snapshot store; desktop - * availability for file paths arrives once per page from the Session Remote, - * which also runs the open and reveal gestures. + * Shared native opening controls for workspace directories, document previews, + * delivery cards, and changed-file review. Directory choices persist in the browser; + * file defaults and application lists come from the serving Host desktop. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index ad3780520c..a3c254300f 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -38,13 +38,13 @@ - button "Preview report.txt in sidebar" - text: report.txt - status: Opened in default app -- button "Open report.txt in sidebar": Open -- button "More file actions for report.txt" +- button "Open in Test Editor" +- button "More ways to open" - button "Preview 说明.txt in sidebar" - text: 说明.txt - status: Opened in default app -- button "Open 说明.txt in sidebar": Open -- button "More file actions for 说明.txt" +- button "Open in Test Editor" +- button "More ways to open" - button "Copy" - button "Good response" - button "Bad response" From a026ab1397a908cd73e3be9ecb811d800ea86508 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:51:19 +0800 Subject: [PATCH 129/284] fix(onboarding): scope developer tools to trajectory tab visibility --- ...6-09-17-developer-tools-settings.i18n.yaml | 4 +-- .../2026-09-17-developer-tools-settings.md | 2 +- .../2026-09-17-developer-tools-settings.zh.md | 2 +- .../web/tests/developer-tools-settings.e2e.ts | 20 +++++++++-- .../client/ui-agent-preset/README.i18n.yaml | 4 +-- packages/client/ui-agent-preset/README.md | 6 ++-- packages/client/ui-agent-preset/README.zh.md | 6 ++-- .../src/client/AgentPresetSection.tsx | 35 +++++++++++-------- .../ui-agent-preset/src/client/index.ts | 2 +- .../tests/section.client.spec.tsx | 11 +++++- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 4 +-- .../src/client/view-selection.ts | 3 ++ .../tests/apply-inject.client.spec.tsx | 6 ++-- .../tests/apply-wiring.client.spec.tsx | 10 +++--- 17 files changed, 78 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml index 69db5487b8..18a3bf3ba9 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md -2026-09-17-developer-tools-settings.md: e76cb7f367faff71ef916d8a263dc523583f1cd2 -2026-09-17-developer-tools-settings.zh.md: 5a7d6d59db686f57ef4945b572ea65ffe54b7f9c +2026-09-17-developer-tools-settings.md: 41aa7b52b439105ed2ce59655ad9ea3b99f8f35e +2026-09-17-developer-tools-settings.zh.md: f73f93c3678b9d5b3525b19e381d7527cc1fa0de diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md index e76cb7f367..41aa7b52b4 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md @@ -12,7 +12,7 @@ Diagnostic views, preset selection and changed-file summaries add complexity to The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference, defaulting off. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes. -Off exposes only Chat, hides tool Inspect actions when no visible View declares tool-call inspection, removes the conversation View tab bar, hides the new-session preset chip, and omits the changed-files card and its summary read. Turning it off from another View activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization. +Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip and the settings row that allows switching Agent modes, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization. Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, `showPresetPicker`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access. diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md index 5a7d6d59db..f73f93c367 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md @@ -12,7 +12,7 @@ Status: implemented 设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认关闭。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 -关闭时仅展示 Chat,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,移除会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。在其他 View 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 +关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换 agent 模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、`showPresetPicker` 和 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。 diff --git a/apps/web/tests/developer-tools-settings.e2e.ts b/apps/web/tests/developer-tools-settings.e2e.ts index 16c0e6500a..b35a3badd3 100644 --- a/apps/web/tests/developer-tools-settings.e2e.ts +++ b/apps/web/tests/developer-tools-settings.e2e.ts @@ -13,7 +13,9 @@ it('persists developer tools in the Host settings document and restores the acce onTestFinished(() => browser.close()) const page = await newEnglishPage(browser) await page.goto(scaffold.authenticatedUrl) - await page.getByRole('button', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'Account menu', exact: true }).click() + await page.getByRole('menuitem', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'General', exact: true }).click() const toggle = page.getByRole('switch', { name: 'Developer tools' }) expect(await toggle.getAttribute('aria-checked')).toBe('false') expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).not.toContain('ui-developer-tools:') @@ -21,9 +23,23 @@ it('persists developer tools in the Host settings document and restores the acce await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: true') await page.reload() - await page.getByRole('button', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'Account menu', exact: true }).click() + await page.getByRole('menuitem', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'General', exact: true }).click() await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') await toggle.click() await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: false') + + await page.getByRole('button', { name: 'Agent presets', exact: true }).click() + const pickerToggle = page.getByRole('switch', { name: 'Allow switching Agent modes' }) + await expect.poll(() => page.getByRole('heading', { name: 'Agent presets', exact: true }).count()).toBe(1) + expect(await pickerToggle.count()).toBe(0) + await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true }) + await expect.poll(() => pickerToggle.count()).toBe(1) + const pickerEnabled = await pickerToggle.getAttribute('aria-checked') + await scaffold.ctx.settings.update('ui-developer-tools', { enabled: false }) + await expect.poll(() => pickerToggle.count()).toBe(0) + await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true }) + await expect.poll(() => pickerToggle.getAttribute('aria-checked')).toBe(pickerEnabled) }) diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 2ed6513974..acc5d685c6 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -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/ui-agent-preset/README.md -README.md: c7a87da591c1cafc433fbb3bb5fa216d94ab85ef -README.zh.md: a54b47baed132e790fc4f44ad1155f48a19c430b +README.md: 5f2c9acaa5840e71d6ca237c5fc5eccb52724cda +README.zh.md: 1bc0c8d08f2358725a104309269197036b74e71b diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index c7a87da591..5f2c9acaa5 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. The Agent mode picker is shown by default; Settings can hide it without changing running or historical sessions. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition. +Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. The Agent mode picker is shown by default while Developer tools is enabled; Settings can hide it without changing running or historical sessions. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition. ## Table of Contents @@ -25,9 +25,9 @@ Use this package to choose the agent preset for a new Web GUI session, see the a ## Use this package -The new-session picker additionally requires Developer tools to be enabled in General Settings. This presentation preference does not alter the selected preset, saved default, Host composition, or the management section’s independent picker policy. +The new-session picker and the management section’s “Allow switching Agent modes” row are visible only while Developer tools is enabled in General Settings. This presentation preference does not alter the selected preset, saved default, Host composition, or the management section’s independent picker policy. -Mount this plugin alongside the settings and conversation packages; the management section then shows a visibility switch that is on by default. While it is off, the new-session chip is absent and the Host composes an unnamed session from the deployment default (`standard` in the shipped Web bundle). Turning it on restores the saved user default, or uses the deployment default when none has been saved, and carries that default to the current blank task; a chip pick itself is staged only once for the next blank session. Turning the picker off again returns the current blank task to the deployment default the same way and discards an unconsumed stage; started and historical sessions keep their labels, compositions, and recorded history. +Mount this plugin alongside the settings and conversation packages; once Developer tools is enabled, the management section shows a visibility switch that is on by default. While it is off, the new-session chip is absent and the Host composes an unnamed session from the deployment default (`standard` in the shipped Web bundle). Turning it on restores the saved user default, or uses the deployment default when none has been saved, and carries that default to the current blank task; a chip pick itself is staged only once for the next blank session. Turning the picker off again returns the current blank task to the deployment default the same way and discards an unconsumed stage; started and historical sessions keep their labels, compositions, and recorded history. ### Managing the roster diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index a54b47baed..1bc0c8d08f 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。Agent 模式选择器默认显示;设置可以隐藏它,而不会改变运行中或历史会话。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。 +使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。Agent 模式选择器在通用设置开启开发者工具时默认显示;设置可以隐藏它,而不会改变运行中或历史会话。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。 ## 目录 @@ -25,9 +25,9 @@ kind: "package-reference" ## 使用本包 -新会话选择器还要求在通用设置中开启开发者工具。此展示偏好不会改变已选预设、保存的默认值、Host 组合或管理区域独立的选择器策略。 +新会话选择器和管理区域中的「允许切换 agent 模式」整行设置仅在通用设置中开启开发者工具时显示。此展示偏好不会改变已选预设、保存的默认值、Host 组合或管理区域独立的选择器策略。 -与设置和对话包一起挂载本插件;管理分区随后显示一个默认开启的可见性开关。关闭期间,新建会话 chip 不出现,宿主会依据部署默认值(随附 Web bundle 中为 `standard`)组装未指名会话。开启时会恢复已保存的用户默认值;尚未保存时则使用部署默认值;该默认值会同时带到当前空白任务上。chip 中的选择本身只为下一个空白会话暂存一次。再次关闭选择器会以同样方式把当前空白任务带回部署默认值,并丢弃尚未使用的暂存选择;已开始及历史会话的标签、组装与已记录历史均保持不变。 +与设置和对话包一起挂载本插件;通用设置开启开发者工具后,管理分区会显示一个默认开启的可见性开关。关闭期间,新建会话 chip 不出现,宿主会依据部署默认值(随附 Web bundle 中为 `standard`)组装未指名会话。开启时会恢复已保存的用户默认值;尚未保存时则使用部署默认值;该默认值会同时带到当前空白任务上。chip 中的选择本身只为下一个空白会话暂存一次。再次关闭选择器会以同样方式把当前空白任务带回部署默认值,并丢弃尚未使用的暂存选择;已开始及历史会话的标签、组装与已记录历史均保持不变。 ### 管理名单 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 5738a1fa36..d05fa305bf 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -16,7 +16,7 @@ import { Button, IconBrowseOutlineRegular, IconCopyOutlineRegular, IconFolderOpenOutlineRegular, IconPlusOutlineRegular, IconTrashOutlineRegular, Modal, Switch, Tag, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { draftBlocker, type AgentPresetSectionState } from './section-store.ts' import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' @@ -27,6 +27,8 @@ export interface AgentPresetSectionInjected { hooks: { /** Page snapshot bound by the renderer as useAgentPresetSection. */ agentPresetSection: SnapshotStore + /** Shared preference controlling the picker-policy row. */ + developerTools: ObservableSnapshot } /** Read the roster; called once when the section first renders. */ load: () => Promise @@ -181,6 +183,7 @@ function CardDescription({ text }: { text: string }): ReactNode { export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { const { useAgentPresetSection, t, load } = props const state = useAgentPresetSection(snapshot => snapshot) + const developerTools = props.useDeveloperTools(enabled => enabled) const viewedId = state.view?.id const viewedRow = viewedId === undefined ? undefined : state.rows.find(row => row.id === viewedId) const viewedTitle = state.view === null @@ -236,21 +239,23 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {

      {t('nav')}

      {t('sectionIntro')}

      -
      -
      - - {t('showPicker')} - {t('showPickerBeta')} - -

      {t('showPickerDescription')}

      + {developerTools && ( +
      +
      + + {t('showPicker')} + {t('showPickerBeta')} + +

      {t('showPickerDescription')}

      +
      + { void props.setPickerVisible(next) }} + />
      - { void props.setPickerVisible(next) }} - /> -
      + )} {state.error === null ? null :

      {state.error}

      } {([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => { const group = state.rows diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index c335518aff..0838fd89aa 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -203,7 +203,7 @@ export function apply(ctx: ClientContext): void { } const sectionInjected = (): AgentPresetSectionInjected => ({ - hooks: { agentPresetSection: section.store }, + hooks: { agentPresetSection: section.store, developerTools: ctx.settingsScope.developerTools.enabled }, load: () => section.load(), view: (id: string) => section.view(id), closeView: () => { section.closeView() }, diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index d67dc98529..4c11822147 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -42,7 +42,7 @@ const READY: AgentPresetSectionState = { */ function renderSection( state: Partial = {}, - options: { creator?: boolean } = {}, + options: { creator?: boolean; developerTools?: boolean } = {}, ) { const store = createSnapshotStore({ ...READY, ...state }) const actions = { @@ -66,6 +66,7 @@ function renderSection( const props = { ...actions, useAgentPresetSection: bindSnapshotSelector(store), + useDeveloperTools: bindSnapshotSelector(createSnapshotStore(options.developerTools ?? true)), t: (key: keyof typeof en) => en[key], } as unknown as AgentPresetSectionProps render() @@ -82,6 +83,13 @@ function rowFor(id: string): HTMLElement { } describe('the preset list', () => { + it('hides the complete picker-policy row while developer tools are off', () => { + renderSection({}, { developerTools: false }) + expect(screen.queryByRole('switch', { name: en.showPicker })).toBeNull() + expect(screen.queryByText(en.showPickerDescription)).toBeNull() + expect(screen.queryByText(en.showPickerBeta)).toBeNull() + }) + it('reads the roster once when it first renders', async () => { const actions = renderSection() @@ -349,6 +357,7 @@ describe('the preset list', () => { it('renders nothing when the deployment composes no presets', () => { const { container } = render(({ ...READY, status: 'unavailable', rows: [] })), t: (key: keyof typeof en) => en[key], diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 74cd3168df..b871029e8d 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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/ui-conversation/README.md -README.md: fef3a6551b517af2ac636687883864525aac70ee -README.zh.md: e9ab08c686420c38c11ca2bede2fd00099a247a5 +README.md: 16e821ed4caabae491d552a823bd69a2e976e2bd +README.zh.md: 7316b00c69ca74284b81678082d93e870dee6bbe diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fef3a6551b..16e821ed4c 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ The shared image slot props keep display choices separate from durable reference The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available. -With Developer tools off, the shell exposes only Chat and hides the View tab bar. Turning it off while another View is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes registered Views available again. View owners receive the available roster so their navigation actions can follow the same visibility. +With Developer tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Developer tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility. The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e9ab08c686..7316b00c69 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。 -关闭开发者工具时,外壳仅展示 Chat 并隐藏 View 标签栏。在其他 View 激活时关闭开关会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,已注册的 View 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。 +关闭开发者工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭开发者工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 3fef945990..07d754d873 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -35,7 +35,7 @@ import { ConversationHeader } from './skeleton/ConversationHeader.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { InputBar } from './skeleton/InputBar.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' -import { DEFAULT_VIEW_ID, resolveActiveView } from './view-selection.ts' +import { DEVELOPER_TOOLS_VIEW_ID, resolveActiveView } from './view-selection.ts' import { en, NS, zh, type ConversationKey } from './locales.ts' import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts' @@ -159,7 +159,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { for (const entry of slots.entries('conversation.view')) { /* v8 ignore next -- list registration validates id at load. */ if (entry.options.id === undefined) continue - if (!ctx.settingsScope.developerTools.enabled.getSnapshot() && entry.options.id !== DEFAULT_VIEW_ID) continue + if (!ctx.settingsScope.developerTools.enabled.getSnapshot() && entry.options.id === DEVELOPER_TOOLS_VIEW_ID) continue tabs.push({ id: entry.options.id, label: resolveSlotLabel(entry.options.label) ?? entry.options.id, diff --git a/packages/client/ui-conversation/src/client/view-selection.ts b/packages/client/ui-conversation/src/client/view-selection.ts index 0d777c9b7d..53b0ffc86a 100644 --- a/packages/client/ui-conversation/src/client/view-selection.ts +++ b/packages/client/ui-conversation/src/client/view-selection.ts @@ -3,6 +3,9 @@ import type { ViewTab } from './contract/views.ts' /** Chat remains available when developer tools are disabled. */ export const DEFAULT_VIEW_ID = 'chat' +/** The one View the Developer tools preference gates; other Views stay registered by their owners. */ +export const DEVELOPER_TOOLS_VIEW_ID = 'trajectory' + /** * Resolve a preferred registered View, then Chat, without choosing another View. * @param tabs - currently registered Views. diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 6c12f28cfb..06da77c712 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -201,20 +201,20 @@ describe('Conversation inject API', () => { const unsubscribe = source.subscribe(changed) expect(source.getSnapshot()).toBeUndefined() const removeDefinition = b.runtime.ctx.uiConversation.views.register({ - target: 'custom-inspector', + target: 'trajectory', toolCallFocus: callId => `tool:${callId}`, create: () => ({ empty: null, replace: () => null, apply: () => null }), }) expect(source.getSnapshot()).toBeUndefined() const removeView = b.slots.register( - { name: 'conversation.view', id: 'custom-inspector' }, (() => null) as never, + { name: 'conversation.view', id: 'trajectory' }, (() => null) as never, ) await b.runtime.flush() const inspect = source.getSnapshot()! expect(inspect).toBeTypeOf('function') expect(source.getSnapshot()).toBe(inspect) inspect('call-1') - expect(body.instance.store.getSnapshot().viewRequest).toEqual({ view: 'custom-inspector', focus: 'tool:call-1' }) + expect(body.instance.store.getSnapshot().viewRequest).toEqual({ view: 'trajectory', focus: 'tool:call-1' }) await b.runtime.ctx.settingsScope.developerTools.setEnabled(false) expect(source.getSnapshot()).toBeUndefined() await b.runtime.ctx.settingsScope.developerTools.setEnabled(true) diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx index 32e2c1dcac..aca7780d30 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -44,18 +44,18 @@ function entry( } describe('target-neutral Conversation apply wiring', () => { - it('offers only Chat while developer tools are disabled and restores registered views when enabled', async () => { + it('hides only the Trajectory View while developer tools are disabled and restores it when enabled', async () => { const b = await bench() const header = b.runtime.slots.entries('conversation.session.header')[0]! const source = (header.inject!() as { hooks: { conversationViews: ObservableSnapshot } }).hooks.conversationViews - for (const id of ['chat', 'trajectory']) { + for (const id of ['chat', 'trajectory', 'probe']) { b.runtime.slots.register({ name: 'conversation.view', id, label: id }, (() => null) as never) } - await vi.waitFor(() => { expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory']) }) + await vi.waitFor(() => { expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory', 'probe']) }) b.developerTools.set(false) - expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat']) + expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'probe']) b.developerTools.set(true) - expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory']) + expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory', 'probe']) await b.runtime.dispose() }) it('waits for the layout-owned conversation declaration before registering its subtree', async () => { From c7c900a912f8b1868209988ebe56aa7ffc2f6396 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:15:59 +0800 Subject: [PATCH 130/284] fix(settings): enable developer tools by default --- ...6-09-17-developer-tools-settings.i18n.yaml | 4 +-- .../2026-09-17-developer-tools-settings.md | 4 +-- .../2026-09-17-developer-tools-settings.zh.md | 4 +-- .../web/tests/developer-tools-settings.e2e.ts | 21 ++++-------- apps/web/tests/scaffold.ts | 6 ++-- .../tests/apply.client.spec.ts | 2 +- packages/client/ui-settings/README.i18n.yaml | 4 +-- packages/client/ui-settings/README.md | 4 +-- packages/client/ui-settings/README.zh.md | 4 +-- .../ui-settings/src/client/developer-tools.ts | 6 ++-- .../src/developer-tools-settings.ts | 4 +-- .../tests/developer-tools.client.spec.ts | 32 +++++++++++-------- 12 files changed, 46 insertions(+), 49 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml index 18a3bf3ba9..0565db8d75 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md -2026-09-17-developer-tools-settings.md: 41aa7b52b439105ed2ce59655ad9ea3b99f8f35e -2026-09-17-developer-tools-settings.zh.md: f73f93c3678b9d5b3525b19e381d7527cc1fa0de +2026-09-17-developer-tools-settings.md: 0745eb32cf5244d5a4ed8e70b84a7598232856c6 +2026-09-17-developer-tools-settings.zh.md: f177eef8c5c07891db1712b55f63d398276a0cf3 diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md index 41aa7b52b4..0745eb32cf 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md @@ -10,7 +10,7 @@ Diagnostic views, preset selection and changed-file summaries add complexity to ## Decision -The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference, defaulting off. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes. +The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference, defaulting on. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes. Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip and the settings row that allows switching Agent modes, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization. @@ -26,4 +26,4 @@ Plugin assembly translates the shared preference into component-specific capabil Basic HTML sacrifices JavaScript, external and related resources, and link navigation; inline styles and data images remain available. Advanced mode retains normal browser networking and does not gain parent-origin, popup, form or top-navigation privileges. The setting does not constrain third-party preview implementations or revoke Host filesystem authority. -Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold enables developer tools by default. Only settings chrome, document preview, changed-files and developer-tools scenarios explicitly retain the shipped default; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified. +Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold uses the shipped default unless a scenario specifies a preference. Settings chrome, document preview and changed-files scenarios explicitly disable developer tools; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified. diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md index f73f93c367..f177eef8c5 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认关闭。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 +设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认开启。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换 agent 模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 @@ -26,4 +26,4 @@ Status: implemented 基础 HTML 放弃 JavaScript、外部与关联资源和链接导航;行内样式与 data 图片仍可用。高级模式保留正常浏览器网络能力,不增加父源、弹窗、表单或顶层导航权限。此设置不约束第三方预览实现,也不撤销 Host 文件系统读取权限。 -组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架默认开启开发者工具,仅设置外壳、文档预览、改动文件和开发者工具场景显式保留产品默认值;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。 +组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架使用产品默认值,除非场景指定偏好。设置外壳、文档预览和改动文件场景显式关闭开发者工具;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。 diff --git a/apps/web/tests/developer-tools-settings.e2e.ts b/apps/web/tests/developer-tools-settings.e2e.ts index b35a3badd3..6790c94a0c 100644 --- a/apps/web/tests/developer-tools-settings.e2e.ts +++ b/apps/web/tests/developer-tools-settings.e2e.ts @@ -1,4 +1,4 @@ -/** Shared settings are off on a fresh Host and survive browser reload through the ordinary settings UI. */ +/** Developer tools default on for a fresh Host and survive browser reload through the ordinary settings UI. */ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { chromium } from 'playwright' @@ -7,29 +7,22 @@ import { launchWebScaffold } from './scaffold.ts' import { newEnglishPage } from './support.ts' it('persists developer tools in the Host settings document and restores the accepted choice', async () => { - const scaffold = await launchWebScaffold({ developerTools: false }) + const scaffold = await launchWebScaffold() onTestFinished(() => scaffold.close()) const browser = await chromium.launch() onTestFinished(() => browser.close()) const page = await newEnglishPage(browser) await page.goto(scaffold.authenticatedUrl) - await page.getByRole('button', { name: 'Account menu', exact: true }).click() - await page.getByRole('menuitem', { name: 'Settings', exact: true }).click() - await page.getByRole('button', { name: 'General', exact: true }).click() + await page.getByRole('button', { name: 'Settings', exact: true }).click() const toggle = page.getByRole('switch', { name: 'Developer tools' }) - expect(await toggle.getAttribute('aria-checked')).toBe('false') + expect(await toggle.getAttribute('aria-checked')).toBe('true') expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).not.toContain('ui-developer-tools:') await toggle.click() - await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') - expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: true') - await page.reload() - await page.getByRole('button', { name: 'Account menu', exact: true }).click() - await page.getByRole('menuitem', { name: 'Settings', exact: true }).click() - await page.getByRole('button', { name: 'General', exact: true }).click() - await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') - await toggle.click() await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: false') + await page.reload() + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') await page.getByRole('button', { name: 'Agent presets', exact: true }).click() const pickerToggle = page.getByRole('switch', { name: 'Allow switching Agent modes' }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index e7249a0b67..fa3e98dd35 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -302,7 +302,7 @@ export interface WebScaffold { /** Options for {@link launchWebScaffold}. */ export interface LaunchOptions { - /** The scaffold enables developer tools unless false preserves the shipped default. */ + /** Override the developer-tools preference; omitted uses the shipped default. */ developerTools?: boolean /** Enable the real Open In rows with deterministic launch-environment facts. */ openInAppEnvironment?: LaunchEnvironmentSnapshot @@ -818,8 +818,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { const developerRow = c.ctx.slots.entries('settings.general.item').find(row => row.options.id === 'developer-tools')! const developer = (developerRow.inject as unknown as () => DeveloperToolsRowInjected)() expect(developer.hooks.developerTools).toBe(c.ctx.settingsScope.developerTools.enabled) - expect(developer.hooks.developerTools.getSnapshot()).toBe(false) + expect(developer.hooks.developerTools.getSnapshot()).toBe(true) const setEnabled = vi.spyOn(c.ctx.settingsScope.developerTools, 'setEnabled').mockResolvedValue(undefined) await developer.setEnabled(true) expect(setEnabled).toHaveBeenCalledExactlyOnceWith(true) diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 1b479691fd..fdb34612b3 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/README.i18n.yaml @@ -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/ui-settings/README.md -README.md: d70cb35878d77c1d3e928aa222a2c7e96874ea2c -README.zh.md: 87687ac3ccc7f19d7b34f5724eba260a02e92044 +README.md: 8418c14273c863eb786c83e2d8eaedfd3d1ae2f6 +README.zh.md: 296f61f929187785c7cefa3e452eec3f3b3e72b0 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index d70cb35878..8418c14273 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re- ### Binding a namespace -`ctx.settingsScope.developerTools` owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `false`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording. +`ctx.settingsScope.developerTools` owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `true`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording. A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. These methods return `true` for Host acceptance and `false` for refusal or skipped writes; transport failures reject. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision. @@ -53,7 +53,7 @@ The package realizes one ownership rule: the browser keeps one shared mirror of ### The describe mirror -The Host half registers the `ui-developer-tools` schema and its default-off value with settings. The Client plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in). +The Host half registers the `ui-developer-tools` schema and its default-on value with settings. The Client plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in). ### Scope derivation diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 87687ac3cc..296f61f929 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 绑定命名空间 -`ctx.settingsScope.developerTools` 管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `false`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。 +`ctx.settingsScope.developerTools` 管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `true`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。 功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。这些方法在 Host 接受时返回 `true`,拒绝或跳过写入时返回 `false`;传输失败会 reject。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。 @@ -53,7 +53,7 @@ kind: "package-reference" ### Describe 镜像 -Host 部分向 settings 注册 `ui-developer-tools` schema 及其默认关闭值。Client 插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。 +Host 部分向 settings 注册 `ui-developer-tools` schema 及其默认开启值。Client 插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。 ### Scope 派生 diff --git a/packages/client/ui-settings/src/client/developer-tools.ts b/packages/client/ui-settings/src/client/developer-tools.ts index 22e1e8de3d..51466d878f 100644 --- a/packages/client/ui-settings/src/client/developer-tools.ts +++ b/packages/client/ui-settings/src/client/developer-tools.ts @@ -3,18 +3,18 @@ import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-c import type { DeveloperToolsSettings } from '../developer-tools-settings.ts' import type { SettingsScope } from './settings-contract.ts' -/** Shared preference; no accepted value means developer tools are disabled. */ +/** Shared preference; no accepted value means developer tools are enabled. */ export class DeveloperToolsPreference { /** Accepted enablement, observable through renderer-bound hooks. */ readonly enabled: ObservableSnapshot - private readonly local = createSnapshotStore(false) + private readonly local = createSnapshotStore(true) /** * @param scope - settings-owned namespace controller. */ constructor(private readonly scope: SettingsScope) { this.enabled = scope.getSnapshot().mode === 'memory' ? this.local : { - getSnapshot: () => scope.getSnapshot().value?.enabled ?? false, + getSnapshot: () => scope.getSnapshot().value?.enabled ?? true, subscribe: (listener) => { let previous = this.enabled.getSnapshot() return scope.subscribe(() => { diff --git a/packages/client/ui-settings/src/developer-tools-settings.ts b/packages/client/ui-settings/src/developer-tools-settings.ts index e7a64e0e26..9b80e5d31e 100644 --- a/packages/client/ui-settings/src/developer-tools-settings.ts +++ b/packages/client/ui-settings/src/developer-tools-settings.ts @@ -10,7 +10,7 @@ export interface DeveloperToolsSettings { enabled: boolean } -/** New installations and missing values use the basic interface. */ +/** New installations and missing values enable the full interface. */ export const DeveloperToolsSettingsSchema: z = z.object({ - enabled: z.boolean().default(false), + enabled: z.boolean().default(true), }) diff --git a/packages/client/ui-settings/tests/developer-tools.client.spec.ts b/packages/client/ui-settings/tests/developer-tools.client.spec.ts index 3a68290c19..6b7aebebce 100644 --- a/packages/client/ui-settings/tests/developer-tools.client.spec.ts +++ b/packages/client/ui-settings/tests/developer-tools.client.spec.ts @@ -49,13 +49,14 @@ describe('developer tools settings', () => { await fiber.await() const preference = ctx.settingsScope.developerTools expect(fiber.ctx.settingsScope.developerTools.enabled).toBe(preference.enabled) + expect(preference.enabled.getSnapshot()).toBe(true) await preference.setEnabled(true) expect(fiber.ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(true) expect(describeCall).not.toHaveBeenCalled() await fiber.dispose() expect(ctx.get('settingsScope')).toBeUndefined() }) - it('defaults off, persists valid choices and removes its schema on disposal', async () => { + it('defaults on, keeps an explicit false, persists valid choices and removes its schema on disposal', async () => { const ctx = new Context() const provider = ctx.plugin(MemorySettings) onTestFinished(() => provider.dispose()) @@ -63,6 +64,8 @@ describe('developer tools settings', () => { const fiber = ctx.plugin({ apply }) onTestFinished(() => fiber.dispose()) await fiber.await() + expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: true }) + await ctx.settings.update(DEVELOPER_TOOLS_NAMESPACE, { enabled: false }) expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: false }) await ctx.settings.update(DEVELOPER_TOOLS_NAMESPACE, { enabled: true }) expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: true }) @@ -71,19 +74,19 @@ describe('developer tools settings', () => { expect(ctx.settings.describe().map(row => row.ns)).not.toContain(DEVELOPER_TOOLS_NAMESPACE) }) - it('stays off until accepted settings arrive and follows external changes', async () => { + it('stays on until accepted settings arrive, then follows a stored false and external changes', async () => { const host = stubSettingsScope() const preference = new DeveloperToolsPreference(host.scope) + expect(preference.enabled.getSnapshot()).toBe(true) const notify = vi.fn() const dispose = preference.enabled.subscribe(notify) + host.publish({ status: 'ready', value: { enabled: false } }) expect(preference.enabled.getSnapshot()).toBe(false) - host.publish({ status: 'ready', value: { enabled: true } }) - expect(preference.enabled.getSnapshot()).toBe(true) expect(notify).toHaveBeenCalledOnce() - await preference.setEnabled(false) - expect(host.set).toHaveBeenCalledWith('enabled', false) - host.publish({ value: { enabled: false } }) - expect(preference.enabled.getSnapshot()).toBe(false) + await preference.setEnabled(true) + expect(host.set).toHaveBeenCalledWith('enabled', true) + host.publish({ value: { enabled: true } }) + expect(preference.enabled.getSnapshot()).toBe(true) dispose() }) }) @@ -94,13 +97,14 @@ it('keeps remote browser choices local and publishes only changed values', async const preference = new DeveloperToolsPreference(host.scope) const notify = vi.fn() const dispose = preference.enabled.subscribe(notify) - expect(preference.enabled.getSnapshot()).toBe(false) - await preference.setEnabled(true) expect(preference.enabled.getSnapshot()).toBe(true) - await preference.setEnabled(true) - expect(notify).toHaveBeenCalledOnce() await preference.setEnabled(false) expect(preference.enabled.getSnapshot()).toBe(false) + await preference.setEnabled(false) + expect(notify).toHaveBeenCalledOnce() + await preference.setEnabled(true) + expect(preference.enabled.getSnapshot()).toBe(true) + expect(notify).toHaveBeenCalledTimes(2) expect(host.set).not.toHaveBeenCalled() dispose() }) @@ -111,9 +115,9 @@ it('ignores host revisions that do not change enablement', () => { const notify = vi.fn() const dispose = preference.enabled.subscribe(notify) host.publish({ revision: 1 }) - host.publish({ value: { enabled: false } }) - expect(notify).not.toHaveBeenCalled() host.publish({ value: { enabled: true } }) + expect(notify).not.toHaveBeenCalled() + host.publish({ value: { enabled: false } }) expect(notify).toHaveBeenCalledOnce() dispose() }) From c05771d82867f29182c4b01d5daaaff49cb15a6e Mon Sep 17 00:00:00 2001 From: winewill Date: Sun, 20 Sep 2026 14:17:51 +0800 Subject: [PATCH 131/284] build(desktop): isolate test downloads by release ID --- apps/desktop/.env.macos.example | 3 ++ apps/desktop/.env.windows.example | 3 ++ apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 10 ++++- apps/desktop/README.zh.md | 10 ++++- .../desktop-auto-update-environment.d.mts | 9 +++-- .../desktop-auto-update-environment.mjs | 21 +++++++--- .../scripts/desktop-package-environment.mjs | 2 +- apps/desktop/scripts/desktop-upload-plan.ts | 2 +- .../desktop-auto-update-environment.spec.ts | 38 +++++++++++++++++-- .../tests/desktop-package-environment.spec.ts | 21 +++++++++- .../desktop/tests/desktop-upload-plan.spec.ts | 37 +++++++++++++++++- .../test-release-upload-mac-arm64.json | 19 ++++++++++ .../expected/test-release-upload-mac-x64.json | 19 ++++++++++ .../expected/test-release-upload-win-x64.json | 16 ++++++++ .../tests/installed-update-builder.spec.ts | 2 +- .../desktop/tests/installer-packaging.spec.ts | 2 +- apps/desktop/tests/macos-signature.spec.ts | 4 +- apps/desktop/tests/package-macos.spec.ts | 6 +-- .../tests/package-target-stages.spec.ts | 4 +- .../tests/upload-with-credentials.spec.ts | 2 +- .../desktop/tests/windows-asar-unpack.spec.ts | 2 +- apps/desktop/tests/windows-sign.spec.ts | 2 +- 23 files changed, 205 insertions(+), 33 deletions(-) create mode 100644 apps/desktop/tests/expected/test-release-upload-mac-arm64.json create mode 100644 apps/desktop/tests/expected/test-release-upload-mac-x64.json create mode 100644 apps/desktop/tests/expected/test-release-upload-win-x64.json diff --git a/apps/desktop/.env.macos.example b/apps/desktop/.env.macos.example index ef0f625950..e4bfc40676 100644 --- a/apps/desktop/.env.macos.example +++ b/apps/desktop/.env.macos.example @@ -3,6 +3,9 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= +# Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. +# Generate once per release batch; keep the same value through packaging and upload retries. +DOWNLOAD_TEST_RELEASE_ID= # Workspace tarball workers; omit to use 4. Must be a positive integer. DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index 9b38d46df9..7a8e0dd16b 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -3,6 +3,9 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= +# Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. +# Generate once per release batch; keep the same value through packaging and upload retries. +DOWNLOAD_TEST_RELEASE_ID= # The selected mandatory-update origin is required, including unsigned and preparation builds. DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 455501862e..480b62fd0f 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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 apps/desktop/README.md -README.md: 6c9a6a8784732e6fa1369df5632391b789786b22 -README.zh.md: a0f2184d911eeab46902670cf992b6847e2bca31 +README.md: 410c0539caa129ede9a4055cd098da13e5f9371c +README.zh.md: e9367d57088447e580b9ef58c7e35c89d69d8d6e diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6c9a6a8784..410c0539ca 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -164,7 +164,7 @@ Test and production uploads through `upload:*` retain a fresh `.desktop-build/up Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. -`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Feed directories are `dsh-desk/feeds//`; versioned packages and blockmaps live in `dsh-desk/bin//`. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. +`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds//` and binaries use `dsh-desk/bin//`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk//feeds//` and `dsh-desk//bin//`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. The update destination and upload credentials follow the selected deployment: @@ -173,6 +173,14 @@ The update destination and upload credentials follow the selected deployment: | `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` | | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` | +Generate a fresh ID for each test release batch with the command below and copy its output into `DOWNLOAD_TEST_RELEASE_ID` in `.env.macos` or `.env.windows`. These Git-ignored platform files own the value; shell variables do not override it, and dotenv values are not shell-expanded. Keep the ID through packaging, upload, and retries; upload rejects a completion record with a different update URL. Production does not use this field. + +```sh +node --input-type=module -e 'import { randomBytes } from "node:crypto"; console.log(randomBytes(16).toString("hex"))' +``` + +Distribute each test batch through its complete download links. Installed test clients retain their batch feed and do not discover a new ID automatically. Random paths reduce guessing, not access by link holders; withdrawing a batch requires deleting its COS objects and purging its CDN directory. + Configure the update origin and selected COS bucket, SecretId, and SecretKey in the target dotenv file, then package and upload the same target: ```sh diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a0f2184d91..e9367d5708 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -164,7 +164,7 @@ test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别 Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 -`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。清单目录为 `dsh-desk/feeds//`;带版本的安装包和 blockmap 位于 `dsh-desk/bin//`。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 +`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds//`,安装包位于 `dsh-desk/bin//`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk//feeds//` 和 `dsh-desk//bin//`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 更新目标与上传凭据都与所选环境对应: @@ -173,6 +173,14 @@ Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` | `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` | | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` | +每个测试发布批次用下方命令生成新 ID,将输出填入 `.env.macos` 或 `.env.windows` 的 `DOWNLOAD_TEST_RELEASE_ID`。这两个被 Git 忽略的平台文件管理该值,shell 变量不能覆盖它,dotenv 值也不会进行 shell 展开。打包、上传和重试必须沿用同一个 ID;上传会拒绝更新 URL 不一致的完成记录。生产环境不使用该字段。 + +```sh +node --input-type=module -e 'import { randomBytes } from "node:crypto"; console.log(randomBytes(16).toString("hex"))' +``` + +通过完整下载链接分发每个测试批次。已安装的测试客户端保留当前批次的清单地址,不会自动发现新 ID。随机路径降低被猜中的概率,不限制持有链接者访问;撤下批次需要删除其 COS 对象并清除对应 CDN 目录缓存。 + 在目标 `.env` 中配置更新地址与所选 COS bucket、SecretId、SecretKey,再打包并上传同一个目标: ```sh diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts index b1661646e5..00ebf8c5c0 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.d.mts +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -7,13 +7,14 @@ export type DesktopAutoUpdateEnvironment = 'test' | 'production' /** Directory name of one supported Desktop release target. */ export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64' -/** Public updater URL for one release target. */ +/** Public updater URL and object prefixes for one release target. */ export interface DesktopAutoUpdateConfig { readonly environment: DesktopAutoUpdateEnvironment readonly target: DesktopAutoUpdateTarget readonly origin: string readonly publicUrl: string readonly keyPrefix: string + readonly binaryKeyPrefix: string } /** Public updater URL and private COS destination for one upload target. */ @@ -62,12 +63,12 @@ export function desktopUpdateMetadataFilename( ): string /** - * Resolve the public updater URL for one release target. + * Resolve the public updater URL and object prefixes for one release target. * @param env - Packaging or upload environment. * @param platform - Target Node.js platform. * @param arch - Target Node.js architecture. * @returns Resolved updater configuration. - * @throws When the test deployment lacks a valid HTTPS origin. + * @throws When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID. */ export function resolveDesktopAutoUpdateConfig( env: NodeJS.ProcessEnv, @@ -81,7 +82,7 @@ export function resolveDesktopAutoUpdateConfig( * @param platform - Target Node.js platform. * @param arch - Target Node.js architecture. * @returns Resolved upload configuration. - * @throws When the selected deployment lacks a required origin or bucket, or the test origin is not HTTPS. + * @throws When the selected deployment lacks a bucket or valid updater configuration. */ export function resolveDesktopUploadConfig( env: NodeJS.ProcessEnv, diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index eebd20156a..464d079b4d 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -120,12 +120,12 @@ function httpsOrigin(value, name) { } /** - * Resolve the public updater URL for one release target. + * Resolve the public updater URL and object prefixes for one release target. * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string }} Resolved updater configuration. - * @throws {Error} When the test deployment lacks a valid HTTPS origin. + * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration. + * @throws {Error} When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID. */ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { const environment = resolveDesktopAutoUpdateEnvironment(env) @@ -137,12 +137,21 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { if (originEnvName === undefined) throw new Error('desktop auto-update: selected deployment has no origin') origin = httpsOrigin(requiredEnvironmentValue(env, originEnvName), originEnvName) } - const keyPrefix = `dsh-desk/feeds/${target}` + let releasePrefix = 'dsh-desk' + if (environment === 'test') { + const releaseId = requiredEnvironmentValue(env, 'DOWNLOAD_TEST_RELEASE_ID') + if (!/^[a-f0-9]{32}$/u.test(releaseId)) { + throw new Error('desktop auto-update: DOWNLOAD_TEST_RELEASE_ID must contain 32 lowercase hexadecimal characters') + } + releasePrefix += `/${releaseId}` + } + const keyPrefix = `${releasePrefix}/feeds/${target}` return { environment, target, origin, keyPrefix, + binaryKeyPrefix: `${releasePrefix}/bin/${target}`, publicUrl: `${origin}/${keyPrefix}/`, } } @@ -152,8 +161,8 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { * @param {NodeJS.ProcessEnv} env - Upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. - * @throws {Error} When the selected deployment lacks a required origin or bucket, or the test origin is not HTTPS. + * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. + * @throws {Error} When the selected deployment lacks a bucket or valid updater configuration. */ export function resolveDesktopUploadConfig(env, platform, arch) { const update = resolveDesktopAutoUpdateConfig(env, platform, arch) diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index c8a794e3c7..8fe35b60bf 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -12,7 +12,7 @@ import { resolveMacOSPackageSettings } from './macos-package-settings.mjs' import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) -const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u +const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index 0ec76dba3a..32f891dcc8 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -222,7 +222,7 @@ export async function createDesktopUploadPlan( const updaterInfo = updateFileInfo(metadata.files[0], `${metadataFilename}.files[0]`, `${base}.${updaterExtension}`) const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo) const artifacts: DesktopUploadArtifact[] = [] - const binaryPrefix = `dsh-desk/bin/${targetName}` + const binaryPrefix = update.binaryKeyPrefix if (target.platform === 'darwin') { const dmgPath = await requireArtifact(artifactsRoot, `${base}.dmg`) diff --git a/apps/desktop/tests/desktop-auto-update-environment.spec.ts b/apps/desktop/tests/desktop-auto-update-environment.spec.ts index 747657042b..c33a9384df 100644 --- a/apps/desktop/tests/desktop-auto-update-environment.spec.ts +++ b/apps/desktop/tests/desktop-auto-update-environment.spec.ts @@ -8,20 +8,48 @@ import { resolveDesktopUploadConfig, } from '../scripts/desktop-auto-update-environment.mjs' +const RELEASE_ID = '0123456789abcdef0123456789abcdef' + describe('desktop auto-update environment', () => { + it.each([undefined, 'test'])('requires a release ID for deployment %s', (deployment) => { + const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: deployment, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DOWNLOAD_TEST_COS_BUCKET: 'test-bucket' } + expect(() => resolveDesktopAutoUpdateConfig(environment, 'win32', 'x64')).toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + expect(() => resolveDesktopUploadConfig(environment, 'win32', 'x64')).toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + }) + + it.each(['', ' ', 'release-1', 'a'.repeat(31), 'a'.repeat(33), 'A'.repeat(32), 'g'.repeat(32), + '../feeds', '01234567-89ab-cdef-0123-456789abcdef', `${RELEASE_ID}/bin`])('rejects invalid test release ID %j', (id) => { + const environment = { DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DOWNLOAD_TEST_RELEASE_ID: id } + expect(() => resolveDesktopAutoUpdateConfig(environment, 'darwin', 'arm64')).toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + }) + + it.each([['darwin', 'arm64', 'mac-arm64'], ['darwin', 'x64', 'mac-x64'], ['win32', 'x64', 'win-x64']] as const) + ('uses one test release directory for %s %s feeds and binaries', (platform, arch, target) => { + expect(resolveDesktopAutoUpdateConfig({ DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID }, platform, arch)).toMatchObject({ + publicUrl: `https://updates.example.com/dsh-desk/${RELEASE_ID}/feeds/${target}/`, + keyPrefix: `dsh-desk/${RELEASE_ID}/feeds/${target}`, + binaryKeyPrefix: `dsh-desk/${RELEASE_ID}/bin/${target}`, + }) + }) + it('defaults packages and uploads to the test deployment', () => { expect(resolveDesktopAutoUpdateEnvironment({})).toBe('test') expect(resolveDesktopAutoUpdateConfig({ DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, }, 'darwin', 'arm64')).toEqual({ environment: 'test', target: 'mac-arm64', origin: 'https://desktop-updates.example.com', - publicUrl: 'https://desktop-updates.example.com/dsh-desk/feeds/mac-arm64/', - keyPrefix: 'dsh-desk/feeds/mac-arm64', + publicUrl: `https://desktop-updates.example.com/dsh-desk/${RELEASE_ID}/feeds/mac-arm64/`, + keyPrefix: `dsh-desk/${RELEASE_ID}/feeds/mac-arm64`, + binaryKeyPrefix: `dsh-desk/${RELEASE_ID}/bin/mac-arm64`, }) expect(resolveDesktopUploadConfig({ DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: 'test-download-bucket', }, 'darwin', 'arm64')).toMatchObject({ bucket: 'test-download-bucket', @@ -30,13 +58,15 @@ describe('desktop auto-update environment', () => { }) }) - it('selects the production URL for packages and bucket for uploads', () => { + it.each([undefined, RELEASE_ID, 'unused-test-value'])('keeps production paths independent of test release ID %s', (id) => { expect(resolveDesktopAutoUpdateConfig({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + DOWNLOAD_TEST_RELEASE_ID: id, }, 'win32', 'x64')).toMatchObject({ environment: 'production', target: 'win-x64', publicUrl: 'https://download.deepseek.com/dsh-desk/feeds/win-x64/', + binaryKeyPrefix: 'dsh-desk/bin/win-x64', }) expect(resolveDesktopUploadConfig({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', @@ -53,9 +83,11 @@ describe('desktop auto-update environment', () => { .toThrow(/DOWNLOAD_TEST_ORIGIN/u) expect(resolveDesktopAutoUpdateConfig({ DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, }, 'darwin', 'arm64').publicUrl).toContain('/mac-arm64/') expect(() => resolveDesktopUploadConfig({ DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, }, 'darwin', 'arm64')).toThrow(/DOWNLOAD_TEST_COS_BUCKET/u) expect(() => resolveDesktopUploadConfig({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index 7153f135a3..98d93e0c07 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -8,7 +8,8 @@ const WINDOWS = { platform: 'win32', arch: 'x64' } as const const MACOS = { platform: 'darwin', arch: 'arm64' } as const const POLICY = { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }) } -const RELEASE = { ...POLICY, DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com' } +const RELEASE = { ...POLICY, DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef' } const MAC_IDENTITY = { DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234' } async function withDirectory(action: (directory: string) => Promise): Promise { @@ -33,6 +34,7 @@ describe('Desktop local packaging configuration', () => { dsh_desktop_mandatory_update_prod_origin: 'https://stale.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'stale-pin', APPLE_ID: 'stale-apple-id', CSC_LINK: 'stale-certificate', DOWNLOAD_TEST_ORIGIN: 'https://stale.example.com', + DOWNLOAD_TEST_RELEASE_ID: 'a'.repeat(32), download_test_release_id: 'b'.repeat(32), dsh_desktop_windows_key_container: 'case-insensitive-stale-container', } expect(loadDesktopPackageEnvironment('win32', parent, directory)).toEqual({ @@ -49,6 +51,23 @@ describe('Desktop local packaging configuration', () => { }) }) + it.each(['win32', 'darwin'] as const)('owns the %s release ID in its platform file', async (platform) => { + await withDirectory(async (directory) => { + const settings = Object.entries(RELEASE).map(([name, value]) => `${name}='${value}'`).join('\n') + '\n' + const file = join(directory, platform === 'win32' ? '.env.windows' : '.env.macos') + const parent = { DOWNLOAD_TEST_RELEASE_ID: 'a'.repeat(32) } + await writeFile(file, settings) + expect(loadDesktopPackageEnvironment(platform, parent, directory).DOWNLOAD_TEST_RELEASE_ID).toBe(RELEASE.DOWNLOAD_TEST_RELEASE_ID) + await writeFile(file, settings.replace(`DOWNLOAD_TEST_RELEASE_ID='${RELEASE.DOWNLOAD_TEST_RELEASE_ID}'\n`, '')) + const missing = loadDesktopPackageEnvironment(platform, parent, directory) + expect(missing.DOWNLOAD_TEST_RELEASE_ID).toBeUndefined() + expect(() => { + validateDesktopPackageEnvironment(missing, platform === 'win32' ? WINDOWS : MACOS) + }).toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + expect(parent.DOWNLOAD_TEST_RELEASE_ID).toBe('a'.repeat(32)) + }) + }) + it('loads mandatory update origins and options only from the platform file without changing its parent', async () => { await withDirectory(async (directory) => { const windowsPolicy = JSON.stringify({ intervalMs: 5000, allowedPageOrigins: ['https://download.example.invalid'] }) diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index d65a44a923..bf4d68843c 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -12,6 +12,7 @@ import type { DesktopPackageTargetName } from '../scripts/package-target.ts' const temporaryDirectories: string[] = [] const TEST_ORIGIN = 'https://desktop-updates.example.com' const TEST_BUCKET = 'test-download-bucket' +const RELEASE_ID = '0123456789abcdef0123456789abcdef' const PRODUCTION_BUCKET = 'production-download-bucket' const require = createRequire(import.meta.url) const { createBlockmap } = require('app-builder-lib/out/targets/differentialUpdateInfoBuilder.js') as { @@ -54,7 +55,7 @@ async function fixture( target, version, environment, - publicUrl: `${origin}/dsh-desk/feeds/${target}/`, + publicUrl: `${origin}/dsh-desk/${environment === 'test' ? `${RELEASE_ID}/` : ''}feeds/${target}/`, })}\n`) if (os === 'mac') { @@ -64,6 +65,7 @@ async function fixture( await writeFile(join(artifactsRoot, `${base}.dmg`), 'notarized DMG fixture') await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin')), `${JSON.stringify({ version, + path: `${base}.zip`, files: [{ url: `${base}.zip`, size: Buffer.byteLength(zip), sha512: digest(zip) }], })}\n`) } @@ -75,6 +77,7 @@ async function fixture( expect(Object.hasOwn(info, 'blockMapSize')).toBe(false) await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32')), `${JSON.stringify({ version, + path: `${base}.exe`, files: [{ url: `${base}.exe`, ...info, @@ -89,6 +92,7 @@ async function fixture( ? { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, } : { @@ -132,7 +136,7 @@ describe('desktop upload plan', () => { expect(plan).toMatchObject({ environment: 'test', version: '1.2.3', - publicUrl: 'https://desktop-updates.example.com/dsh-desk/feeds/mac-arm64/', + publicUrl: `https://desktop-updates.example.com/dsh-desk/${RELEASE_ID}/feeds/mac-arm64/`, bucket: TEST_BUCKET, }) expect(plan.artifacts.map(artifact => artifact.filename)).toEqual([ @@ -147,6 +151,34 @@ describe('desktop upload plan', () => { }) }) + it.each(['mac-arm64', 'mac-x64', 'win-x64'] as const)('publishes every %s object and YAML reference inside the test release directory', async (target) => { + const paths = await fixture(target) + const plan = await createDesktopUploadPlan(target, paths) + const prefix = `dsh-desk/${RELEASE_ID}` + const payload = plan.artifacts.find(artifact => artifact.filename.endsWith(target === 'win-x64' ? '.exe' : '.zip'))! + for (const artifact of plan.artifacts) { + expect(artifact.key).toBe(`${prefix}/${artifact.channelMetadata ? 'feeds' : 'bin'}/${target}/${artifact.filename}`) + if (artifact.channelMetadata) { + expect(load(artifact.contents!)).toMatchObject({ + path: `${TEST_ORIGIN}/${payload.key}`, + files: [{ url: `${TEST_ORIGIN}/${payload.key}` }], + }) + } + } + await expect(JSON.stringify(plan.artifacts.map(({ key, contents }) => ({ key, contents })), null, 2) + '\n') + .toMatchFileSnapshot(`./expected/test-release-upload-${target}.json`) + }) + + it('rejects a changed or missing release ID before uploading a completed package', async () => { + const paths = await fixture('mac-arm64') + await expect(createDesktopUploadPlan('mac-arm64', { + ...paths, environment: { ...paths.environment, DOWNLOAD_TEST_RELEASE_ID: 'a'.repeat(32) }, + })).rejects.toThrow(/completion record/u) + await expect(createDesktopUploadPlan('mac-arm64', { + ...paths, environment: { ...paths.environment, DOWNLOAD_TEST_RELEASE_ID: undefined }, + })).rejects.toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + }) + it('uploads the prerelease channel metadata emitted by electron-builder', async () => { const paths = await fixture('mac-arm64', '1.2.3-alpha.4') const plan = await createDesktopUploadPlan('mac-arm64', paths) @@ -193,6 +225,7 @@ describe('desktop upload plan', () => { environment: { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, }, })).rejects.toThrow(/completion record.*test/u) diff --git a/apps/desktop/tests/expected/test-release-upload-mac-arm64.json b/apps/desktop/tests/expected/test-release-upload-mac-arm64.json new file mode 100644 index 0000000000..515f0234f9 --- /dev/null +++ b/apps/desktop/tests/expected/test-release-upload-mac-arm64.json @@ -0,0 +1,19 @@ +[ + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.dmg" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip.blockmap" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/nightly-mac.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/latest-mac.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" + } +] diff --git a/apps/desktop/tests/expected/test-release-upload-mac-x64.json b/apps/desktop/tests/expected/test-release-upload-mac-x64.json new file mode 100644 index 0000000000..5039a3aede --- /dev/null +++ b/apps/desktop/tests/expected/test-release-upload-mac-x64.json @@ -0,0 +1,19 @@ +[ + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.dmg" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip.blockmap" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/nightly-mac.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/latest-mac.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" + } +] diff --git a/apps/desktop/tests/expected/test-release-upload-win-x64.json b/apps/desktop/tests/expected/test-release-upload-win-x64.json new file mode 100644 index 0000000000..cb60e0ce29 --- /dev/null +++ b/apps/desktop/tests/expected/test-release-upload-win-x64.json @@ -0,0 +1,16 @@ +[ + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe.blockmap" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/nightly.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\n size: 30\n sha512: >-\n eVumVOW1yvT3fAgGKUnliEzSfhmn8jUfzpNaUPufU1yekWrwJe8wRPC+JOrHH2Hmc/eLynwxjX1yIiER8tVOTA==\n" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/latest.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\n size: 30\n sha512: >-\n eVumVOW1yvT3fAgGKUnliEzSfhmn8jUfzpNaUPufU1yekWrwJe8wRPC+JOrHH2Hmc/eLynwxjX1yIiER8tVOTA==\n" + } +] diff --git a/apps/desktop/tests/installed-update-builder.spec.ts b/apps/desktop/tests/installed-update-builder.spec.ts index 55ad22fd5d..089b04deef 100644 --- a/apps/desktop/tests/installed-update-builder.spec.ts +++ b/apps/desktop/tests/installed-update-builder.spec.ts @@ -18,7 +18,7 @@ vi.mock('../scripts/windows-sign.mjs', () => ({ })) const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const -const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.deepseek.com', +const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.deepseek.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), DOWNLOAD_TEST_COS_BUCKET: 'bj-toc-download-test-1320056602' } diff --git a/apps/desktop/tests/installer-packaging.spec.ts b/apps/desktop/tests/installer-packaging.spec.ts index 1dcfcb24fd..75d6b02d7e 100644 --- a/apps/desktop/tests/installer-packaging.spec.ts +++ b/apps/desktop/tests/installer-packaging.spec.ts @@ -31,7 +31,7 @@ describe('installer preparation preserves application dependencies', () => { DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', APPLE_KEYCHAIN_PROFILE: 'installer-test', - DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', } for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value) try { diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 2bd63adbaa..4d71d3048b 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -22,7 +22,7 @@ const RELEASE_ENVIRONMENT = { APPLE_API_KEY: '/private/credentials/AuthKey_TEST123456.p8', APPLE_API_KEY_ID: 'TEST123456', APPLE_API_ISSUER: '11111111-2222-3333-4444-555555555555', - DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', } function portablePath(value: string): string { @@ -71,7 +71,7 @@ describe('desktop macOS release signature', () => { }, publish: [{ provider: 'generic', - url: 'https://desktop-updates.example.com/dsh-desk/feeds/mac-arm64/', + url: 'https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/', channel: 'nightly', }], }) diff --git a/apps/desktop/tests/package-macos.spec.ts b/apps/desktop/tests/package-macos.spec.ts index 3b780b8b06..e8fb5dcf34 100644 --- a/apps/desktop/tests/package-macos.spec.ts +++ b/apps/desktop/tests/package-macos.spec.ts @@ -17,7 +17,7 @@ const environment = { DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', APPLE_KEYCHAIN_PROFILE: 'fixture-profile', - DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', } function barrier() { @@ -33,7 +33,7 @@ async function fixture(arch: 'arm64' | 'x64' = 'arm64') { await mkdir(join(appPath, 'Contents', 'Resources'), { recursive: true }) await writeFile(join(appPath, 'payload'), 'signed content') await writeMacOSAppUpdateConfig(join(appPath, 'Contents', 'Resources'), { - publicUrl: `https://desktop-updates.example.com/dsh-desk/feeds/mac-${arch}/`, + publicUrl: `https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-${arch}/`, }, 'deepseek-harness-updater') const version = '1.2.3-alpha.1' const base = `deepseek-harness-${version}-mac-${arch}` @@ -107,7 +107,7 @@ describe('parallel macOS artifacts', () => { .toEqual({ payload: 'signed content', appTicket: false }) expect(await readFile(join(f.appPath, 'ticket'), 'utf8')).toBe('accepted') expect(await readFile(join(f.appPath, 'Contents', 'Resources', 'app-update.yml'), 'utf8')) - .toContain(`/dsh-desk/feeds/mac-${arch}/`) + .toContain(`/dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-${arch}/`) expect((await readdir(f.root)).sort()).toEqual(['artifacts']) expect(f.apple.verifySignature).toHaveBeenCalledTimes(4) expect(f.apple.verifyNotarization).toHaveBeenCalledTimes(1) diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index 5b8880ce6a..9e8f98b4c8 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -29,7 +29,7 @@ vi.mock('node:fs', async importOriginal => ({ afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks() }) const environment = { DSH_DESKTOP_APP_ID: 'com.example.test', DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', - DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' } + DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' } function supervisor(failure?: string) { vi.stubEnv('npm_execpath', 'fixture-pnpm.cjs') @@ -59,6 +59,8 @@ it('requires one signing preflight before building, then records only the comple } } expect(writeFileSync).toHaveBeenCalledOnce() + const record = JSON.parse(vi.mocked(writeFileSync).mock.calls[0]![1] as string) as { publicUrl: string } + expect(record.publicUrl).toBe('https://updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/') }) it('initializes shared storage only after acquiring the preflight stage lock', async () => { diff --git a/apps/desktop/tests/upload-with-credentials.spec.ts b/apps/desktop/tests/upload-with-credentials.spec.ts index 9a6a93f0a8..29688621ca 100644 --- a/apps/desktop/tests/upload-with-credentials.spec.ts +++ b/apps/desktop/tests/upload-with-credentials.spec.ts @@ -86,7 +86,7 @@ afterEach(async () => { describe('credential launcher destination', () => { const fileEnvironment = { - DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.example.com', + DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', DOWNLOAD_TEST_COS_BUCKET: 'test-bucket', DOWNLOAD_TEST_COS_SECRET_ID: 'stale-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'stale-key', } const injectedEnvironment = { diff --git a/apps/desktop/tests/windows-asar-unpack.spec.ts b/apps/desktop/tests/windows-asar-unpack.spec.ts index ddfaf15de2..2d5c79563b 100644 --- a/apps/desktop/tests/windows-asar-unpack.spec.ts +++ b/apps/desktop/tests/windows-asar-unpack.spec.ts @@ -168,7 +168,7 @@ it.each([true, false])('validates the real builder hook for unsigned=%s', async DSH_DESKTOP_APP_ID: 'com.example.unpack', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0', - DSH_DESKTOP_WINDOWS_CER_FILE: certificate, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DSH_DESKTOP_WINDOWS_CER_FILE: certificate, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', }, 'win32', 'x64', input.source) // Qualification replaces files after creating the base configuration; builder owns a separate config object. input.config.asarUnpack = [...config.asarUnpack] diff --git a/apps/desktop/tests/windows-sign.spec.ts b/apps/desktop/tests/windows-sign.spec.ts index 1b1440b0fe..e5d5a7cd3f 100644 --- a/apps/desktop/tests/windows-sign.spec.ts +++ b/apps/desktop/tests/windows-sign.spec.ts @@ -159,7 +159,7 @@ describe('Windows token signing', () => { await writeFile(signTool, 'fixture') await writeFile(path, 'fixture') validateDesktopPackageEnvironment({ - DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), DSH_DESKTOP_WINDOWS_CER_FILE: certificateFile, DSH_DESKTOP_WINDOWS_SIGNTOOL: signTool, From c1ee42cd9cbaabdeddf39df1d429db2f5c606e84 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 14:28:15 +0800 Subject: [PATCH 132/284] fix: portal file action tooltips outside clipped cards --- apps/web/tests/present.e2e.ts | 12 +++++++ .../src/client/OpenTargetButton.tsx | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/Tooltip.module.css | 2 ++ packages/client/ui-primitives/src/Tooltip.tsx | 36 ++++++++++--------- .../tests/tooltip.client.spec.tsx | 15 ++++++++ 8 files changed, 54 insertions(+), 21 deletions(-) diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index fb23c2db8e..4df8bfae81 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -132,6 +132,18 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con await row.waitFor() await expect.poll(() => row.getByRole('button', { name: 'More ways to open' }).count()).toBe(2) expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt') + const card = row.locator('[data-presented-file]').filter({ hasText: 'report.txt' }) + await card.getByRole('button', { name: 'Open in Test Editor', exact: true }).hover() + await page.getByRole('tooltip', { name: 'Open in Test Editor', exact: true }).waitFor() + expect(await page.getByRole('tooltip').evaluate((tooltip) => { + const rect = tooltip.getBoundingClientRect() + const previous = tooltip.style.pointerEvents + tooltip.style.pointerEvents = 'auto' + const visible = document.elementFromPoint(rect.left + rect.width / 2, rect.bottom - 1) === tooltip + tooltip.style.pointerEvents = previous + return tooltip.parentElement === document.body && visible + })).toBe(true) + await page.mouse.move(0, 0) const beforePreview = (await opened()).length const column = page.locator('[data-rightbar-col]') for (const [name, content] of [['report.txt', 'EDITED_REPORT'], ['说明.txt', 'EDITED_NOTE']] as const) { diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index e9284d9293..8cc56c74e5 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -113,7 +113,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { anchor={(
      - + +
      ) + const anchor = screen.getByRole('button', { name: 'File action' }) + fireEvent.mouseEnter(anchor) + const tooltip = screen.getByRole('tooltip') + expect(tooltip.parentElement).toBe(document.body) + expect(view.container.contains(anchor)).toBe(true) + expect(view.container.contains(tooltip)).toBe(false) + view.unmount() + expect(screen.queryByRole('tooltip')).toBeNull() +}) From 17a52a8fe382ac220c4029be4186e9a72a4e0f6f Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 20 Sep 2026 14:30:02 +0800 Subject: [PATCH 133/284] fix(subagent): retain unreadable children in migrated catalogs --- ...leased-session-format-migrations.i18n.yaml | 4 +- ...8-31-released-session-format-migrations.md | 2 +- ...1-released-session-format-migrations.zh.md | 2 +- ...session-local-subagent-migration.i18n.yaml | 4 +- ...-09-19-session-local-subagent-migration.md | 8 +- ...-19-session-local-subagent-migration.zh.md | 8 +- ...ncomplete-child-catalog-evidence.i18n.yaml | 4 +- ...19-v3-incomplete-child-catalog-evidence.md | 6 +- ...v3-incomplete-child-catalog-evidence.zh.md | 6 +- apps/web/tests/preset-migration.snapshot.ts | 16 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 69 ++++- docs/persistence-catalog.zh.md | 69 ++++- ...2026-09-20-unknown-child-catalog.i18n.yaml | 6 + .../2026-09-20-unknown-child-catalog.md | 48 +++ ...26-09-20-unknown-child-catalog.schema.json | 104 +++++++ .../2026-09-20-unknown-child-catalog.zh.md | 48 +++ .../historical-formats/README.i18n.yaml | 4 +- .../historical-formats/README.md | 2 +- .../historical-formats/README.zh.md | 2 +- docs/persistence-schema.json | 283 ++++++++++++++++++ docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 4 +- docs/subsystems/subagent.zh.md | 4 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../src/client/sessions/session.ts | 5 +- .../api/session-controller/src/history.ts | 2 +- packages/api/session-controller/src/types.ts | 2 +- .../tests/manager.client.spec.ts | 19 ++ .../tests/transport.host.spec.ts | 10 + packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 2 +- packages/client/ui-plan/README.zh.md | 2 +- packages/client/ui-plan/src/client/plan.ts | 2 +- .../tests/plan-resource.client.spec.ts | 2 +- packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 2 + packages/client/ui-subagent/README.zh.md | 2 + .../src/client/SubagentHeaderLineage.tsx | 3 +- .../src/client/SubagentReadOnlyComposer.tsx | 4 +- .../client/ui-subagent/src/client/index.ts | 1 + .../client/ui-subagent/src/client/locales.ts | 4 + .../src/client/sidebar-chat/index.tsx | 2 +- .../tests/browser-plugin.client.spec.ts | 2 + .../tests/conversation-ui.client.spec.tsx | 17 +- .../tests/sidebar-chat.client.spec.tsx | 4 +- .../core/session/src/known-event-types.ts | 1 + .../extensions/tool-cordis/src/api-catalog.ts | 4 +- packages/sdk/client/tests/sdk-client.spec.ts | 4 + .../session-format-v3-to-v4/README.i18n.yaml | 4 +- .../session/session-format-v3-to-v4/README.md | 12 +- .../session-format-v3-to-v4/README.zh.md | 12 +- .../session-format-v3-to-v4/src/facts.ts | 15 + .../session-format-v3-to-v4/src/migration.ts | 7 +- .../session-format-v3-to-v4/src/validation.ts | 6 +- .../tests/catalog-completion.spec.ts | 13 +- .../tests/validation.spec.ts | 17 ++ .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../src/catalog-migration.ts | 4 + .../session-persistence-jsonl/src/index.ts | 2 +- .../tests/catalog-migration.spec.ts | 45 ++- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- packages/subagent/subagent/src/catalog.ts | 36 ++- .../subagent/subagent/src/control-types.ts | 3 +- .../subagent/subagent/src/projection-types.ts | 1 + .../subagent/subagent/tests/catalog.spec.ts | 3 + python/sdk/tests/test_client.py | 9 + 73 files changed, 908 insertions(+), 120 deletions(-) create mode 100644 docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml create mode 100644 docs/persistence-changes/2026-09-20-unknown-child-catalog.md create mode 100644 docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json create mode 100644 docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml index d31a7e21b8..04fa599e6f 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md -2026-08-31-released-session-format-migrations.md: 21c95503ad05abf54b00c9cb20bec563cbd885ae -2026-08-31-released-session-format-migrations.zh.md: 46e26b14d10adcc66cf4f0c0aea7573bae3f235a +2026-08-31-released-session-format-migrations.md: e21793e0ea3d0c5376cbb3949c5d6ef599b9afb1 +2026-08-31-released-session-format-migrations.zh.md: 637d8fcf0206661df9a6f1b3b470ad8a55746720 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md index 21c95503ad..e21793e0ea 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md @@ -131,7 +131,7 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr ### Parent catalog prerequisites -Runtime child-failure handling in this paragraph is partially superseded by [session-local catalog preparation](../bug-fix/2026-09-19-session-local-subagent-migration.md): opening the parent still completes its catalog from direct-child reads, but unreadable children do not block healthy parent history. V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md). +Runtime child-failure handling in this paragraph is partially superseded by [session-local catalog preparation](../bug-fix/2026-09-19-session-local-subagent-migration.md): opening the parent still completes its catalog from direct-child reads, but unreadable children do not block healthy parent history. V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; new complete entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs and retain unknown-mode membership without inventing discovery fields, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. Unreadable or unsupported headers are excluded from discovery; failures reading a recognized child preserve its header identity as unknown-mode membership. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The collector reports child decoding and descriptor failures as warnings with the child path; cancellation and source-consistency checks still stop preparation. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md). Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md index 46e26b14d1..637d8fcf02 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md @@ -131,7 +131,7 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o ### 父目录前置事实 -本段的运行时子会话错误处理部分由[逐会话目录准备](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代:打开父会话仍通过读取直属子会话补齐目录,但不可读子会话不再阻断健康父历史。V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。 +本段的运行时子会话错误处理部分由[逐会话目录准备](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代:打开父会话仍通过读取直属子会话补齐目录,但不可读子会话不再阻断健康父历史。V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;新增完整记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志与未知模式成员关系,不编造发现字段,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。不可读或不支持的 header 不参与发现;读取已识别子会话失败时,其 header 身份保留为未知模式成员关系。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。收集器将子解码和 descriptor 失败报告为带子路径的警告;取消与来源一致性检查仍中止准备。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。 原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml index 7d4ff08c8e..fba52db72b 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md -2026-09-19-session-local-subagent-migration.md: ee06345246f69b7a176170105d77e3e9ecca8ac8 -2026-09-19-session-local-subagent-migration.zh.md: 050f64877860dd8f63244a34a8ba9d8d3945189c +2026-09-19-session-local-subagent-migration.md: fcdbb7830a12bca645b2a20c68178fff97d74626 +2026-09-19-session-local-subagent-migration.zh.md: bd6aaf5c434ec109cae2d3c68ddd29249debabf2 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md index ee06345246..fcdbb7830a 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md @@ -10,9 +10,9 @@ Opening a historical parent requires discovery facts from its direct children. A ## Decision -Opening A discovers candidate B Sessions through headers, reads their own descriptors, and completes A's catalog. Historical child reads use the V0–V3 catalog; current child reads use native validation. These reads neither prepare B's own catalog nor publish B's successor. Opening B separately performs B's migration and catalog preparation. Frontend discovery and `list_agents` retain their parent-catalog behavior and existing result types. +Opening A discovers candidate B Sessions through headers, reads their own descriptors, and completes A's catalog. Historical child reads use the V0–V3 catalog; current child reads use native validation. These reads neither prepare B's own catalog nor publish B's successor. Opening B separately performs B's migration and catalog preparation. Frontend discovery retains its parent-catalog source. Unknown-mode rows remain browsable; `list_agents` continues to select only known continuable children. -JSONL omits unreadable or unsupported headers from discovery. A child-body or descriptor-field failure produces a warning with the child path and omits that child's supplemental fact, while preserving healthy siblings and existing parent entries. Cancellation and source-consistency failures still stop the operation. Inspected child revisions, including failed decodes, are rechecked before reuse and publication so repairs cannot silently reuse stale evidence. Released predecessors remain byte-identical. +JSONL omits unreadable or unsupported headers from discovery. A child-body or descriptor-field failure produces a warning with the child path and retains the child's header identity. If the parent lacks a complete entry, migration appends `subagent/catalog-unknown`, projected as `mode: 'unknown'` alongside healthy siblings. Existing complete entries remain authoritative. Cancellation and source-consistency failures still stop the operation. Inspected child revisions, including failed decodes, are rechecked before reuse and publication so repairs cannot silently reuse stale evidence. Released predecessors remain byte-identical. This partially supersedes child-failure propagation in [released migrations](../architecture/2026-08-31-released-session-format-migrations.md) and [incomplete child evidence](2026-09-19-v3-incomplete-child-catalog-evidence.md). Their format conversion, descriptor cardinality, conflict validation, and immutable publication rules remain active. @@ -20,12 +20,14 @@ This partially supersedes child-failure propagation in [released migrations](../ **Skip catalog completion and discover children in each consumer.** This introduces incomplete tool results and additional UI states despite the parent's readable child descriptors. The parent catalog already serves these consumers. +**Drop unreadable children from the catalog.** Published parents would lose their navigation entries. Header identity is sufficient to retain membership without inventing a mode or label. + **Recursively prepare child catalogs.** A only needs B's own descriptor. Preparing B's descendants adds unrelated work and failure dependencies. **Refuse A when one child cannot be read.** The child's discovery metadata does not justify withholding the parent's healthy history. Warnings retain the child location without inventing mode or label fields. ## Consequences -Initial parent preparation still scans headers and reads direct-child bodies one at a time. It does not claim to eliminate this I/O. Published V4 parents use their catalog without rescanning children. A failed child's missing catalog entry is not automatically repaired after parent publication; the child remains addressable by id, as with other incomplete historical descriptor evidence. +Initial parent preparation still scans headers and reads direct-child bodies one at a time. It does not claim to eliminate this I/O. Published V4 parents use their catalog without rescanning children. Unknown entries are not automatically rewritten after publication. Opening a child retries its log and determines browsing capabilities from its actual descriptor, or reports the child-local error. A separate required-on-read event preserves the finalized V4 catalog payload while making unknown membership durable; older readers that lack this event refuse the log. Raw and compressed tests cover catalog completion, child-local errors, source changes and repairs, unchanged child generations, and deferred child-catalog preparation. The shipped Web migration snapshot checks the parent catalog with a corrupt sibling, then opens the child independently. Its additional historical child raises the corpus budget from ten to eleven roles; the guard rejects a twelfth role. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md index 050f648778..bd6aaf5c43 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -打开 A 时通过 header 发现候选 B Session,读取其自身 descriptor,并补齐 A 的 catalog。历史子日志使用 V0–V3 catalog 读取,当前子日志使用原生校验。这些读取不准备 B 自己的 catalog,也不发布 B 的后继。单独打开 B 时才执行 B 的迁移与目录准备。前端发现和 `list_agents` 保留读取父 catalog 的行为及现有结果类型。 +打开 A 时通过 header 发现候选 B Session,读取其自身 descriptor,并补齐 A 的 catalog。历史子日志使用 V0–V3 catalog 读取,当前子日志使用原生校验。这些读取不准备 B 自己的 catalog,也不发布 B 的后继。单独打开 B 时才执行 B 的迁移与目录准备。前端发现继续读取父 catalog。模式未知的条目仍可浏览;`list_agents` 继续只选择已知可继续的子会话。 -JSONL 在发现时跳过不可读或不支持的 header。子正文或 descriptor 字段失败会产生带子路径的警告,并跳过该子的补充事实,同时保留健康兄弟项和已有父目录项。取消与来源一致性失败仍中止操作。复用和发布前会重新检查已检查子日志的修订,包括解码失败的日志,使修复不能静默复用旧证据。已发布前驱保持逐字节不变。 +JSONL 在发现时跳过不可读或不支持的 header。子正文或 descriptor 字段失败会产生带子路径的警告,并保留该子的 header 身份。父目录没有完整条目时,迁移追加 `subagent/catalog-unknown`,与健康兄弟项一同投影为目录条目,其模式为 `mode: 'unknown'`。已有完整条目仍具有权威性。取消与来源一致性失败仍中止操作。复用和发布前会重新检查已检查子日志的修订,包括解码失败的日志,使修复不能静默复用旧证据。已发布前驱保持逐字节不变。 本决策部分取代[已发布迁移](../architecture/2026-08-31-released-session-format-migrations.zh.md)和[不完整子证据](2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)中的子错误传播规则。其格式转换、descriptor 数量、冲突校验和不可变发布规则仍有效。 @@ -20,12 +20,14 @@ JSONL 在发现时跳过不可读或不支持的 header。子正文或 descripto **跳过目录补齐,让每个消费者发现子项。** 父会话明明有可读的子 descriptor,却会因此产生不完整工具结果和额外 UI 状态。父 catalog 已经服务这些消费者。 +**从目录删除不可读子会话。** 已发布父会话会丢失相应导航入口。Header 身份足以保留成员关系,无须编造模式或标签。 + **递归准备子目录。** A 只需要 B 自身的 descriptor。准备 B 的后代会增加无关工作与失败依赖。 **一个子会话不可读就拒绝 A。** 子会话的发现元数据不足以成为阻断健康父历史的理由。警告保留子位置,不虚构 mode 或 label 字段。 ## 后果 -首次父准备仍扫描 header 并逐个读取直属子正文,不宣称消除此 I/O。已发布 V4 父会话使用其 catalog,不重新扫描子日志。父发布后不会自动修复失败子项缺失的目录记录;该子会话仍可按 id 寻址,与其他不完整历史 descriptor 证据一致。 +首次父准备仍扫描 header 并逐个读取直属子正文,不宣称消除此 I/O。已发布 V4 父会话使用其 catalog,不重新扫描子日志。发布后不自动重写未知条目。打开子会话会重试其日志,根据实际 descriptor 确定浏览能力,或报告该子会话的错误。独立的必读事件保留已定稿 V4 目录载荷,同时持久记录未知成员;不认识该事件的旧读取器拒绝日志。 原始和压缩测试覆盖目录补齐、子会话错误隔离、来源变化与修复、子代际不变和延后的子目录准备。随产品 Web 迁移快照在损坏兄弟存在时检查父目录,再独立打开子会话。额外的历史子角色使语料预算由十个增加到十一个;守卫拒绝第十二个角色。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml index 00bf6ce0fd..bf6d6c2fd0 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md -2026-09-19-v3-incomplete-child-catalog-evidence.md: 30aac77c231b34f04daaa8095f19fbba17c02ed8 -2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: f4f8ee9870e697f675c838284401f5dd943a389e +2026-09-19-v3-incomplete-child-catalog-evidence.md: 400a84f376a7babc0a95492ca33a3bcd0761cd79 +2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: 535951630bc600647724d50107e74e83ab626739 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md index 30aac77c23..400a84f376 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md @@ -10,9 +10,9 @@ A valid V3 child log can have no own descriptor, an unsupported descriptor versi ## Decision -The V3→V4 migration appends a missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions contribute no new catalog fact. Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md). +The V3→V4 migration appends a complete missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions retain unknown-mode membership through [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md). Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md). -The JSONL failure-propagation rules below are partially superseded by [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md); the converter still validates supplied facts and conflicts. Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. Invalid fields in that descriptor, corrupt logs, unreadable membership, unsupported selected generations, and changed source revisions retain their existing failures. +The JSONL failure-propagation rules below are partially superseded by [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md); the converter still validates supplied facts and conflicts. Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. JSONL isolates invalid descriptor fields and corrupt child bodies while retaining known header identity; unreadable or unsupported headers are excluded from discovery. Changed source revisions still refuse publication. ## Alternatives considered @@ -24,6 +24,6 @@ The JSONL failure-propagation rules below are partially superseded by [session-l ## Consequences -A historical child without a usable descriptor remains readable by id but may be absent from its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later catalog repair is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks. +A historical child with a readable header but no usable descriptor retains unknown-mode membership in its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later mode backfill is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks. Unit, JSONL read/write, and Preview packing tests cover unavailable evidence, unchanged source bytes, retained entries, identity conflicts, and evidence changes before publication. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md index f4f8ee9870..535951630b 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md @@ -10,9 +10,9 @@ Status: implemented ## Decision -V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字段时,追加缺失的父目录项。其他 descriptor 数量及不受支持的版本不贡献新的目录事实。已有父目录项和子事件保持完整。这只替代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的证据缺失拒绝规则。 +V3→V4 迁移仅在恰好一个受支持的自身子 descriptor 提供发现字段时,追加完整的缺失父目录项。其他 descriptor 数量和不支持的版本通过[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)保留模式未知的成员关系。已有父目录项和子事件保持不变。这取代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中对缺失证据的拒绝。 -下述 JSONL 错误传播规则部分由[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)取代;转换器仍校验提供的事实及冲突。已知子创建时间仍必须与已有父目录项一致。只有恰好一个受支持的自身 descriptor 时,才校验其字段并比较 mode/label。该 descriptor 字段无效、日志损坏、成员关系不可读、所选代际不受支持以及源修订变化仍保留原有失败行为。 +JSONL 错误传播规则由[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)部分取代;转换器仍校验已提供事实及冲突。已知子创建时间必须匹配已有父目录项。只有恰好一个受支持的自身 descriptor 时才检查 mode/label 和 descriptor 字段。JSONL 隔离无效 descriptor 字段和损坏子正文,并保留已知 header 身份;不可读或不支持的 header 不参与发现。来源修订变化仍拒绝发布。 ## Alternatives considered @@ -24,6 +24,6 @@ V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字 ## Consequences -没有可用 descriptor 的历史子 Session 仍可按 id 读取,但可能不出现在其父级的直属子目录中。已有目录项仍可见。V4 后继发布后,打开它不会重新扫描历史子日志;后续自动修复目录不属于本迁移。准备过程仍会在发布前复查子修订,因此新出现的证据无法悄悄绕过源一致性检查。 +历史子会话具有可读 header 但没有可用 descriptor 时,在父直属子目录中保留未知模式成员关系。已有目录项仍可见。V4 后继发布后,打开它不重新扫描历史子日志;自动补齐后续模式不属于本迁移。准备过程仍在发布前重新检查子修订,因此新出现的证据不能静默绕过来源一致性检查。 单元、JSONL 读写和 Preview 打包测试覆盖不可用证据、源字节不变、条目保留、身份冲突以及发布前的证据变化。 diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index 16df0a6b84..1f1b81ed90 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -71,6 +71,7 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t .filter(event => event.type === 'agent-preset/selected') .map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : []) expect(await scaffold.ctx.subagents.listChildren(id)).toEqual([ + { id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' }, { id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' }, ]) @@ -80,7 +81,7 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t const address = { kind: 'subagent', parentSessionId: id, childSessionId: childId, mode: 'one-shot' } as const const childPage = await scaffold.ctx.sessionController.page({ address, throughSeq: childRows.length - 1 }, signal) expect(childPage.records.map(record => record.event.type)).toEqual(childRows.map(row => row['type'])) - await expect(scaffold.ctx.sessionController.page({ address: { ...address, childSessionId: brokenId }, throughSeq: 1 }, signal)) + await expect(scaffold.ctx.sessionController.page({ address: { ...address, childSessionId: brokenId, mode: 'unknown' }, throughSeq: 1 }, signal)) .rejects.toThrow() await expect(scaffold.ctx.sessionController.page({ address: { kind: 'session', sessionId: id }, throughSeq: rows.length - 1 }, signal)) .resolves.toMatchObject({ hasMore: false }) @@ -107,14 +108,17 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t && (row['data'] as { agentPreset: string }).agentPreset === 'code' ? { ...row, data: { agentPreset: 'ptc' } } : row), - { type: 'subagent/catalog', seq: rows.length, time: rows.at(-1)?.['time'], data: { + { type: 'subagent/catalog-unknown', seq: rows.length, time: rows.at(-1)?.['time'], data: { + version: 0, childId: brokenId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'unknown', + } }, + { type: 'subagent/catalog', seq: rows.length + 1, time: rows.at(-1)?.['time'], data: { version: 0, childId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child', } }, // Agent activation closes its restored prefix with a fresh seed marker. - { type: 'session/end-seed', seq: rows.length + 1, time: 0, data: {} }, - { type: 'permission/preset', seq: rows.length + 2, time: 0, data: { preset: 'workspace-write' } }, - { type: 'sandbox/mode', seq: rows.length + 3, time: 0, data: { mode: 'workspace-write' } }, - { type: 'approval/policy', seq: rows.length + 4, time: 0, data: { policy: 'ask' } }, + { type: 'session/end-seed', seq: rows.length + 2, time: 0, data: {} }, + { type: 'permission/preset', seq: rows.length + 3, time: 0, data: { preset: 'workspace-write' } }, + { type: 'sandbox/mode', seq: rows.length + 4, time: 0, data: { mode: 'workspace-write' } }, + { type: 'approval/policy', seq: rows.length + 5, time: 0, data: { policy: 'ask' } }, ].map(row => JSON.stringify(row)).join('\n') + '\n' const context = { sessionIds: [id], cwd: scaffold.workspaceCwd } expect(normalizeSessionSnapshots([published], context)).toEqual(normalizeSessionSnapshots([expected], context)) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ae6c4b51eb..71aa4ee12b 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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 docs/persistence-catalog.md -persistence-catalog.md: 43b4f86125847625026bd5619216ccdc7be926c1 -persistence-catalog.zh.md: c655d97086fa60630aa5dae1bf3f93be595f179b +persistence-catalog.md: 2f483ff9dfa831765a25f1ddd20399e47f150b8c +persistence-catalog.zh.md: 782c30226d9df359b46f3aca02451478307d067e diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 43b4f86125..2f483ff9df 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -58,6 +58,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871) | +| `event:subagent/catalog-unknown` | event | `d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21` | [`{ type: "subagent/catalog-unknown" }`](#persistence-type-sha256-d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -900,7 +901,21 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/ 'subagent/catalog': SubagentCatalogEvent ``` -Source: [`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) +Source: [`packages/subagent/subagent/src/catalog.ts:48`](../packages/subagent/subagent/src/catalog.ts) + + + +#### `subagent/catalog-unknown` — log-only + +```ts persistence-catalog +/** + * A historical child's header identity when its descriptor cannot establish a mode. + * @param data - child identity retained for discovery and later history reads. + */ +'subagent/catalog-unknown': UnknownCatalogEvent +``` + +Source: [`packages/subagent/subagent/src/catalog.ts:53`](../packages/subagent/subagent/src/catalog.ts) @@ -2475,6 +2490,14 @@ SHA-256: `44c8c77aa9aa100b0d74d2deee85474b29faa6eee0ac0f662f7abe9931ddd7f2` `"subagent/catalog"` + + +### `"subagent/catalog-unknown"` + +SHA-256: `d437ebdbc1839ffcf3f6fa201a003126ec63945186b0c0792f1db466bf8ebd89` + +`"subagent/catalog-unknown"` + ### `"subagent/descriptor"` @@ -2787,6 +2810,14 @@ SHA-256: `2c4c21f19f426a58f2dac914e34313aa43f8408972de857a486d1f086d5adb6c` `"unavailable"` + + +### `"unknown"` + +SHA-256: `05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04` + +`"unknown"` + ### `"usage"` @@ -5087,6 +5118,26 @@ Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src | `interrupted` | not declared | none | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | not declared | none | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | + + + + + + +### `UnknownCatalogEvent` + +SHA-256: `c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca` + +Sources: [`packages/subagent/subagent/src/catalog.ts:34`](../packages/subagent/subagent/src/catalog.ts) + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | optional | `string` | +| `mode` | required | `"unknown"` | +| `version` | required | `0` | + @@ -8237,6 +8288,22 @@ SHA-256: `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | `time` | required | `number` | | `type` | required | `"subagent/catalog"` | + + + + +### `{ type: "subagent/catalog-unknown" }` + +SHA-256: `d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21` + +| Property | Presence | Type | +|---|---|---| +| `data` | required | [`UnknownCatalogEvent`](#persistence-type-sha256-c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca) | +| `ignorable` | optional | `true` | +| `seq` | required | `number` | +| `time` | required | `number` | +| `type` | required | `"subagent/catalog-unknown"` | + diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c655d97086..782c30226d 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -60,6 +60,7 @@ | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871) | +| `event:subagent/catalog-unknown` | event | `d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21` | [`{ type: "subagent/catalog-unknown" }`](#persistence-type-sha256-d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -902,7 +903,21 @@ export type SessionEvent = { 'subagent/catalog': SubagentCatalogEvent ``` -来源:[`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:48`](../packages/subagent/subagent/src/catalog.ts) + + + +#### `subagent/catalog-unknown` — log-only + +```ts persistence-catalog +/** + * A historical child's header identity when its descriptor cannot establish a mode. + * @param data - child identity retained for discovery and later history reads. + */ +'subagent/catalog-unknown': UnknownCatalogEvent +``` + +来源:[`packages/subagent/subagent/src/catalog.ts:53`](../packages/subagent/subagent/src/catalog.ts) @@ -2477,6 +2492,14 @@ SHA-256: `44c8c77aa9aa100b0d74d2deee85474b29faa6eee0ac0f662f7abe9931ddd7f2` `"subagent/catalog"` + + +### `"subagent/catalog-unknown"` + +SHA-256: `d437ebdbc1839ffcf3f6fa201a003126ec63945186b0c0792f1db466bf8ebd89` + +`"subagent/catalog-unknown"` + ### `"subagent/descriptor"` @@ -2789,6 +2812,14 @@ SHA-256: `2c4c21f19f426a58f2dac914e34313aa43f8408972de857a486d1f086d5adb6c` `"unavailable"` + + +### `"unknown"` + +SHA-256: `05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04` + +`"unknown"` + ### `"usage"` @@ -5089,6 +5120,26 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` | `interrupted` | 未声明 | 无 | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | 未声明 | 无 | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | + + + + + + +### `UnknownCatalogEvent` + +SHA-256: `c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca` + +来源:[`packages/subagent/subagent/src/catalog.ts:34`](../packages/subagent/subagent/src/catalog.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"unknown"` | +| `version` | 必需 | `0` | + @@ -8239,6 +8290,22 @@ SHA-256: `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | `time` | 必需 | `number` | | `type` | 必需 | `"subagent/catalog"` | + + + + +### `{ type: "subagent/catalog-unknown" }` + +SHA-256: `d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `data` | 必需 | [`UnknownCatalogEvent`](#persistence-type-sha256-c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca) | +| `ignorable` | 可选 | `true` | +| `seq` | 必需 | `number` | +| `time` | 必需 | `number` | +| `type` | 必需 | `"subagent/catalog-unknown"` | + diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml b/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml new file mode 100644 index 0000000000..cfcd73af1d --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 docs/persistence-changes/2026-09-20-unknown-child-catalog.md +2026-09-20-unknown-child-catalog.md: 80de55e616d63730bc1d0bbc0ffa43923f833923 +2026-09-20-unknown-child-catalog.zh.md: 1ee8a04e7318013ec27a7c736fadd540a96cfc0d diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.md b/docs/persistence-changes/2026-09-20-unknown-child-catalog.md new file mode 100644 index 0000000000..80de55e616 --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.md @@ -0,0 +1,48 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-09-20-unknown-child-catalog + +English | [中文](2026-09-20-unknown-child-catalog.zh.md) + +## Summary + +Add subagent/catalog-unknown to retain historical direct-child header identity when a descriptor cannot establish the mode. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-20-unknown-child-catalog +baseline: false +changes: + - root: "event:subagent/catalog-unknown" + previous: null + after: "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21" + decision: same-version +``` + + +## Compatibility + +This adds an ordinary required-on-read event without changing the finalized subagent/catalog payload, Session header, or envelope. Existing logs remain valid. Older readers that do not know the new event refuse logs containing it; current readers project it into the existing catalog with unknown mode and keep continuation requests restricted to supported child descriptors. + + +## Verification + +pnpm exec vitest run packages/session/session-format-v3-to-v4/tests/validation.spec.ts packages/subagent/subagent/tests/catalog.spec.ts: 16 tests passed, covering unknown identity restoration, malformed payload rejection, duplicate child rejection, and shared catalog projection. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json b/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json new file mode 100644 index 0000000000..7fb3b867fa --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json @@ -0,0 +1,104 @@ +{ + "formatVersion": 2, + "roots": [ + { + "key": "event:subagent/catalog-unknown", + "kind": "event", + "event": "subagent/catalog-unknown", + "surface": false, + "digest": "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 6, + "optional": true + }, + { + "name": "seq", + "type": 2, + "optional": false + }, + { + "name": "time", + "type": 2, + "optional": false + }, + { + "name": "type", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/catalog-unknown" + } + ] + } + } + ], + "types": [] +} diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md b/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md new file mode 100644 index 0000000000..1ee8a04e73 --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md @@ -0,0 +1,48 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-09-20-unknown-child-catalog + +[English](2026-09-20-unknown-child-catalog.md) | 中文 + +## 概述 + +新增 subagent/catalog-unknown,在 descriptor 无法确定模式时保留历史直属子会话的 header 身份。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-20-unknown-child-catalog +baseline: false +changes: + - root: "event:subagent/catalog-unknown" + previous: null + after: "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21" + decision: same-version +``` + + +## 兼容性 + +新增普通的必读事件,不修改已定稿的 subagent/catalog 载荷、Session header 或事件信封。已有日志仍有效。不认识新事件的旧读取器拒绝包含它的日志;当前读取器将其投影为现有目录中的未知模式项,继续执行请求仍受支持的子 descriptor 约束。 + + +## 验证 + +pnpm exec vitest run packages/session/session-format-v3-to-v4/tests/validation.spec.ts packages/subagent/subagent/tests/catalog.spec.ts:16 个测试通过,覆盖未知身份恢复、畸形载荷拒绝、重复 child 拒绝及共享目录投影。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index 15481ef915..93eb20d961 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -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 docs/persistence-changes/historical-formats/README.md -README.md: 9bcfaa1246a03446d4d4fc95dd19f9ca60196301 -README.zh.md: 1f17367beb484feaeb289a42b3e5de50d3139be5 +README.md: 386b0bd9091cbd509843bd6464c8a95650accb99 +README.zh.md: e93e8033daa639eee3a5035efc2df8ef123f563e diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 9bcfaa1246..386b0bd909 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 583 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 63 / 587 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 1f17367beb..e93e8033da 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 583 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 63 / 587 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 428d512264..b069ba0699 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -14985,6 +14985,104 @@ ] } }, + { + "key": "event:subagent/catalog-unknown", + "kind": "event", + "event": "subagent/catalog-unknown", + "surface": false, + "digest": "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 6, + "optional": true + }, + { + "name": "seq", + "type": 2, + "optional": false + }, + { + "name": "time", + "type": 2, + "optional": false + }, + { + "name": "type", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/catalog-unknown" + } + ] + } + }, { "key": "event:subagent/descriptor", "kind": "event", @@ -21691,6 +21789,20 @@ "names": [], "sources": [] }, + { + "digest": "05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "literal", + "value": "unknown" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "05d2ae1e0c7f8770f83f9a09047afb39a335d8ac986df3193659aa1e581472cf", "schema": { @@ -73477,6 +73589,67 @@ "names": [], "sources": [] }, + { + "digest": "c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": 0 + } + ] + }, + "names": [ + "packages/subagent/subagent/src/catalog.ts#UnknownCatalogEvent" + ], + "sources": [ + "packages/subagent/subagent/src/catalog.ts:34" + ] + }, { "digest": "c8e6f48878cc2a2f0dc59682280d81f407943fb503f32c64bf9ff3d9393c3eff", "schema": { @@ -76958,6 +77131,102 @@ "names": [], "sources": [] }, + { + "digest": "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 6, + "optional": true + }, + { + "name": "seq", + "type": 2, + "optional": false + }, + { + "name": "time", + "type": 2, + "optional": false + }, + { + "name": "type", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/catalog-unknown" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "d28765f87d6b6ab408e0b2f3e7e297791b004b806d8e302c49903ebae815feb2", "schema": { @@ -76972,6 +77241,20 @@ "names": [], "sources": [] }, + { + "digest": "d437ebdbc1839ffcf3f6fa201a003126ec63945186b0c0792f1db466bf8ebd89", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "literal", + "value": "subagent/catalog-unknown" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d", "schema": { diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 034d835426..805d66f003 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -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 docs/subsystems/subagent.md -subagent.md: d642fe1658926b17cb29f280803f12e61228be60 -subagent.zh.md: ff153c7861b4ac46bb058efd00a2f5a50181b6f2 +subagent.md: 67f9bc31b46617ea2f7b1ec81bf8c52fd62720cb +subagent.zh.md: a69e032ed96a24a980badaf9e14d924311e1d175 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index d642fe1658..67f9bc31b4 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -8,7 +8,7 @@ Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subag Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) -The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics. +The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics. Historical children with unavailable descriptors have `mode: 'unknown'`; their header identity remains discoverable without granting continuation capabilities. ## Two kinds of capability, discovered two ways @@ -465,7 +465,7 @@ The spawn and fork backends create an ordinary one-shot agent through `parent.ct - **Delegation depth** is durable `SessionHeader.delegationDepth` plus the merge-extensible runtime field `AgentOptions.subagentDepth`; absence means top-level depth zero, and the greater present value is authoritative. The seam owns both fields — the loop neither sets nor reads them — so an in-process child persists parent depth + 1, cold resume cannot lower it, and every start rejects a derived depth outside the safe-integer domain or above a defined absolute `request.maxDepth` cap. - **Fork seeding** uses [`CreateAgentOptions.seed`](core.md#creation-and-ownership) (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `ctx.agents.resume()` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/runtime-diagnostics/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). -`SubagentCatalogEntry` describes one successful direct-child creation fact; `SubagentCatalogState` is the host-only projection state. `listChildren()` owns a live-preferred parent observation without opening child logs. Browser consumers read `subagentCatalog` through the shared Session projection store and combine membership with Session-list activity. `SubagentCatalogRow` belongs to complete-descendant listing. [The parent-catalog decision](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns the persistent facts and read semantics. +`SubagentCatalogEntry` describes a direct child with complete or unknown-mode discovery information; `SubagentCatalogState` is the host-only projection state. `listChildren()` owns a live-preferred parent observation without opening child logs. Browser consumers read `subagentCatalog` through the shared Session projection store and combine membership with Session-list activity. `SubagentCatalogRow` belongs to complete-descendant listing. [The parent-catalog decision](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns the persistent facts and read semantics. diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index ff153c7861..a69e032ed9 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -8,7 +8,7 @@ Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.sub 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) -`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。 +`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。历史子会话的 descriptor 不可用时使用 `mode: 'unknown'`,保留 header 身份供发现,但不授予继续执行能力。 ## 两类能力,两种发现方式 @@ -469,7 +469,7 @@ spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将 - **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,冷恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。 - **Fork 种子注入**使用 [`CreateAgentOptions.seed`](core.zh.md#creation-and-ownership)(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `ctx.agents.resume()` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/runtime-diagnostics/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。 -`SubagentCatalogEntry` 描述一条成功创建直接子级的事实;`SubagentCatalogState` 是仅 host 使用的 projection state。`listChildren()` 拥有一次 live-preferred 父 Session observation,不打开子级日志。浏览器消费者通过共享 Session projection store 读取 `subagentCatalog`,并将成员关系与 Session 列表活动状态组合。`SubagentCatalogRow` 属于完整后代列表。[父目录决策](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 规定持久事实与读取语义。 +`SubagentCatalogEntry` 描述一条完整或未知模式的直接子级目录记录;`SubagentCatalogState` 是仅 host 使用的 projection state。`listChildren()` 拥有一次 live-preferred 父 Session observation,不打开子级日志。浏览器消费者通过共享 Session projection store 读取 `subagentCatalog`,并将成员关系与 Session 列表活动状态组合。`SubagentCatalogRow` 属于完整后代列表。[父目录决策](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 规定持久事实与读取语义。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index d04576c269..6ea89c55b9 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: a0e18910d14f879171a1415476a7e05a0d261a8a -README.zh.md: 58466e387fd604137a2358e8b9a7de3320691034 +README.md: 49eac824a0f9752fa5e2af6eb8c93d0b7b3bbfd9 +README.zh.md: d129ce01cf073dee679b965d83b19b48831d0188 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index a0e18910d1..49eac824a0 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -61,7 +61,7 @@ Attachment authorization reads declared content fields of built-in Session event `sessions.retain(target, { source, signal? })` immediately acquires one exact Client generation and starts its shared initial history opening. The target is a known Session id or a durable direct-parent subagent address; the Host validates an explicit address when history opens. The returned reference supports idempotent `release()` and `Symbol.dispose`; its `ready` Promise follows the shared `Session.open()` result and resolves to the exact binding when that attempt settles, including when a Remote failure is represented by `openState: 'error'`. It rejects when `Session.open()` rejects, its waiter is cancelled, or the reference is released early. Cancelling one waiter does not cancel another owner's opening. `sessions.using(target, options, operation)` waits for that settlement, holds its reference until the callback settles, and propagates rejected readiness and callback failures. -References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. +References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. An unknown-mode subagent address permits history reads with direct-parent validation. Successful descriptor restoration determines the displayed mode; failures remain local to the child, and control requests still require a confirmed continuable identity. ## Session media references diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 58466e387f..d129ce01cf 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -61,7 +61,7 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 `sessions.retain(target, { source, signal? })` 立即获取一个精确 Client generation 的引用,并启动其共享的首次历史打开。目标是已知 Session id 或持久的直接父子 subagent 地址;Host 在打开历史时校验显式地址。返回引用支持幂等的 `release()` 和 `Symbol.dispose`;其 `ready` Promise 跟随共享的 `Session.open()` 结果,并在该次尝试结算时解析为确切 binding,包括 Remote failure 以 `openState: 'error'` 表示的情况。仅当 `Session.open()` 拒绝、等待方取消或引用提前释放时,`ready` 才拒绝。取消一个等待方不会取消其他 owner 的打开。`sessions.using(target, options, operation)` 等待该次结算,持有引用直到回调结束,并传播被拒绝的就绪与回调失败。 -引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 +引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 模式未知的子代理地址允许读取历史,但仍校验直接父级。成功恢复 descriptor 后确定展示模式;失败只影响该子会话,控制请求仍要求已确认的 continuable 身份。 ## 会话媒体引用 diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index cd38a26fcf..b3afc491da 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -812,6 +812,7 @@ export class Session implements SessionFace { } private buildSnapshot(): SessionSnapshot { + const identity = this.projections.values().subagent return { sessionId: this.sessionId, pendingSubmissions: this.pendingSubmissions, @@ -819,7 +820,9 @@ export class Session implements SessionFace { subagent: this.address === undefined ? null : { - address: this.address, + address: this.address.mode === 'unknown' && identity != null + ? { ...this.address, mode: identity.mode } + : this.address, ...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }), }, removed: this.removed, diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index a22badc1f1..069ee3bf32 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -364,7 +364,7 @@ function validateAddress( reason: 'unsupported', }) } - if (identity.mode !== address.mode) { + if (address.mode !== 'unknown' && identity.mode !== address.mode) { throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', { childSessionId: address.childSessionId, }) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index a925e9da83..1d27d62dc5 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -393,7 +393,7 @@ export type SessionAddress = readonly kind: 'subagent' readonly parentSessionId: SessionId readonly childSessionId: SessionId - readonly mode: 'one-shot' | 'continuable' + readonly mode: 'one-shot' | 'continuable' | 'unknown' } /** One non-activating Session projection read. */ diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 7ee5be5397..6f70f7636b 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -501,6 +501,25 @@ describe('subagent catalogs', () => { }) }) + it('resolves an unknown browsing mode from the child projection without changing parent membership', async ({ mock, start }) => { + mock.load(sessionWorld) + const client = await start() + const manager = new SessionManager(client.ctx.remote) + const entries = [{ id: S2, createdAt: 1, mode: 'unknown' as const }] + manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0, value: entries }) + const address = manager.subagentAddress(S2)! + expect(address).toEqual({ parentSessionId: S1, childSessionId: S2, mode: 'unknown' }) + manager.resolveTarget(address) + const child = manager.get(S2) + expect(child.getSnapshot().subagent?.address.mode).toBe('unknown') + manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'subagent', seq: 0, + value: { seq: SessionSeq(0), mode: 'continuable', label: 'repaired child' } }) + await child.open() + expect(child.getSnapshot().subagent?.address).toEqual({ ...address, mode: 'continuable' }) + expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog).toEqual(entries) + await manager.dispose() + }) + it('publishes pushed membership without a catalog request or an instantiated parent', ({ mock, remote }) => { const manager = makeManager(mock, remote) manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1, running: true })) diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 04b43972e9..341aaf4e86 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -451,6 +451,16 @@ describe('SessionHistoryController', () => { }, signal)).resolves.toMatchObject({ records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], }) + await expect(transport.page({ + address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'unknown' }, + throughSeq: 0, + }, signal)).resolves.toMatchObject({ + records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], + }) + await expect(transport.page({ + address: { kind: 'subagent', parentSessionId: SessionId('other-parent'), childSessionId, mode: 'unknown' }, + throughSeq: 0, + }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) await expect(transport.page({ address: { kind: 'subagent', diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 74be8bbf76..24fc6a38eb 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -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/ui-plan/README.md -README.md: b6072cd7d75446a0a45abe4009273235e4ff4e1b -README.zh.md: 44b5ce85ccd6250f30904f80b01ee083f804f1ce +README.md: 96ecd3777ef0ce4ce6babd977eef055ad5c683ec +README.zh.md: fb6eb850b868913bdccec175500ff4e26d7a2157 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index b6072cd7d7..96ecd3777e 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -51,7 +51,7 @@ Admission failures (`matched: false`, business errors, transport faults) surface The chip occupies the conversation-declared `conversation.input.plan` single seat; the node half is an empty apply (the roster row). Reads ride the generic projection pair through the standard-kit `useProjection`: the effective target is `pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way. The seat's injected face carries one verb, `exitPlanMode`, which executes `/plan off` through `ctx.remote.commands.execute` and maps admission failures to an inline error line. The placeholder and hint text live in ui-conversation's `conversation` locale namespace and are shared verbatim with the claimed `/plan` command hint. -Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. +Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 44b5ce85cc..fb6eb850b8 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" 徽章占据 conversation 声明的 `conversation.input.plan` 单实例座位;node 半部是空 apply(roster 行)。读取经 standard-kit 的 `useProjection` 走通用投影对:有效目标是 `pending ? !active : active`——折叠的宿主值而非客户端乐观态,因此到达的帧无论哪个方向都会纠正徽章。座位注入面携带一个动词 `exitPlanMode`,经 `ctx.remote.commands.execute` 执行 `/plan off`,并把准入失败映射为一行内联错误。placeholder 与提示文案位于 ui-conversation 的 `conversation` locale 命名空间,与已认领 `/plan` 命令的提示逐字共用。 -计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 +计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 diff --git a/packages/client/ui-plan/src/client/plan.ts b/packages/client/ui-plan/src/client/plan.ts index e3dff30e29..d44b8f2dd9 100644 --- a/packages/client/ui-plan/src/client/plan.ts +++ b/packages/client/ui-plan/src/client/plan.ts @@ -74,7 +74,7 @@ export function parsePlanAddress(address: string): PlanAddress | undefined { if (parts.length === 2) return { session: { kind: 'session', sessionId: parts[0] as SessionId }, callId: parts[1] as ToolCallId, } - if (parts.length === 5 && parts[0] === 'subagent' && (parts[3] === 'one-shot' || parts[3] === 'continuable')) return { + if (parts.length === 5 && parts[0] === 'subagent' && (parts[3] === 'one-shot' || parts[3] === 'continuable' || parts[3] === 'unknown')) return { session: { kind: 'subagent', parentSessionId: parts[1] as SessionId, childSessionId: parts[2] as SessionId, mode: parts[3] }, callId: parts[4] as ToolCallId, } diff --git a/packages/client/ui-plan/tests/plan-resource.client.spec.ts b/packages/client/ui-plan/tests/plan-resource.client.spec.ts index a27b03c185..57be9acd36 100644 --- a/packages/client/ui-plan/tests/plan-resource.client.spec.ts +++ b/packages/client/ui-plan/tests/plan-resource.client.spec.ts @@ -36,7 +36,7 @@ describe('plan history resource', () => { expect(b.page).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal)) expect(b.closed).toHaveBeenCalledOnce() }) - it.each(['one-shot', 'continuable'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => { + it.each(['one-shot', 'continuable', 'unknown'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => { const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) const address = `dsh-resource://plan/subagent/parent/child/${mode}/call` const session = { kind: 'subagent', parentSessionId: 'parent', childSessionId: 'child', mode } diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 45a496719c..9bab3901c6 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -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/ui-subagent/README.md -README.md: 1404e893add386b88a24d78675a4f0be06f497a0 -README.zh.md: 97c743b580e25cd50bcb01a3a7547cb7415a0e08 +README.md: d0dc10f17b2b83d7a605ee622ea027698ebeac77 +README.zh.md: 1019fd3e7d6fbd8b259c6e94dd830c47ebb2058e diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 1404e893ad..d0dc10f17b 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -65,6 +65,8 @@ Each visible catalog level advances its own clock while it contains a running ch One-shot children always elect a read-only composer. A continuable child elects one only when its exact parent is unavailable and the child is not running; otherwise the ordinary composer's Session routes prompts through `subagents/prompt`. This package never receives host context or calls a model-facing tool. +Unknown-mode catalog rows remain visible and clickable, using the child id when no label is available. Their composer is read-only until child history establishes a supported mode; a read failure is reported in the child conversation. + ----- diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 97c743b580..1019fd3e7d 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -65,6 +65,8 @@ kind: "package-reference" one-shot child 始终选用只读编辑器。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器;否则普通编辑器的会话会经 `subagents/prompt` 路由提示词。本包绝不接收宿主上下文,也不调用面向模型的工具。 +模式未知的目录项仍可见、可点击,没有标签时显示子会话 id。读取子历史并确认受支持模式前,输入区保持只读;读取失败在该子会话中报告。 + ----- diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 6dd416fb95..75e130bbaa 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -238,7 +238,8 @@ function CatalogRows({ || (childCatalog.state === 'loading' && childCatalog.entries.length === 0) const summary = summaries[entry.id] const label = entry.label ?? entry.id - const mode = entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable') + const mode = entry.mode === 'unknown' ? t('mode.unknown') + : entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable') const completed = entry.activity === 'inactive' && summary?.projectionValues?.subagentTiming?.lastTurnCompleted === true const activity = entry.activity === 'running' diff --git a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx index 158e8cb77b..07feb84f55 100644 --- a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx +++ b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx @@ -4,7 +4,7 @@ import css from './SubagentReadOnlyComposer.module.css' /** Why a catalog-addressed conversation cannot accept human input. */ export interface SubagentReadOnlyMatch { - reason: 'one-shot' | 'parent-unavailable' + reason: 'one-shot' | 'parent-unavailable' | 'unknown' } /** Full chain props after the read-only subagent selector accepts the owner currency. */ @@ -24,7 +24,7 @@ export function SubagentReadOnlyComposer({
      {t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')} - {t(oneShot ? 'readonly.oneShot.body' : 'readonly.body')} + {t(matched.reason === 'unknown' ? 'readonly.unknown.body' : oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
      ) diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 0055ea8c3f..d0779cddc8 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -37,6 +37,7 @@ export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRig function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { const subagent = owner.session?.subagent if (subagent === undefined || subagent === null) return null + if (subagent.address.mode === 'unknown') return { reason: 'unknown' } if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' } // Until a Host summary establishes parent availability, keep the normal // disabled composer instead of claiming that the parent is offline. diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index cc966d411c..c17a2f3cb1 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -24,6 +24,8 @@ export const zh = { 'retry': '重试', 'mode.oneShot': '一次性', 'mode.continuable': '可继续', + 'mode.unknown': '模式未知', + 'readonly.unknown.body': '读取子会话后才能确定是否可继续。', 'activity.running': '正在运行', 'activity.completed': '已完成', 'activity.inactive': '当前未运行', @@ -64,6 +66,8 @@ export const en: Record = { 'retry': 'Retry', 'mode.oneShot': 'one-shot', 'mode.continuable': 'continuable', + 'mode.unknown': 'unknown mode', + 'readonly.unknown.body': 'Read the child session to determine whether it can be continued.', 'activity.running': 'running', 'activity.completed': 'completed', 'activity.inactive': 'not running', diff --git a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx index a4fe489714..fde31ff638 100644 --- a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx +++ b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx @@ -73,7 +73,7 @@ export function parseSubagentChatAddress(value: string): SubagentAddress | undef if (parts.length !== 2 || parts[0] !== 'session') return undefined const parentSessionId = url.searchParams.get('parent') const mode = url.searchParams.get('mode') - if (parentSessionId === null || parentSessionId === '' || (mode !== 'one-shot' && mode !== 'continuable')) { + if (parentSessionId === null || parentSessionId === '' || (mode !== 'one-shot' && mode !== 'continuable' && mode !== 'unknown')) { return undefined } try { diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 49b2ac6016..a8e87cb283 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -152,6 +152,8 @@ describe('apply', () => { // One-shot stays read-only even while running: it has no stop action. expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true))) .toEqual({ reason: 'one-shot' }) + expect(select(owner({ address: { ...address, mode: 'unknown' }, parentAvailable: true }))) + .toEqual({ reason: 'unknown' }) expect(select(owner({ address }))).toBeNull() expect(select(owner({ address, parentAvailable: true }))).toBeNull() expect(select(owner({ address, parentAvailable: false }))) diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 626026fe54..44fad0a0b1 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -25,7 +25,7 @@ const CHILD = 'child' as SessionId const GRANDCHILD = 'grandchild' as SessionId const t: SubagentHeaderLineageProps['t'] = makeTranslate(zh) -type CatalogFixture = { entries: readonly SubagentCatalogRow[]; parentAvailable: boolean; state: 'loading' | 'ready' | 'error'; error: SessionListState['projectionsBySession'][SessionId]['error'] } +type CatalogFixture = { entries: readonly (SubagentCatalogRow | { id: SessionId; mode: 'unknown'; label?: string; activity: 'inactive' })[]; parentAvailable: boolean; state: 'loading' | 'ready' | 'error'; error: SessionListState['projectionsBySession'][SessionId]['error'] } function catalog(over: Partial = {}): CatalogFixture { return { @@ -402,6 +402,16 @@ describe('SubagentHeaderLineage', () => { }) }) + it('keeps unknown catalog children clickable by their durable parent address', () => { + const input = props(catalog({ entries: [{ id: CHILD, mode: 'unknown', activity: 'inactive' }] })) + render() + hoverCatalog(screen.getByRole('button', { name: /子代理/ })) + const row = screen.getByRole('treeitem', { name: new RegExp(CHILD) }) + expect(row.textContent).toContain('模式未知') + fireEvent.keyDown(row, { key: 'Enter' }) + expect(input.openChild).toHaveBeenCalledWith({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'unknown' }) + }) + it('shows durable completion and token totals, ticks active duration, and freezes inactive rows', async () => { const now = 2_000_000_000_000 const minute = 60_000 @@ -926,6 +936,11 @@ describe('SubagentReadOnlyComposer', () => { expect(screen.getByRole('status').textContent).toContain('父会话当前不在线') }) + it('keeps an unknown child read-only until its descriptor is available', () => { + render() + expect(screen.getByRole('status').textContent).toContain('读取子会话后才能确定是否可继续') + }) + it('explains that one-shot histories never accept follow-ups', () => { render() expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息') diff --git a/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx b/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx index 143a0cb595..0348f9c786 100644 --- a/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx +++ b/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx @@ -38,6 +38,8 @@ describe('Sidebar chat address', () => { expect(parseSubagentChatAddress(resource)).toEqual(ADDRESS) expect(parseSubagentChatAddress(subagentChatAddress({ ...ADDRESS, mode: 'one-shot' }))) .toEqual({ ...ADDRESS, mode: 'one-shot' }) + expect(parseSubagentChatAddress(subagentChatAddress({ ...ADDRESS, mode: 'unknown' }))) + .toEqual({ ...ADDRESS, mode: 'unknown' }) }) it.each([ @@ -47,7 +49,7 @@ describe('Sidebar chat address', () => { 'dsh-resource://subagentchat/other/child?parent=parent&mode=continuable', 'dsh-resource://subagentchat/session/child?mode=continuable', 'dsh-resource://subagentchat/session/child?parent=&mode=continuable', - 'dsh-resource://subagentchat/session/child?parent=parent&mode=unknown', + 'dsh-resource://subagentchat/session/child?parent=parent&mode=invalid', 'dsh-resource://subagentchat/session/%?parent=parent&mode=continuable', ])('rejects %s', (address) => { expect(parseSubagentChatAddress(address)).toBeUndefined() diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index adaca29255..2679faecfb 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -58,6 +58,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'step/end', 'step/start', 'subagent/catalog', + 'subagent/catalog-unknown', 'subagent/descriptor', 'subagent/model-selection-policy', 'system/message', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 78975d4a43..cd09683c63 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -5745,7 +5745,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionAddress', - declaration: 'export type SessionAddress = {\n readonly kind: \'session\';\n readonly sessionId: SessionId;\n} | {\n readonly kind: \'subagent\';\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'one-shot\' | \'continuable\';\n};', + declaration: 'export type SessionAddress = {\n readonly kind: \'session\';\n readonly sessionId: SessionId;\n} | {\n readonly kind: \'subagent\';\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'one-shot\' | \'continuable\' | \'unknown\';\n};', }, { name: 'SessionAssistantStreamAttempt', @@ -6429,7 +6429,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentCatalogEntry', - declaration: 'export type SubagentCatalogEntry = {\n readonly id: SessionId;\n readonly createdAt: number;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n});', + declaration: 'export type SubagentCatalogEntry = {\n readonly id: SessionId;\n readonly createdAt: number;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n} | {\n readonly mode: \'unknown\';\n readonly label?: string;\n});', }, { name: 'SubagentCatalogRow', diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index 0fb2eafad1..2015d47d2c 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/packages/sdk/client/tests/sdk-client.spec.ts @@ -647,6 +647,9 @@ describe('HarnessClient', () => { const inject = (method: string, params: Record): void => { (client as unknown as { dispatchNotification(n: HarnessNotification): void }).dispatchNotification({ method, params }) } + const unknownChild = { type: 'subagent/catalog-unknown', seq: 0, time: 1, + data: { version: 0, childId: 'unreadable-child', childCreatedAt: 1, mode: 'unknown' } } + inject('session.event', { sessionId: 'root', event: unknownChild }) inject('subagent.started', { parentSessionId: 'root', childSessionId: 'child' }) inject('subagent.started', { parentSessionId: 'child', childSessionId: 'grandchild' }) inject('session.event', { sessionId: 'grandchild', event: { type: 'noop' } }) @@ -658,6 +661,7 @@ describe('HarnessClient', () => { inject('subagent.started', { parentSessionId: '', childSessionId: 'x' }) inject('subagent.finished', { childSessionId: 'root' }) + expect(await tree.next()).toEqual({ method: 'session.event', params: { sessionId: 'root', event: unknownChild } }) expect((await tree.next()).method).toBe('subagent.started') expect((await tree.next()).method).toBe('subagent.started') expect((await tree.next()).params.sessionId).toBe('grandchild') diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index c22705c4fa..c5b3413814 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -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/session/session-format-v3-to-v4/README.md -README.md: 4a6a58a0b1375ff3f318d7cf94bf008ec16ab9df -README.zh.md: 7383dff61fd921976772a6cdaad9d98474f6c150 +README.md: 6721d43e7653942b055443a2da99d7fca1d6fd57 +README.zh.md: 48b7e5cbd00a19a81730cbf1310e462196cd8ca1 diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 4a6a58a0b1..6721d43e76 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -144,13 +144,13 @@ There is no recursive source search. Captured request text, assistant replay sta |---|---| | One descriptor with version 1 | Require string provider and label; derive `mode: 'continuable'`. | | One descriptor with version 2 or 3 | Require string provider; use its mode and optional label under catalog rules. | -| Zero descriptors, or an unsupported descriptor version | May retain an existing parent entry; cannot create a missing entry. | -| More than one own descriptor | Retain an existing parent entry without mode/label comparison; do not create a missing entry. | +| Zero descriptors, or an unsupported descriptor version | Retain an existing parent entry; otherwise append unknown-mode membership. | +| More than one own descriptor | Retain an existing parent entry without mode/label comparison; otherwise append unknown-mode membership. | | Existing own parent entry | Retain it and its extensions; require matching child creation time and mode/label from exactly one supported own descriptor, when available. | | Missing own parent entry with complete supported evidence | Append a version-0 catalog fact with child id, creation time, mode, and optional label. | -| Missing own parent entry without complete evidence | Preserve the parent without inventing a catalog entry. | +| Missing own parent entry without complete evidence | Append `subagent/catalog-unknown` with header identity and unknown mode, without inventing a label. | -Catalog version 0 requires string `childId`, nonnegative safe-integer `childCreatedAt`, mode `continuable` or `one-shot`, and a string label for continuable mode; a present one-shot label must also be a string. Duplicate own child ids are refused. Existing entries without a corresponding retained child remain in the parent. Descriptor collection does not restore a child's old continuation composition or recover deleted children from tool arguments. +Catalog version 0 requires string `childId`, nonnegative safe-integer `childCreatedAt`, mode `continuable` or `one-shot`, and a string label for continuable mode; a present one-shot label must also be a string. Duplicate own child ids are refused. Existing entries without a corresponding retained child remain in the parent. Descriptor collection does not restore a child's old continuation composition or recover deleted children from tool arguments. `subagent/catalog-unknown` separately records version 0, child id, creation time, and `mode: 'unknown'`; both event types contribute to the same unique-child catalog. Existing `subagent/catalog` payloads retain their mode requirements. The stage considers parent catalog records only after the final inherited cut. Every inherited marker discards earlier catalog candidates without interpreting their payloads. Missing entries append after all source events, sorted by creation time then child id, with dense new sequences. Their time is the final source event's time, or header creation time for an empty log. They neither enter the model surface nor change the inherited count. @@ -243,7 +243,7 @@ Current common admission does not validate each user/tool/developer content bloc | `compaction/start`, `compaction/summary`, `compaction/end` | Match compaction id, source command, and the active turn context. Summary spans name exact current-surface nodes and exclude the protected head; successful completion has one summary. Inherited unfinished compactions expire at the end-seed marker. | | `compaction/prune` | Its span names exact current-surface nodes and excludes the protected head; it does not require a compaction transaction or its owner fields. | | Compact checkpoint replacement | Its `compact-checkpoint` source identifies the active compaction. | -| Native `subagent/catalog` | Check own version-0 payload fields and unique child ids after the inherited cut. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. | +| Native `subagent/catalog` and `subagent/catalog-unknown` | Check own version-0 payload fields and unique child ids after the inherited cut. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. | | Inherited cut and delivery | Apply the marker, coordinate, and generation-ownership rules stated above. | These checks are generation-owned in [relationships.ts](src/relationships.ts). Full common message/envelope acceptance and plugin-owned message projections additionally use the installed Session; the exported V4 restorer alone is not a replacement for complete catalog restoration. @@ -334,7 +334,7 @@ The edge preserves the recorded request prefix. Provider cache availability and - **Nested historical tool results** — migration currently refuses results containing another tool-result wrapper. The original generation remains intact and no V4 successor is published. A later converter may support evidenced source cases without changing the established V4 format; the [migration cookbook](../../../docs/cookbook/adding-a-session-format-version.md#stages-and-validation) defines that distinction. - **Historical extension consumers** — prefixed message and result fields preserve JSON data without activating core fields. A consumer must explicitly understand those fields before interpreting them. - **Retained child logs required** — a parent alone cannot recover unrecorded child ids, creation times, or descriptors. Deleted children cannot be reconstructed from tool arguments; existing parent catalog records remain. -- **Missing historical catalog entries** — without exactly one supported own descriptor, an absent parent entry is not backfilled. The child log remains readable by id; current V4 reads do not rescan children to repair that omission. +- **Unknown historical modes** — without exactly one supported own descriptor, a missing parent entry records unknown mode. Current reads do not rewrite that entry; opening the child resolves available descriptor information or reports its own error. - **Storage scope** — facts cover recognizable children within the same persistence root. Cross-root import and corrupt-log repair are outside this migration. diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index 7383dff61f..48b7e5cbd0 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -144,13 +144,13 @@ V3 未知内容标签变为 `plugin:`,其他字段原样保留 |---|---| | 一个 version 1 descriptor | 要求字符串 provider 与 label;导出 `mode: 'continuable'`。 | | 一个 version 2 或 3 descriptor | 要求字符串 provider;按目录规则使用其 mode 和可选 label。 | -| 没有 descriptor,或 descriptor 版本不受支持 | 可以保留已有父目录项;不能创建缺失项。 | -| 多个自身 descriptor | 保留已有父目录项,不比较 mode/label;不创建缺失项。 | +| 没有 descriptor,或 descriptor 版本不受支持 | 保留已有父目录项;否则追加模式未知的目录项。 | +| 多个自身 descriptor | 保留已有父目录项,不比较 mode/label;否则追加模式未知的目录项。 | | 已有自身父目录项 | 保留条目及其扩展;要求子创建时间一致,并在恰好一个受支持的自身 descriptor 可用时比较其 mode/label。 | | 缺少自身父目录项,且受支持证据完整 | 追加带 child id、创建时间、mode 和可选 label 的 version-0 目录事实。 | -| 缺少自身父目录项,且证据不完整 | 保留父 Session,不凭空创建目录项。 | +| 缺少自身父目录项,且证据不完整 | 追加 `subagent/catalog-unknown`,记录 header 身份和未知模式,不编造标签。 | -Catalog version 0 要求字符串 `childId`、非负安全整数 `childCreatedAt`、`continuable` 或 `one-shot` mode,以及 continuable mode 下的字符串 label;存在的 one-shot label 也必须是字符串。重复的自身 child id 被拒绝。没有对应保留子日志的已有条目仍保留在父日志中。Descriptor 收集不恢复子级的旧 continuation composition,也不从工具参数恢复已删除子级。 +Catalog version 0 要求字符串 `childId`、非负安全整数 `childCreatedAt`、`continuable` 或 `one-shot` mode,以及 continuable mode 下的字符串 label;存在的 one-shot label 也必须是字符串。重复的自身 child id 被拒绝。没有对应保留子日志的已有条目仍保留在父日志中。Descriptor 收集不恢复子级的旧 continuation composition,也不从工具参数恢复已删除子级。 `subagent/catalog-unknown` 单独记录 version 0、child id、创建时间和 `mode: 'unknown'`;两种事件共同组成 child id 唯一的目录。已有 `subagent/catalog` 载荷保留其 mode 要求。 Stage 只把最终继承截点之后的父目录记录作为候选。每个 inherited marker 都会丢弃更早的目录候选,不解释其载荷。缺失项追加在所有源事件之后,按创建时间、child id 排序,并使用连续的新序号。时间取最后一个源事件的 time;空日志则取 header 创建时间。这些记录既不进入模型表面,也不改变继承计数。 @@ -243,7 +243,7 @@ System image 接纳要求非空 attachment id、PNG/JPEG/WebP/GIF MIME 类 | `compaction/start`、`compaction/summary`、`compaction/end` | 匹配 compaction id、源 command 和活动 turn 上下文。Summary 区间引用精确的当前表面节点且排除 protected head;成功完成需要一个 summary。继承的未完成 compaction 在 end-seed marker 处过期。 | | `compaction/prune` | 其区间引用精确的当前表面节点且排除 protected head;它不要求存在 compaction 事务或其所有者字段。 | | Compact checkpoint 替换 | 其 `compact-checkpoint` 来源标识活动 compaction。 | -| 原生 `subagent/catalog` | 检查继承截点之后的自身 version-0 载荷字段与 child id 唯一性。原生读取既不收集子日志,也不比较其物理事实;继承项不建立自身成员关系。 | +| 原生 `subagent/catalog` 与 `subagent/catalog-unknown` | 检查继承截点之后的自身 version-0 载荷字段与 child id 唯一性。原生读取既不收集子日志,也不比较其物理事实;继承项不建立自身成员关系。 | | 继承截点与 delivery | 应用上文的 marker、坐标及代际归属规则。 | 这些检查由 [relationships.ts](src/relationships.ts) 按代际拥有。完整的通用消息/信封接纳与插件拥有的消息投影还使用已安装 Session;单独的导出 V4 恢复器不能替代完整 catalog 恢复。 @@ -334,7 +334,7 @@ Fork 种子构造归核心 Session 所有,不属于此迁移。原生 V4 接 - **历史嵌套工具结果**——当前迁移拒绝包含另一个 tool-result wrapper 的结果。原始代际保持完整,且不发布 V4 successor。后续转换器可以支持有证据的源数据场景,而不改变既定 V4 格式;[迁移 cookbook](../../../docs/cookbook/adding-a-session-format-version.zh.md#stages-and-validation) 定义了这一区别。 - **历史扩展消费者**——带前缀的消息与结果字段保留 JSON 数据,不激活核心字段。消费者必须明确理解这些字段后才能解释它们。 - **依赖保留的子日志**——仅凭父日志无法恢复未记录的子 id、创建时间或 descriptor。删除的子 Session 无法从工具参数恢复;已存在的父目录记录仍保留。 -- **历史目录项缺失**——没有恰好一个受支持的自身 descriptor 时,不补填父目录缺失项。子日志仍可按 id 读取;当前 V4 读取不会重新扫描子日志来补齐该条目。 +- **历史模式未知**——没有恰好一个受支持的自身 descriptor 时,缺失的父目录项记录未知模式。当前读取不重写该项;打开子会话时解析可用的 descriptor 信息,或报告该子会话的错误。 - **存储范围**——事实只覆盖同一持久化根目录内可识别的子 Session。跨根目录导入和损坏日志修复不属于此迁移。 diff --git a/packages/session/session-format-v3-to-v4/src/facts.ts b/packages/session/session-format-v3-to-v4/src/facts.ts index 23a0c233d0..a250801759 100644 --- a/packages/session/session-format-v3-to-v4/src/facts.ts +++ b/packages/session/session-format-v3-to-v4/src/facts.ts @@ -78,6 +78,21 @@ export function catalogFact(value: SessionFormatJsonValue, subject = 'subagent/c return value } +/** + * Validate a current catalog entry retained without a supported child descriptor. + * @param value - decoded unknown-child payload. + * @returns the validated identity with unknown mode. + */ +export function unknownCatalogFact(value: SessionFormatJsonValue): SessionFormatJsonObject { + if (!isSessionFormatJsonObject(value) || value['version'] !== 0 + || typeof value['childId'] !== 'string' || value['mode'] !== 'unknown' + || (value['label'] !== undefined && typeof value['label'] !== 'string')) { + throw new SessionFormatError('subagent/catalog-unknown requires a version 0 identity with unknown mode') + } + sessionFormatCount(value['childCreatedAt'], 'catalog child creation time') + return value +} + /** * Name the child and its optional storage-owned location in a migration diagnostic. * @param source - validated supplemental child evidence. diff --git a/packages/session/session-format-v3-to-v4/src/migration.ts b/packages/session/session-format-v3-to-v4/src/migration.ts index 3b1c1de60a..2184f5b819 100644 --- a/packages/session/session-format-v3-to-v4/src/migration.ts +++ b/packages/session/session-format-v3-to-v4/src/migration.ts @@ -122,9 +122,10 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage { } continue } - if (fact === undefined) continue - existingCatalogs.set(id, fact) - context.emitEvent({ type: 'subagent/catalog', seq: this.nextSeq++, time: this.time, data: fact }) + const entry = fact ?? { version: 0, childId: id, childCreatedAt: source['childCreatedAt'] as number, mode: 'unknown' } + existingCatalogs.set(id, entry) + context.emitEvent({ type: fact === undefined ? 'subagent/catalog-unknown' : 'subagent/catalog', + seq: this.nextSeq++, time: this.time, data: entry }) } return cut } diff --git a/packages/session/session-format-v3-to-v4/src/validation.ts b/packages/session/session-format-v3-to-v4/src/validation.ts index 8c0e74d61d..4b9df349cc 100644 --- a/packages/session/session-format-v3-to-v4/src/validation.ts +++ b/packages/session/session-format-v3-to-v4/src/validation.ts @@ -6,7 +6,7 @@ import type { SessionFormatArtifact, SessionFormatEvent } from '@deepseek-ai/dsh import { assertV4DeveloperData } from './developer.ts' import { assertV4LifecycleRelationships } from './relationships.ts' import { assertV4MessageSources } from './message-sources.ts' -import { catalogFact } from './facts.ts' +import { catalogFact, unknownCatalogFact } from './facts.ts' import { assertV4RetiredSyntax } from './retired-syntax.ts' import { assertV4SystemMessageFields } from './system-message.ts' import { assertV4ForkResult } from './fork-result.ts' @@ -119,8 +119,8 @@ export function assertReleasedV4Relationships(artifact: SessionFormatArtifact, k && deliveryId !== artifact.header.id) { throw new SessionFormatError('current-generation delivery marker names the wrong Session') } - if (event.type === 'subagent/catalog' && event.seq >= artifact.inheritedEventCount) { - const fact = catalogFact(event.data) + if ((event.type === 'subagent/catalog' || event.type === 'subagent/catalog-unknown') && event.seq >= artifact.inheritedEventCount) { + const fact = event.type === 'subagent/catalog' ? catalogFact(event.data) : unknownCatalogFact(event.data) const id = fact['childId'] as string if (ids.has(id)) throw new SessionFormatError(`duplicate catalog child ${id}`) ids.add(id) diff --git a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts index 7b1ad7e3c0..73733b0afc 100644 --- a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts @@ -74,7 +74,7 @@ describe('V3 parent catalog completion', () => { it('requires explicit corpus facts and refuses conflicting identities', () => { expect(() => sessionFormatV3ToV4.createStage({ sourceHeader: header, targetHeader: { ...header, version: 4 }, sourceKind: 'decoded', sourceInheritedEventCount: 0 })).toThrow('explicit historical child facts') - expect(() => migrate([], [{ ...child, mode: 'unknown' }])).toThrow('complete version 0') + expect(() => migrate([], [{ ...child, mode: 'invalid' }])).toThrow('complete version 0') expect(() => migrate([{ type: 'subagent/catalog', seq: 0, time: 2, data: child }], [{ ...child, childCreatedAt: 3 }])).toThrow('conflicts') }) @@ -88,8 +88,8 @@ describe('V3 parent catalog completion', () => { ], } expect(childCatalogFact(historicalChildCatalogSource(artifact))).toEqual(child) - expect(migrate([], [historicalChildCatalogSource({ ...artifact, inheritedEventCount: 0 })]).events).toEqual([]) - expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [] })]).events).toEqual([]) + expect(migrate([], [historicalChildCatalogSource({ ...artifact, inheritedEventCount: 0 })]).events).toMatchObject([{ data: { childId: 'child', mode: 'unknown' } }]) + expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [] })]).events).toMatchObject([{ data: { childId: 'child', mode: 'unknown' } }]) }) it('orders tied and differently dated children independently of directory enumeration', () => { const children = [{ ...child, childId: 'z' }, { ...child, childId: 'a' }, { ...child, childId: 'first', childCreatedAt: 1 }] @@ -114,11 +114,11 @@ describe('V3 parent catalog completion', () => { expect(() => migrate([preceding, { ...delivery, data: { ...delivery.data, sessionFormatVersion: 3 } }], [])).toThrow('wrong Session') }) - it('skips unavailable descriptors while requiring complete discovery fields for new facts', () => { + it('retains unknown children while validating available descriptor fields', () => { const artifact = { header: { ...header, origin: 'subagent' as const, parentSession: 'parent' }, inheritedEventCount: 0, events: [] } expect(() => historicalChildCatalogSource({ ...artifact, header })).toThrow('direct parent') for (const data of [null, {}, { version: 4, mode: 'one-shot', provider: 'spawn' }]) { - expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [{ type: 'subagent/descriptor', seq: 0, time: 1, data }] })]).events).toEqual([]) + expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [{ type: 'subagent/descriptor', seq: 0, time: 1, data }] })]).events).toMatchObject([{ data: { childId: 'parent', mode: 'unknown' } }]) } for (const value of [null, { ...child, version: 1 }, { ...child, childId: null }, { ...child, label: null }, { ...child, childCreatedAt: -1 }]) { @@ -148,7 +148,8 @@ describe('V3 parent catalog completion', () => { const unavailable = { childId: 'unavailable', childCreatedAt: 1, descriptorCount: kind === 'missing' ? 0 : kind === 'multiple' ? 2 : 1, descriptor } expect(migrate([], [child, unavailable]).events).toEqual([ - { type: 'subagent/catalog', seq: 0, time: 1, data: child }, + { type: 'subagent/catalog-unknown', seq: 0, time: 1, data: { version: 0, childId: 'unavailable', childCreatedAt: 1, mode: 'unknown' } }, + { type: 'subagent/catalog', seq: 1, time: 1, data: child }, ]) }) diff --git a/packages/session/session-format-v3-to-v4/tests/validation.spec.ts b/packages/session/session-format-v3-to-v4/tests/validation.spec.ts index 2d3c447842..a0e1f9e9de 100644 --- a/packages/session/session-format-v3-to-v4/tests/validation.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/validation.spec.ts @@ -113,6 +113,23 @@ describe('restoreReleasedV4Artifact', () => { .toThrow(/unseeded format v4 Session has inherited events/) }) + it('retains unknown child membership and rejects invalid identities or duplicate child ids', () => { + const known = new Set([...KNOWN, 'subagent/catalog', 'subagent/catalog-unknown']) + const data = { version: 0, childId: 'unreadable', childCreatedAt: 2, mode: 'unknown' } + const event = { type: 'subagent/catalog-unknown', seq: 0, time: 1, data } + const valid = artifact([event]) + expect(restoreReleasedV4Artifact(valid, known)).toBe(valid) + expect(() => restoreReleasedV4Artifact(artifact([{ ...event, data: { ...data, label: 'retained' } }]), known)).not.toThrow() + for (const invalid of [null, { ...data, version: 1 }, { ...data, childId: 1 }, + { ...data, childCreatedAt: -1 }, { ...data, mode: 'one-shot' }, { ...data, label: null }]) { + expect(() => restoreReleasedV4Artifact(artifact([{ ...event, data: invalid }]), known)).toThrow() + } + expect(() => restoreReleasedV4Artifact(artifact([event, { ...event, seq: 1 }]), known)).toThrow('duplicate catalog child') + expect(() => restoreReleasedV4Artifact(artifact([event, { type: 'subagent/catalog', seq: 1, time: 1, + data: { ...data, mode: 'one-shot' } }]), known)).toThrow('duplicate catalog child') + expect(() => restoreReleasedV4Artifact(artifact([{ ...event, type: 'subagent/catalog' }]), known)).toThrow('complete version 0') + }) + it('validates only the envelope in physical mode', () => { const unknown = conversation() unknown.splice(2, 0, { type: 'campaign/unknown', seq: 0, time: 1, data: { x: 1 } }) diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 21073ec706..166eaa18dc 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -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/session/session-persistence-jsonl/README.md -README.md: af64ea90850ea0777ceb7c61d4c43dd1a0bc6707 -README.zh.md: b61d64e72aa76f58b3ed9534da15c8657952e46a +README.md: 066b43d6a0f7c0e29b8c2ebbba17fa460103187a +README.zh.md: 61693b96d904b8d2c3d679c065f3667eb3bdc46e diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index af64ea9085..066b43d6a0 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -81,7 +81,7 @@ The current-generation scanner applies the current codec owner’s structural ad `open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. -Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It finds candidate direct children from headers, reads each child's own descriptor through historical codecs, and retains compact evidence plus source revisions. It neither prepares child catalogs nor publishes child successors. Unreadable or unsupported headers are omitted from discovery. Child decoding and descriptor-field failures produce warnings naming the child path and omit its backfill; healthy children and existing parent catalog entries remain available. Opening a damaged child still reports that child's error. Missing, unsupported, or multiple descriptors provide no new fact. Preparation rechecks membership and inspected source revisions before returning, reuse, and publication, including failed child reads so a repaired child invalidates stale preparation. Source drift retries a read open once and refuses a write open. Cancellation still aborts the operation. Current V4 opens bypass discovery and validate catalog fields, uniqueness, and current delivery ownership before exposing events. +Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It finds candidate direct children from headers, reads each child's own descriptor through historical codecs, and retains compact evidence plus source revisions. It neither prepares child catalogs nor publishes child successors. Unreadable or unsupported headers are omitted from discovery. Child decoding and descriptor-field failures produce warnings naming the child path and retain header identity through `subagent/catalog-unknown` when the parent has no complete entry; healthy children and existing parent catalog entries remain available. Opening a damaged child still reports that child's error. Missing, unsupported, or multiple descriptors likewise produce unknown-mode membership without inventing a label. Published unknown entries remain browsable; child history reads retry the actual log and resolve its mode from a valid descriptor. Preparation rechecks membership and inspected source revisions before returning, reuse, and publication, including failed child reads so a repaired child invalidates stale preparation. Source drift retries a read open once and refuses a write open. Cancellation still aborts the operation. Current V4 opens bypass discovery and validate catalog fields, uniqueness, and current delivery ownership before exposing events. Historical `stat` and `list` revisions require metadata work proportional to the root’s Session count. Fresh body preparation scans all selected headers and decodes direct-child bodies; memo reuse still scans membership and checks revisions. Read-only access never publishes an upgrade, so cold processes and evicted preparations repeat that work. Current V4 body reads and revisions avoid the historical corpus scan. See the [measured costs and diagnostic command](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md#catalog-scan-measurements). diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index b61d64e72a..61693b96d9 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -81,7 +81,7 @@ kind: "package-reference" `open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;每个嵌套对象和数组都会冻结,句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 -历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:从 header 找到候选直属子 Session,通过历史编解码器逐个读取其自身 descriptor,保留紧凑证据与来源修订。此过程不准备子目录,也不发布子后继。不可读或不支持的 header 不参与发现。子日志解码或 descriptor 字段失败会产生带子路径的警告,并跳过该项补填;健康子项和已有父目录项仍可使用。打开损坏子 Session 时仍报告该子会话的错误。缺失、不支持或多个 descriptor 不提供新事实。准备返回、复用与发布前会重新检查成员集合及已检查来源的修订,也包括读取失败的子日志,使修复后的子日志能够使旧准备缓存失效。来源变化时只读打开重试一次,写打开拒绝发布。取消仍会中止操作。当前 V4 打开跳过发现,并在暴露事件前校验目录字段、唯一性及当前投递归属。 +历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:从 header 找到候选直属子 Session,通过历史编解码器逐个读取其自身 descriptor,保留紧凑证据与来源修订。此过程不准备子目录,也不发布子后继。不可读或不支持的 header 不参与发现。子日志解码或 descriptor 字段失败会产生带子路径的警告;父目录没有完整条目时,通过 `subagent/catalog-unknown` 保留 header 身份信息。健康子项和已有父目录项仍可使用。打开损坏子 Session 时仍报告该子会话的错误。缺失、不支持或多个 descriptor 同样生成模式未知的目录项,不编造标签。已发布的未知条目仍可浏览;读取子历史时会重试实际日志,并从有效 descriptor 确定模式。准备返回、复用与发布前会重新检查成员集合及已检查来源的修订,也包括读取失败的子日志,使修复后的子日志能够使旧准备缓存失效。来源变化时只读打开重试一次,写打开拒绝发布。取消仍会中止操作。当前 V4 打开跳过发现,并在暴露事件前校验目录字段、唯一性及当前投递归属。 历史格式的 `stat` 与 `list` 修订号需要与根目录 Session 数量成正比的元数据工作。首次正文准备扫描所有所选 header 并解码直属子正文;复用准备缓存仍扫描成员集合并检查修订。只读访问从不发布升级,因此冷进程与被淘汰的准备缓存会重复这些工作。当前 V4 正文读取和修订号避开历史全库扫描。见[实测成本与诊断命令](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md#catalog-scan-measurements)。 diff --git a/packages/session/session-persistence-jsonl/src/catalog-migration.ts b/packages/session/session-persistence-jsonl/src/catalog-migration.ts index f07d5179a7..f0aefc057f 100644 --- a/packages/session/session-persistence-jsonl/src/catalog-migration.ts +++ b/packages/session/session-persistence-jsonl/src/catalog-migration.ts @@ -45,6 +45,8 @@ export async function prepareCatalogFacts( path: source.path, identity: await stat(source.path, { bigint: true }), } witnesses.push(witness) + const unavailable = { childId: source.header.id, childCreatedAt: source.header.createdAt, + descriptorCount: 0, descriptor: null, sourcePath: source.path } let restored: Awaited> try { restored = await readDecodedJsonlSource(source.path, version, compression, { @@ -55,6 +57,7 @@ export async function prepareCatalogFacts( } catch (error: unknown) { signal.throwIfAborted() failures.push({ path: source.path, error }) + facts.push(unavailable) continue } witness.identity = restored.identity @@ -69,6 +72,7 @@ export async function prepareCatalogFacts( fact = historicalChildCatalogSource(restored.artifact) } catch (error: unknown) { failures.push({ path: source.path, error }) + facts.push(unavailable) continue } facts.push({ ...fact, sourcePath: source.path }) diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index e5046b45ad..24ee0d013e 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -634,7 +634,7 @@ class JsonlSessionPersistence extends SessionPersistence { const sources = await children() const related = await prepareCatalogFacts(id, sources, this.compression, signal) for (const failure of related.failures) { - this.ctx.logger.warn(`${this.name}: session "${id}" catalog omitted an unreadable child (raw log: ${failure.path}): ${String(failure.error)}`) + this.ctx.logger.warn(`${this.name}: session "${id}" catalog retained a child with unknown descriptor (raw log: ${failure.path}): ${String(failure.error)}`) } const membership = sources.map(source => source.path).sort() validateRelatedSources = async () => { diff --git a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts index 209cc851f4..028edc5d1b 100644 --- a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts @@ -120,7 +120,7 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', const catalog = { type: 'subagent/catalog', seq: 0, time: 1, data: { version: 0, childId: 'grandchild', childCreatedAt: 3, mode: 'one-shot' } } const path = await f.write('child', [catalog, { ...catalog, seq: 1 }], true, 4) - expect(await f.read()).toEqual([]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) expect(warn).toHaveBeenCalledWith(expect.stringContaining(path)) await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) }) @@ -136,7 +136,10 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', const original = await readFile(childPath) await f.write('healthy', [f.descriptor], true) const parent = await f.ctx.sessionPersistence.open(f.parent, access) - try { expect((await parent.read()).events).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'healthy' } }]) } finally { await parent.close() } + try { expect((await parent.read()).events).toMatchObject([ + { type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }, + { type: 'subagent/catalog', data: { childId: 'healthy', mode: 'continuable' } }, + ]) } finally { await parent.close() } await expect(f.ctx.sessionPersistence.open(SessionId('child'), access)).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) expect(await readFile(childPath)).toEqual(original) await expect(readFile(generationLogPath(f.root, undefined, SessionId('child'), 4, compression))).rejects.toMatchObject({ code: 'ENOENT' }) @@ -167,16 +170,16 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', const f = await fixture() const childPath = await f.write('child', [], true) await writeFile(childPath, Buffer.concat([await readFile(childPath), Buffer.alloc(8)])) - expect(await f.read()).toEqual([]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) }) - it('keeps a complete parent catalog when a published child never writes a descriptor', async () => { + it.each([false, true])('keeps a complete parent catalog when child metadata is unavailable (corrupt=%s)', async (corrupt) => { const f = await fixture() const catalog = { type: 'subagent/catalog', seq: 0, time: 2, data: { version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot', label: 'failed startup' } } const parentPath = await f.write(f.parent, [catalog]) - const childPath = await f.write('child', [], true) + const childPath = await f.write('child', corrupt ? [{ type: 'future/required', seq: 0, time: 2, data: {} }] : [], true) const original = await Promise.all([readFile(parentPath), readFile(childPath)]) expect(await f.read()).toEqual([catalog]) const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') @@ -205,10 +208,10 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', const childPath = await f.write('child', events, true) const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) const original = await Promise.all([readFile(parentPath), readFile(childPath)]) - expect(await f.read()).toEqual([]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') - try { expect((await writer.read()).events).toEqual([]) } finally { await writer.close() } - expect(await f.read()).toEqual([]) + try { expect((await writer.read()).events).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) } finally { await writer.close() } + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) const child = await f.ctx.sessionPersistence.open(SessionId('child'), 'read') try { expect((await child.read()).events).toEqual(events) } finally { await child.close() } expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original) @@ -234,18 +237,18 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', expect(await f.read()).toEqual([]) }) - it('omits unsupported child contents without migrating the child', async () => { + it('retains unknown child identity without migrating unsupported contents', async () => { const f = await fixture() await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) - expect(await f.read()).toEqual([]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionFormatUnsupportedError) }) - it('omits invalid descriptor fields and reports the child source', async () => { + it('retains unknown child identity and reports invalid descriptor fields', async () => { const f = await fixture() const warn = vi.spyOn(f.ctx.logger, 'warn') const path = await f.write('child', [{ ...f.descriptor, data: { version: 2, provider: 'spawn', mode: 'unknown' } }], true) - expect(await f.read()).toEqual([]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) expect(warn).toHaveBeenCalledWith(expect.stringContaining(path)) }) @@ -280,7 +283,7 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', it('recollects newly available discovery fields before publishing a prepared parent', async () => { const f = await fixture() const childPath = await f.write('child', [], true) - expect(await f.read()).toEqual([]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) await f.write('child', [f.descriptor], true) await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(childPath) const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) @@ -380,13 +383,27 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', it('recollects a repaired child before publishing a prepared parent', async () => { const f = await fixture() await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) - expect(await f.read()).toEqual([]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) await f.write('child', [f.descriptor], true) expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child' } }]) const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') await writer.close() }) + it('keeps published unknown membership while a repaired child opens independently', async () => { + const f = await fixture() + await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + await writer.close() + const current = generationLogPath(f.root, undefined, f.parent, 4, compression) + const published = await readFile(current) + await f.write('child', [f.descriptor], true) + const child = await f.ctx.sessionPersistence.open(SessionId('child'), 'read') + try { expect((await child.read()).events).toEqual([f.descriptor]) } finally { await child.close() } + expect(await f.read()).toMatchObject([{ data: { childId: 'child', mode: 'unknown' } }]) + expect(await readFile(current)).toEqual(published) + }) + it('completes only the opened parent catalog and defers its child catalog', async () => { const f = await fixture() const childPath = await f.write('child', [f.descriptor], true) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index f2d1e1d365..37e5596037 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -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/subagent/subagent/README.md -README.md: 7d62e423189906c49d9479bd7a6dbd14b7303afd -README.zh.md: af62ad97bc4821222d46ed73509c823851953e1d +README.md: 94da1be172b441aec1fd16d3853596eaee74ca1b +README.zh.md: 462bc3aff2155b96f87d9f69c5aae1aa5002a9ca diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 7d62e42318..94da1be172 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -109,7 +109,7 @@ A request is validated against the provider's advertised capabilities, a durable The manager reserves a child identity, resolves the durable descriptor, creates (or cold-resumes) the child Agent, installs it in an Activation, and submits the prompt. Model-authored messages cross one parent/child edge through fixed Steer scheduling; browser human prompts choose Queue or best-effort Steer through an internal adapter, while other host protocols may retain Queue for distinct turns. A Session queue command admits a live subagent-owned Agent only from its own continuable descriptor. Settlement waits for Agent activity to finish, an empty Inbox, and no owned children, then flushes final Session state with admission open. Under the child lock, the manager revalidates the wake generation, Session sequence, Inbox, and owned children; the synchronous task entry of `Agent.runMaintenance()` claims the idle phase and closes the private subagent Inbox in the same JavaScript turn before handle disposal. An absent direct-child Activation cold-resumes from the persisted session. When a resident Activation settles, the manager tells the child's direct parent in the parent's own turn stream. -Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Each child's `subagentTiming` projection accumulates post-descriptor duration and records whether its latest closed turn ended with `completed`, clearing that completion when another turn opens. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Projection state-version changes refold cached rows from the durable log. The catalog view preserves parent event order in O(D) time for D facts, and its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives. +Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Each child's `subagentTiming` projection accumulates post-descriptor duration and records whether its latest closed turn ended with `completed`, clearing that completion when another turn opens. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Projection state-version changes refold cached rows from the durable log. The catalog view preserves parent event order in O(D) time for D facts, and its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives. Historical migration may instead append `subagent/catalog-unknown` from a readable child header. Its `mode: 'unknown'` projection keeps the child visible without claiming continuation support; an existing complete entry remains authoritative. ### Ownership and invariants diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index af62ad97bc..462bc3aff2 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -109,7 +109,7 @@ kind: "package-reference" 管理器预留 child 身份、解析持久化描述符、创建(或冷恢复)child、把它安装进 Activation 并提交提示词。模型编写的消息通过固定 Steer 调度跨一条 parent/child 边;浏览器人类 prompt 通过内部适配器选择 Queue 或 best-effort Steer,其他 host 协议仍可保留 Queue 以创建独立轮次。Session queue command 仅根据 child 自身的 continuable descriptor 准入在线 subagent-owned Agent。Settlement 会等待 Agent 活动结束、Inbox 为空且没有所拥有子级,再在准入开放时 flush 最终 Session 状态。管理器随后在 child lock 内重新验证 wake generation、Session 序号、Inbox 与所拥有子级;`Agent.runMaintenance()` 的同步 task 入口会占用 idle 阶段,并在同一个 JavaScript turn 内关闭私有 subagent Inbox,然后才释放句柄。直接 child 不存在 Activation 时会从持久化会话冷恢复。当驻留 Activation 结算时,管理器会在 parent 自身的轮次流中告知该 child 的直接 parent。 -本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在提供方返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。每个 child 的 `subagentTiming` projection 会累加 descriptor 之后的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次打开时会清除该完成状态。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。projection 状态版本变更会从持久日志重新折叠缓存行。目录视图对 D 条事实以 O(D) 时间保留父目录事件顺序,其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。 +本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在提供方返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。每个 child 的 `subagentTiming` projection 会累加 descriptor 之后的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次打开时会清除该完成状态。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。projection 状态版本变更会从持久日志重新折叠缓存行。目录视图对 D 条事实以 O(D) 时间保留父目录事件顺序,其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。 历史迁移可根据可读子 header 追加 `subagent/catalog-unknown`。其 `mode: 'unknown'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。 ### 所有权与不变式 diff --git a/packages/subagent/subagent/src/catalog.ts b/packages/subagent/subagent/src/catalog.ts index 3ec29bbd2f..2fbc828892 100644 --- a/packages/subagent/subagent/src/catalog.ts +++ b/packages/subagent/subagent/src/catalog.ts @@ -31,6 +31,14 @@ export type SubagentCatalogEvent = | { readonly mode: 'continuable'; readonly label: string } ) +type UnknownCatalogEvent = { + readonly version: 0 + readonly childId: SessionId + readonly childCreatedAt: number + readonly mode: 'unknown' + readonly label?: string +} + declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** @@ -38,13 +46,18 @@ declare module '@deepseek-ai/dsh-session/types' { * @param data - versioned parent-owned catalog entry. */ 'subagent/catalog': SubagentCatalogEvent + /** + * A historical child's header identity when its descriptor cannot establish a mode. + * @param data - child identity retained for discovery and later history reads. + */ + 'subagent/catalog-unknown': UnknownCatalogEvent } } /** Host fold state for one parent catalog. */ export interface SubagentCatalogState { readonly inheritedEventCount: SessionLogOffset - readonly head?: ChunkedList | undefined + readonly head?: ChunkedList | undefined } const sessionIdSchema = z.string() as unknown as z.ZodType @@ -62,10 +75,13 @@ const continuableCatalogSchema = z.object({ mode: z.literal('continuable'), label: z.string(), }).strict() +const unknownCatalogSchema = oneShotCatalogSchema.extend({ mode: z.literal('unknown') }) +const completeCatalogSchema = z.union([oneShotCatalogSchema, continuableCatalogSchema]) const eventDataSchema = z.union([ oneShotCatalogSchema, continuableCatalogSchema, -]) as unknown as z.ZodType + unknownCatalogSchema, +]) as unknown as z.ZodType const viewSchema = z.array(z.union([ oneShotCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({ id: sessionIdSchema, @@ -75,6 +91,10 @@ const viewSchema = z.array(z.union([ id: sessionIdSchema, createdAt: continuableCatalogSchema.shape.childCreatedAt, }), + unknownCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({ + id: sessionIdSchema, + createdAt: unknownCatalogSchema.shape.childCreatedAt, + }), ])) as unknown as z.ZodType const stateSchema: z.ZodType = z.object({ inheritedEventCount: z.number().int().nonnegative() as unknown as z.ZodType, @@ -88,14 +108,14 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } /** - * Materialize direct children from their parent's successful creation facts. + * Materialize complete and unknown-mode child identities from parent catalog events. * @param state - parent catalog fold state. * @returns current direct-child rows in parent catalog event order. */ function subagentCatalogEntries(state: SubagentCatalogState): SubagentCatalogEntry[] { const entries: SubagentCatalogEntry[] = [] for (const data of iterateChunkedList(state.head)) { - entries.push(data.mode === 'one-shot' + entries.push(data.mode !== 'continuable' ? { id: data.childId, createdAt: data.childCreatedAt, @@ -118,10 +138,12 @@ export const subagentCatalogProjectionDefinition = { stateSchema, init: (_header: SessionHeader, inheritedEventCount: SessionLogOffset) => ({ inheritedEventCount }), apply: (state, event: SessionEvent) => { - if (event.type !== 'subagent/catalog' || event.seq < state.inheritedEventCount) return state - return { ...state, head: appendChunkedList(state.head, eventDataSchema.parse(event.data)) } + if ((event.type !== 'subagent/catalog' && event.type !== 'subagent/catalog-unknown') || event.seq < state.inheritedEventCount) return state + const data = (event.type === 'subagent/catalog-unknown' ? unknownCatalogSchema : completeCatalogSchema) + .parse(event.data) as SubagentCatalogEvent | UnknownCatalogEvent + return { ...state, head: appendChunkedList(state.head, data) } }, - stateVersion: 2, + stateVersion: 3, wire: { viewSchema, view: subagentCatalogEntries }, } satisfies ProjectionDefinition<'subagentCatalog', SubagentCatalogState> diff --git a/packages/subagent/subagent/src/control-types.ts b/packages/subagent/subagent/src/control-types.ts index 10a8cfc426..965f4d70dc 100644 --- a/packages/subagent/subagent/src/control-types.ts +++ b/packages/subagent/subagent/src/control-types.ts @@ -71,7 +71,7 @@ export type SubagentListEntry = readonly reason: 'corrupt' | 'unsupported' | 'unavailable' } -/** Durable parent/child address that selects subagent transport in the client. */ +/** Durable parent/child browsing address; unknown mode is resolved when child history is read. */ export type SubagentAddress = & { readonly parentSessionId: SessionId @@ -80,6 +80,7 @@ export type SubagentAddress = & ( | { readonly mode: 'one-shot' } | { readonly mode: 'continuable' } + | { readonly mode: 'unknown' } ) /** One human message addressed to a continuable direct child. */ diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index c33a2df9fb..27201624fb 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -15,6 +15,7 @@ export type SubagentCatalogEntry = & ( | { readonly mode: 'one-shot'; readonly label?: string } | { readonly mode: 'continuable'; readonly label: string } + | { readonly mode: 'unknown'; readonly label?: string } ) /** Durable active-turn timing for one descriptor-backed child session. */ diff --git a/packages/subagent/subagent/tests/catalog.spec.ts b/packages/subagent/subagent/tests/catalog.spec.ts index cfd51813fe..f8450cd93d 100644 --- a/packages/subagent/subagent/tests/catalog.spec.ts +++ b/packages/subagent/subagent/tests/catalog.spec.ts @@ -107,6 +107,8 @@ describe('subagent catalog projection', () => { fact(0, 'child-b', 1, { mode: 'one-shot' }), fact(1, 'child-a', 1, { mode: 'one-shot', label: 'once' }), fact(2, 'child-d', 3, { mode: 'continuable', label: 'later' }), + { type: 'subagent/catalog-unknown', seq: SessionSeq(3), time: 0, + data: { version: 0, childId: SessionId('unreadable'), childCreatedAt: 4, mode: 'unknown' } }, ] const state = fold(events) @@ -121,6 +123,7 @@ describe('subagent catalog projection', () => { { id: SessionId('child-b'), createdAt: 1, mode: 'one-shot' }, { id: SessionId('child-a'), createdAt: 1, mode: 'one-shot', label: 'once' }, { id: SessionId('child-d'), createdAt: 3, mode: 'continuable', label: 'later' }, + { id: SessionId('unreadable'), createdAt: 4, mode: 'unknown' }, ]) }) diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 0d1cc64097..cb889a6a6f 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -595,6 +595,15 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe def test_session_subscription_keeps_descendant_relationships_across_subscriptions() -> None: client = HarnessClient() with client.subscribe_session_notifications("main") as first: + unknown_child = { + "type": "subagent/catalog-unknown", "seq": 0, "time": 1, + "data": {"version": 0, "childId": "unreadable-child", "childCreatedAt": 1, "mode": "unknown"}, + } + client._handle_message({ + "jsonrpc": "2.0", "method": "session.event", + "params": {"sessionId": "main", "event": unknown_child}, + }) + assert first.next().payload == {"sessionId": "main", "event": unknown_child} client._handle_message({ "jsonrpc": "2.0", "method": "subagent.started", From 5992977f2ee459b798a4ec8c1dba656f0e75cb22 Mon Sep 17 00:00:00 2001 From: fz Date: Sun, 20 Sep 2026 14:36:27 +0800 Subject: [PATCH 134/284] fix(pkg): make SEA native addon extraction atomic @yao-pkg/pkg's SEA bootstrap extracts native addons from the VFS into a per-user cache ($PKG_NATIVE_CACHE_PATH/pkg//) with plain writeFileSync, so concurrent first starts of the same executable truncate files another process has already mapped: SIGBUS on Linux, "Library not loaded" for sharp's libvips on macOS. Extend the existing pnpm patch so the three write sites go through a same-directory temporary file and renameSync; a cache path is then either absent or complete, every process finishes its own copy pass before it loads, and identical content makes concurrent renames harmless without a lock. Cache location, hash keying, and per-file comparison are unchanged. The single-exe Agent Note records the decision and the measured before/after runs. Closes #4664 --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 + ...-executable-sdk-runtime-distribution.zh.md | 2 + patches/@yao-pkg__pkg@6.21.0.patch | 58 +++++++++++++++++-- pnpm-lock.yaml | 6 +- 5 files changed, 62 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index d12179dde4..ce9821d058 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 56c9770966647e97763e0eaf339e4589fba673fb -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 04351105a56304020f5696cf1ce82d7a83ed2ced +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 2de12a542f6c17754eb494a9e42ba843573182cb +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: c391d7268ed55915aeefe4cb323dcf1350ad631f diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 56c9770966..2de12a542f 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -23,6 +23,8 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h `@yao-pkg/pkg` is an exact-pinned root `devDependency` invoked as `pnpm exec pkg`, with [`patches/@yao-pkg__pkg@6.21.0.patch`](../../../../patches/@yao-pkg__pkg@6.21.0.patch) removing the SEA bootstrap's `patchChildProcess` call. Unpatched, pkg rewrites spawned commands named `node` — including the string after a `-c`/`/c` flag, exactly the Bash tool's `bash -c` form — to the executable itself and stamps `PKG_EXECPATH` into every child environment, so a model-issued `node --version` silently boots the dsh CLI; Node's own SEA layer performs no such rewrite, and a SEA binary cannot impersonate plain Node because it always boots its embedded app. With the call removed, children resolve `node` through PATH like any other process (a machine without Node reports command-not-found honestly), no `PKG_EXECPATH` reaches children, absolute `process.execPath` spawns still re-enter the app, worker threads never applied the hook, and `process.pkg` sidecar selection is untouched. +The same patch makes the SEA bootstrap's native-addon extraction atomic. On the first `dlopen` of an addon inside the VFS, pkg's `patchDlopen` copies the addon's package tree to `$PKG_NATIVE_CACHE_PATH/pkg//` (default `~/.cache/pkg/...`), a per-user directory that every concurrent process of the same executable shares; upstream writes each file straight to its final path with `writeFileSync`, which truncates a file another process may already have mapped and lets that process's `existsSync`/hash check read a half-written file. Concurrent first starts therefore die with SIGBUS on Linux or fail with `ERR_DLOPEN_FAILED: Library not loaded` on sharp's libvips on macOS ([#4664](https://github.com/deepseek-harness/deepseek-harness/issues/4664)). The patch routes the three write sites through a same-directory temporary file and `renameSync`, so a cache path is either absent or complete, every process finishes its own copy pass before it loads, and concurrent renames of identical content need no lock. The cache location, hash keying, and per-file comparison are unchanged, so a consumer that isolates the cache per instance through `PKG_NATIVE_CACHE_PATH` keeps working; worker threads only define the hook and never install it. Measured (linux-x64, node24 target, pkg 6.21.0, a probe that loads sharp with its 18 MB libvips library, 16 concurrent first starts against one empty cache, five rounds): 39 of 80 processes died with SIGBUS before the change and 0 of 80 after it, with no temporary files left behind. + Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former. ### The serving interface is a plugin inside the dsh application diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 04351105a5..c391d7268e 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -23,6 +23,8 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 `@yao-pkg/pkg` 是精确钉版的根 `devDependency`,经 `pnpm exec pkg` 调用,并以 [`patches/@yao-pkg__pkg@6.21.0.patch`](../../../../patches/@yao-pkg__pkg@6.21.0.patch) 移除 SEA bootstrap 中的 `patchChildProcess` 调用。未打补丁时,pkg 会把 spawn 的 `node` 命令——包括 `-c`/`/c` 标志后的命令串,恰是 Bash 工具的 `bash -c` 形态——改写为 exe 自身,并向每个子进程环境注入 `PKG_EXECPATH`,模型下发的 `node --version` 会静默启动 dsh CLI;Node 自身的 SEA 层没有这种改写,且 SEA 二进制永远启动内嵌应用、无法充当纯 Node。移除该调用后,子进程像普通进程一样经 PATH 解析 `node`(无 Node 的机器如实报 command not found),子进程环境不再出现 `PKG_EXECPATH`,以 `process.execPath` 绝对路径 spawn 的重入不受影响,worker 线程本来就未应用该钩子,`process.pkg` 侧车选择也不受影响。 +同一补丁还把 SEA bootstrap 的原生插件解包改成原子写入。VFS 内的插件首次 `dlopen` 时,pkg 的 `patchDlopen` 会把该插件所在的包目录整棵拷到 `$PKG_NATIVE_CACHE_PATH/pkg/<.node 文件的 sha256>/`(默认 `~/.cache/pkg/...`),这是同一个 exe 的所有并发进程共用的按用户目录;上游用 `writeFileSync` 直接写目标路径,会把另一个进程可能已经映射的文件截断,并让该进程的 `existsSync`/哈希比对读到写了一半的文件。因此并发首次启动在 Linux 上以 SIGBUS 崩溃,在 macOS 上对 sharp 的 libvips 报 `ERR_DLOPEN_FAILED: Library not loaded`([#4664](https://github.com/deepseek-harness/deepseek-harness/issues/4664))。补丁让三处写入都经过同目录临时文件加 `renameSync`,于是缓存路径要么不存在、要么完整,每个进程都在加载前走完自己的拷贝,相同内容的并发 rename 不需要锁。缓存位置、哈希键与逐文件比对均未改变,通过 `PKG_NATIVE_CACHE_PATH` 按实例隔离缓存的消费方照常工作;worker 线程只定义该钩子而从不安装。 实测(linux-x64、node24 构建目标、pkg 6.21.0,一个加载 sharp 及其 18 MB libvips 库的探针,16 个进程对同一个空缓存并发首次启动,跑五轮):改动前 80 个进程中 39 个以 SIGBUS 崩溃,改动后 80 个全部正常,且无残留临时文件。 + 术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP(Agent Client Protocol)回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。 ### 对外服务接口是 dsh 应用中的插件 diff --git a/patches/@yao-pkg__pkg@6.21.0.patch b/patches/@yao-pkg__pkg@6.21.0.patch index 9f0aa5919d..4facf1f7f2 100644 --- a/patches/@yao-pkg__pkg@6.21.0.patch +++ b/patches/@yao-pkg__pkg@6.21.0.patch @@ -1,8 +1,56 @@ diff --git a/prelude/sea-bootstrap.bundle.js b/prelude/sea-bootstrap.bundle.js -index ac14980e953134f2285e326a5111cf52de717cf5..19465f8004dcd7f75bc7459de2055546f232fd23 100644 +index ac14980e953134f2285e326a5111cf52de717cf5..1b1c00d1274cba5f63487898a9d940e8870f4f19 100644 --- a/prelude/sea-bootstrap.bundle.js +++ b/prelude/sea-bootstrap.bundle.js -@@ -5356,6 +5356,39 @@ var require_sea_vfs_setup = __commonJS({ +@@ -63,6 +63,25 @@ var require_bootstrap_shared = __commonJS({ + throw new Error("pkg: unknown compression codec id " + compression); + } + } ++ // Write `content` to `dest` through a same-directory temporary file and an ++ // atomic rename, so a concurrent process never observes a partially written ++ // addon: its existsSync/hash check and dlopen see either no file or a complete ++ // one. A direct writeFileSync truncates a file another process may already ++ // have mapped (SIGBUS on Linux, "Library not loaded" on macOS) and exposes the ++ // half-written bytes to that process's hash comparison. ++ function writeFileAtomic(dest, content) { ++ var tmp = dest + "." + process.pid + "." + Math.random().toString(36).slice(2) + ".tmp"; ++ fs.writeFileSync(tmp, content); ++ try { ++ fs.renameSync(tmp, dest); ++ } catch (e) { ++ try { ++ fs.unlinkSync(tmp); ++ } catch (_) { ++ } ++ throw e; ++ } ++ } + function cpRecursive(src, dest) { + var st = fs.lstatSync(src); + if (st.isSymbolicLink()) { +@@ -100,7 +119,7 @@ var require_bootstrap_shared = __commonJS({ + return; + } + } +- fs.writeFileSync(dest, srcContent); ++ writeFileAtomic(dest, srcContent); + } + function patchDlopen(insideSnapshot) { + var ancestor = process.dlopen; +@@ -134,10 +153,10 @@ var require_bootstrap_shared = __commonJS({ + var dContent = fs.readFileSync(tmpModulePath); + var dHash = createHash("sha256").update(dContent).digest("hex"); + if (hash !== dHash) { +- fs.writeFileSync(tmpModulePath, moduleContent); ++ writeFileAtomic(tmpModulePath, moduleContent); + } + } else { +- fs.writeFileSync(tmpModulePath, moduleContent); ++ writeFileAtomic(tmpModulePath, moduleContent); + } + newPath = tmpModulePath; + } +@@ -5356,6 +5375,39 @@ var require_sea_vfs_setup = __commonJS({ } }; } @@ -42,7 +90,7 @@ index ac14980e953134f2285e326a5111cf52de717cf5..19465f8004dcd7f75bc7459de2055546 var SEAProvider = class extends MemoryProvider { constructor(seaManifest) { super(); -@@ -5465,12 +5498,21 @@ var require_sea_vfs_setup = __commonJS({ +@@ -5465,12 +5517,21 @@ var require_sea_vfs_setup = __commonJS({ } return -2; } @@ -67,7 +115,7 @@ index ac14980e953134f2285e326a5111cf52de717cf5..19465f8004dcd7f75bc7459de2055546 } existsSync(filePath) { perf2.count("existsSync calls"); -@@ -5542,7 +5584,7 @@ var require_sea_vfs_setup = __commonJS({ +@@ -5542,7 +5603,7 @@ var require_sea_vfs_setup = __commonJS({ var require_worker_bootstrap_string = __commonJS({ "prelude/_worker-bootstrap-string.js"(exports2, module2) { "use strict"; @@ -76,7 +124,7 @@ index ac14980e953134f2285e326a5111cf52de717cf5..19465f8004dcd7f75bc7459de2055546 } }); -@@ -5560,7 +5602,6 @@ var require_sea_bootstrap_core = __commonJS({ +@@ -5560,7 +5621,6 @@ var require_sea_bootstrap_core = __commonJS({ var insideSnapshot = vfs.insideSnapshot; var SNAPSHOT_PREFIX = vfs.SNAPSHOT_PREFIX; shared.patchDlopen(insideSnapshot); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc16be7fbf..0aa5307d29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,7 +11,7 @@ overrides: patchedDependencies: '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861 - '@yao-pkg/pkg@6.21.0': 07f08c630d0d3886d81fd4600116d07e4871ddcd57f92cb22746b6d246cdc337 + '@yao-pkg/pkg@6.21.0': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7 node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0 importers: @@ -86,7 +86,7 @@ importers: version: 4.1.8(vitest@4.1.8) '@yao-pkg/pkg': specifier: 6.21.0 - version: 6.21.0(patch_hash=07f08c630d0d3886d81fd4600116d07e4871ddcd57f92cb22746b6d246cdc337)(supports-color@9.4.0) + version: 6.21.0(patch_hash=75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7)(supports-color@9.4.0) '@yarnpkg/cli-dist': specifier: 4.17.1 version: 4.17.1 @@ -23016,7 +23016,7 @@ snapshots: - bare-buffer - react-native-b4a - '@yao-pkg/pkg@6.21.0(patch_hash=07f08c630d0d3886d81fd4600116d07e4871ddcd57f92cb22746b6d246cdc337)(supports-color@9.4.0)': + '@yao-pkg/pkg@6.21.0(patch_hash=75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7)(supports-color@9.4.0)': dependencies: '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 From 99e22ebbeb1e24139abdd01812651b4cfb26785f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:55:27 +0800 Subject: [PATCH 135/284] refactor(llm): make the official DeepSeek adapter Messages-only --- .../2026-09-15-messages-v1-base-url.i18n.yaml | 4 +- .../2026-09-15-messages-v1-base-url.md | 2 +- .../2026-09-15-messages-v1-base-url.zh.md | 2 +- ...6-messages-historical-tool-input.i18n.yaml | 4 +- ...26-09-16-messages-historical-tool-input.md | 2 +- ...09-16-messages-historical-tool-input.zh.md | 2 +- ...ages-input-history-compatibility.i18n.yaml | 4 +- ...18-messages-input-history-compatibility.md | 2 +- ...messages-input-history-compatibility.zh.md | 2 +- ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 2 +- ...08-20-unified-image-request-pipeline.zh.md | 2 +- ...-09-07-deepseek-messages-adapter.i18n.yaml | 4 +- .../2026-09-07-deepseek-messages-adapter.md | 14 +- ...2026-09-07-deepseek-messages-adapter.zh.md | 14 +- ...026-09-19-deepseek-messages-only.i18n.yaml | 6 + .../2026-09-19-deepseek-messages-only.md | 25 + .../2026-09-19-deepseek-messages-only.zh.md | 25 + apps/cli/tests/built-bin.e2e.ts | 9 +- .../tests/fixtures/image-offload.cordis.yml | 1 - .../acp/tests/image-offload.expected.e2e.ts | 165 +- .../headless/tests/headless.expected.e2e.ts | 12 +- apps/web/tests/deepseek-messages-chat.e2e.ts | 2 +- .../tests/deepseek-messages-settings.e2e.ts | 10 +- apps/web/tests/scaffold.ts | 27 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 5 - docs/config-catalog.zh.md | 5 - ...deepseek-llm-api-wire-extensions.i18n.yaml | 4 +- docs/deepseek-llm-api-wire-extensions.md | 2 +- docs/deepseek-llm-api-wire-extensions.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 2 +- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 4 +- packages/bundle/web-app/README.zh.md | 4 +- .../src/client/ProviderEditor.tsx | 2 +- .../ui-settings-models/src/client/locales.ts | 6 +- .../tests/components.client.spec.tsx | 8 +- .../tests/image-offload.spec.ts | 17 +- .../tool-agent-team/tests/tool-team.spec.ts | 33 +- packages/llm/README.i18n.yaml | 4 +- packages/llm/README.md | 2 +- packages/llm/README.zh.md | 2 +- .../README.i18n.yaml | 4 +- .../llm/deepseek-llm-api-extensions/README.md | 2 +- .../deepseek-llm-api-extensions/README.zh.md | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 67 +- packages/llm/llm-deepseek/README.zh.md | 69 +- packages/llm/llm-deepseek/package.json | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 168 +- packages/llm/llm-deepseek/src/config.ts | 33 +- .../llm-deepseek/src/{common => }/defaults.ts | 0 .../llm-deepseek/src/{common => }/file-id.ts | 0 .../src/{common => }/file-store.ts | 20 +- .../src/{common => }/files-api.ts | 117 +- .../src/{common => }/image-tokens.ts | 0 .../src/{protocols/messages => }/images.ts | 10 +- packages/llm/llm-deepseek/src/index.ts | 33 +- .../src/{common => }/messages-api.ts | 0 .../src/{common => }/model-info.ts | 0 .../llm-deepseek/src/{common => }/models.ts | 2 +- .../src/protocols/chat-completions/adapter.ts | 378 --- .../protocols/chat-completions/serialize.ts | 444 --- .../src/protocols/chat-completions/sse.ts | 40 - .../protocols/chat-completions/translate.ts | 210 -- .../src/protocols/chat-completions/types.ts | 183 -- .../src/protocols/messages/adapter.ts | 152 - .../src/{protocols/messages => }/replay.ts | 4 +- .../src/{common => }/request-extensions.ts | 6 +- .../src/{common => }/request-files.ts | 0 .../src/{common => }/request-pricing.ts | 0 .../src/{protocols/messages => }/serialize.ts | 6 +- .../src/{protocols/messages => }/sse.ts | 0 .../src/{protocols/messages => }/translate.ts | 0 .../src/{protocols/messages => }/transport.ts | 0 .../llm-deepseek/src/{common => }/types.ts | 11 +- .../src/{common => }/upload-index.ts | 0 .../messages/types.ts => wire-types.ts} | 4 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 672 ++-- .../llm/llm-deepseek/tests/adapter.spec.ts | 2750 +++-------------- .../llm-deepseek/tests/dynamic-config.spec.ts | 16 +- .../llm/llm-deepseek/tests/egress.spec.ts | 6 +- .../expected/degraded-replay.json | 0 .../tests/{messages => }/extensions.spec.ts | 2 +- .../llm/llm-deepseek/tests/file-store.spec.ts | 132 +- .../llm/llm-deepseek/tests/files-api.spec.ts | 131 +- .../tests/{messages => }/files.spec.ts | 35 +- .../tests/{messages => }/fixtures/cordis.yml | 0 .../tests/{messages => }/fixtures/history.ts | 4 +- .../{messages => }/fixtures/input-history.ts | 4 +- .../tests/{messages => }/helpers.ts | 41 +- .../llm-deepseek/tests/image-tokens.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 45 +- .../tests/messages/adapter.e2e.ts | 281 -- .../tests/messages/adapter.spec.ts | 450 --- .../tests/messages/fixtures/red.png | Bin 69 -> 0 bytes .../tests/messages/serialize.spec.ts | 454 --- .../llm/llm-deepseek/tests/mock-server.ts | 45 +- .../llm-deepseek/tests/prepared-call.spec.ts | 58 + .../llm/llm-deepseek/tests/protocol.spec.ts | 92 - .../tests/request-pricing.spec.ts | 2 +- .../llm/llm-deepseek/tests/runtime.e2e.ts | 462 +++ .../llm/llm-deepseek/tests/runtime.spec.ts | 2340 ++++++++++++++ .../llm/llm-deepseek/tests/serialize.spec.ts | 1222 +++----- packages/llm/llm-deepseek/tests/sse.spec.ts | 68 - .../tests/{messages => }/stream.spec.ts | 6 +- .../llm/llm-deepseek/tests/translate.spec.ts | 446 --- .../llm-deepseek/tests/upload-index.spec.ts | 4 +- .../tests/transport-recovery.spec.ts | 5 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- .../tests/feedback-composition.spec.ts | 8 +- .../continuation-messages-adapter.spec.ts | 2 +- .../llm-mock-server/README.i18n.yaml | 4 +- .../test-support/llm-mock-server/README.md | 12 +- .../test-support/llm-mock-server/README.zh.md | 12 +- .../test-support/llm-mock-server/package.json | 2 +- .../test-support/llm-mock-server/src/index.ts | 109 +- .../llm-mock-server/tests/server.spec.ts | 98 +- python/sdk/tests/manual_sdk_agent_smoke.py | 21 +- scripts/no-unknown-casts.baseline.json | 42 - .../cordis.snapshot.yml | 2 +- .../cordis.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.yml | 2 +- .../office-skills-no-renderer/cordis.yml | 1 - 128 files changed, 4786 insertions(+), 7743 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md create mode 100644 .agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.zh.md rename packages/llm/llm-deepseek/src/{common => }/defaults.ts (100%) rename packages/llm/llm-deepseek/src/{common => }/file-id.ts (100%) rename packages/llm/llm-deepseek/src/{common => }/file-store.ts (93%) rename packages/llm/llm-deepseek/src/{common => }/files-api.ts (63%) rename packages/llm/llm-deepseek/src/{common => }/image-tokens.ts (100%) rename packages/llm/llm-deepseek/src/{protocols/messages => }/images.ts (93%) rename packages/llm/llm-deepseek/src/{common => }/messages-api.ts (100%) rename packages/llm/llm-deepseek/src/{common => }/model-info.ts (100%) rename packages/llm/llm-deepseek/src/{common => }/models.ts (92%) delete mode 100644 packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts delete mode 100644 packages/llm/llm-deepseek/src/protocols/chat-completions/serialize.ts delete mode 100644 packages/llm/llm-deepseek/src/protocols/chat-completions/sse.ts delete mode 100644 packages/llm/llm-deepseek/src/protocols/chat-completions/translate.ts delete mode 100644 packages/llm/llm-deepseek/src/protocols/chat-completions/types.ts delete mode 100644 packages/llm/llm-deepseek/src/protocols/messages/adapter.ts rename packages/llm/llm-deepseek/src/{protocols/messages => }/replay.ts (93%) rename packages/llm/llm-deepseek/src/{common => }/request-extensions.ts (87%) rename packages/llm/llm-deepseek/src/{common => }/request-files.ts (100%) rename packages/llm/llm-deepseek/src/{common => }/request-pricing.ts (100%) rename packages/llm/llm-deepseek/src/{protocols/messages => }/serialize.ts (98%) rename packages/llm/llm-deepseek/src/{protocols/messages => }/sse.ts (100%) rename packages/llm/llm-deepseek/src/{protocols/messages => }/translate.ts (100%) rename packages/llm/llm-deepseek/src/{protocols/messages => }/transport.ts (100%) rename packages/llm/llm-deepseek/src/{common => }/types.ts (92%) rename packages/llm/llm-deepseek/src/{common => }/upload-index.ts (100%) rename packages/llm/llm-deepseek/src/{protocols/messages/types.ts => wire-types.ts} (95%) rename packages/llm/llm-deepseek/tests/{messages => }/expected/degraded-replay.json (100%) rename packages/llm/llm-deepseek/tests/{messages => }/extensions.spec.ts (99%) rename packages/llm/llm-deepseek/tests/{messages => }/files.spec.ts (91%) rename packages/llm/llm-deepseek/tests/{messages => }/fixtures/cordis.yml (100%) rename packages/llm/llm-deepseek/tests/{messages => }/fixtures/history.ts (77%) rename packages/llm/llm-deepseek/tests/{messages => }/fixtures/input-history.ts (93%) rename packages/llm/llm-deepseek/tests/{messages => }/helpers.ts (63%) delete mode 100644 packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts delete mode 100644 packages/llm/llm-deepseek/tests/messages/adapter.spec.ts delete mode 100644 packages/llm/llm-deepseek/tests/messages/fixtures/red.png delete mode 100644 packages/llm/llm-deepseek/tests/messages/serialize.spec.ts create mode 100644 packages/llm/llm-deepseek/tests/prepared-call.spec.ts delete mode 100644 packages/llm/llm-deepseek/tests/protocol.spec.ts create mode 100644 packages/llm/llm-deepseek/tests/runtime.e2e.ts create mode 100644 packages/llm/llm-deepseek/tests/runtime.spec.ts delete mode 100644 packages/llm/llm-deepseek/tests/sse.spec.ts rename packages/llm/llm-deepseek/tests/{messages => }/stream.spec.ts (97%) delete mode 100644 packages/llm/llm-deepseek/tests/translate.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml index e63305ebf1..106b30af30 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md -2026-09-15-messages-v1-base-url.md: 97f7294c28988e6f115963033cdac6794d70d6f3 -2026-09-15-messages-v1-base-url.zh.md: ccb8c5930dbbfa6274770cc03087b4d8077fe27b +2026-09-15-messages-v1-base-url.md: d9014756eacd1752495bfecb52860634ece08ae1 +2026-09-15-messages-v1-base-url.zh.md: 8f5803e4bdc68195164e187d1aa8cd537f71c5d6 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md index 97f7294c28..d9014756ea 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md @@ -12,7 +12,7 @@ The Messages transport appends the Anthropic-standard `/v1` namespace to a confi The shared Messages API owner trims trailing slashes and treats only a final path segment exactly equal to `v1` as the existing API version. It preserves that root and appends `/messages` or `/files`; every other base receives `/v1/messages` or `/v1/files`. The same resolved root scopes cached file uploads. The official `https://api.deepseek.com/anthropic` base therefore resolves to `/anthropic/v1`, and an explicit `/anthropic/v1` base remains unchanged. -Chat Completions retains its independent URL behavior. The Messages rule does not infer support for `v1beta`, `v2`, `v4`, or other version-like suffixes; deployments that include those segments receive the standard `/v1` namespace beneath them. +The Messages rule does not infer support for `v1beta`, `v2`, `v4`, or other version-like suffixes; deployments that include those segments receive the standard `/v1` namespace beneath them. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md index ccb8c5930d..8f5803e4bd 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md @@ -12,7 +12,7 @@ Messages 传输会在配置的基址后追加 Anthropic 标准 `/v1` 命名空 共享 Messages API 所有者移除末尾斜线,仅把严格等于 `v1` 的最后路径段视为已有 API 版本。它保留该根地址并追加 `/messages` 或 `/files`;其他基址均追加 `/v1/messages` 或 `/v1/files`。缓存文件上传也使用同一个解析后的根地址划分作用域。因此,官方 `https://api.deepseek.com/anthropic` 基址解析为 `/anthropic/v1`,显式 `/anthropic/v1` 基址保持不变。 -Chat Completions 保留独立的 URL 行为。Messages 规则不会推断对 `v1beta`、`v2`、`v4` 或其他版本式后缀的支持;包含这些路径段的部署会在其下获得标准 `/v1` 命名空间。 +Messages 规则不推断 `v1beta`、`v2`、`v4` 或其他类似版本后缀的支持;包含这些路径段的部署会在其下接收标准 `/v1` 命名空间。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml index 6bb2fcf803..e6974943f5 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md -2026-09-16-messages-historical-tool-input.md: 201b0f21ad041395a4c4101d6919b2878e22c9ab -2026-09-16-messages-historical-tool-input.zh.md: bcc637982420124b401ef65c668b068394b6d915 +2026-09-16-messages-historical-tool-input.md: 85e4047bb5274eb7c7adb458d564e1268d4ea81e +2026-09-16-messages-historical-tool-input.zh.md: 5a8c31dbee32094f124102ccac9ffb7b8b81b177 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md index 201b0f21ad..85e4047bb5 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md @@ -10,7 +10,7 @@ Chat Completions retains tool arguments as strings, including malformed JSON fro ## Decision -The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) follows the [pi-ai history conversion](../../../../packages/llm/llm-pi-ai/src/replay.ts): malformed JSON and non-object values become `{}` only in the outgoing historical tool input. Call ids, names, results, and original Session records remain intact. This applies with valid, absent, or unusable native replay metadata and does not execute the historical call again. +The [Messages serializer](../../../../packages/llm/llm-deepseek/src/serialize.ts) follows the [pi-ai history conversion](../../../../packages/llm/llm-pi-ai/src/replay.ts): malformed JSON and non-object values become `{}` only in the outgoing historical tool input. Call ids, names, results, and original Session records remain intact. This applies with valid, absent, or unusable native replay metadata and does not execute the historical call again. This supersedes the historical argument rejection in the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md). New Messages responses still require valid object arguments before successful completion; output-limit truncation retains its existing pruning behavior. No Session event, persistence type, or protocol configuration changes. diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md index bcc6379824..5a8c31dbee 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md @@ -10,7 +10,7 @@ Chat Completions 将工具参数保留为字符串,其中可能包含失败调 ## 决策 -[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) 遵循 [pi-ai 历史转换](../../../../packages/llm/llm-pi-ai/src/replay.ts)的做法:只在发出的历史工具输入中,将非法 JSON 和非对象值替换为 `{}`。调用 ID、名称、结果和原始 Session 记录保持不变。原生回放元数据有效、缺失或不可用时均采用此规则,也不会重新执行历史调用。 +[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/serialize.ts) 遵循 [pi-ai 历史转换](../../../../packages/llm/llm-pi-ai/src/replay.ts)的做法:只在发出的历史工具输入中,将非法 JSON 和非对象值替换为 `{}`。调用 ID、名称、结果和原始 Session 记录保持不变。原生回放元数据有效、缺失或不可用时均采用此规则,也不会重新执行历史调用。 这取代了 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的历史参数拒绝规则。新生成的 Messages 响应在成功完成前仍要求工具参数是有效对象;达到输出上限时仍按现有规则裁剪。不改变 Session 事件、持久化类型或协议配置。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml index 0898823b35..259613bc28 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md -2026-09-18-messages-input-history-compatibility.md: 1775276deec6180f1fd186b11cb7a757a3328a54 -2026-09-18-messages-input-history-compatibility.zh.md: 50367e33c29df0679a44aca1fe680d2bedc8c263 +2026-09-18-messages-input-history-compatibility.md: dd974fb1ae883716002a1381f4c374dee792b3b3 +2026-09-18-messages-input-history-compatibility.zh.md: 6e850328db63d5ae5cb508aa3529e9f2507182b0 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md index 1775276dee..dd974fb1ae 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md @@ -10,7 +10,7 @@ Saved subagent settlement notices can contain child reasoning and tool calls in ## Decision -The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks. +The [Messages serializer](../../../../packages/llm/llm-deepseek/src/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks. This partially supersedes the settlement decision's rejection of serializer tolerance and the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md)'s input rejection. Notice construction still projects nonempty child text for every parent provider. Canonical child output and ordinary assistant reasoning and tool calls remain available to their existing consumers. The omission is based on input role and block type, not the notice source or creation date, so it also applies to newly supplied user and tool-result content. diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md index 50367e33c2..6e850328db 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。 +[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。 这部分取代了结算决策对序列化器容错的拒绝,以及 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的输入拒绝规则。通知构建仍为每种父级提供方投影子级的非空文本。子级的规范输出以及普通助手推理和工具调用仍可供现有消费者使用。省略依据是输入角色和块类型,而不是通知来源或创建日期,因此也适用于新提供的用户和工具结果内容。 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index df9b8b6339..4f9e640fe2 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: 7431a474b55a9dae82b1c9cd36b770b31fe39602 -2026-08-20-unified-image-request-pipeline.zh.md: 247a57b8017f513c552204d951ef6931f7f9d045 +2026-08-20-unified-image-request-pipeline.md: b79eeb7d908d664362851c28bda64dab163aac89 +2026-08-20-unified-image-request-pipeline.zh.md: 444234715256c48167b0b45dbd8843a9c80079f7 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index 7431a474b5..b79eeb7d90 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -34,7 +34,7 @@ Every retained request image is preceded by its display name or complete attachm ### DeepSeek Files lifecycle -The direct `deepseek-official` adapter normally uploads every retained request version through its selected protocol’s Files API and sends file-id references. [Messages](2026-09-07-deepseek-messages-adapter.md) and Chat Completions share the lifecycle while retaining their own endpoint and wire formats. A [bounded inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) sends the same deterministic request versions when file resolution fails. The default catalog advertises `deepseek-v4-flash-vision-exp` as image-capable. Uploaded ids are indexed by Files endpoint and API-key scope plus `variantId`. Uploads request seven days by default. Chat records the returned `expires_at`; Messages metadata omits expiry, so its local reuse deadline uses the original upload time plus the configured lifetime without promising remote deletion. A mapping with no more than one hour of reuse remaining is replaced without a preceding retrieve call. The index never stores the API key. +The direct `deepseek-official` adapter uploads every retained request version through the [Messages Files API](2026-09-07-deepseek-messages-adapter.md) and sends file-id references. A [bounded inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) sends the same deterministic request versions when file resolution fails. Uploaded ids are indexed by Files endpoint and API-key scope plus `variantId`. Uploads request seven days by default. Messages metadata omits expiry, so local reuse uses the original upload time plus the configured lifetime without promising remote deletion. A mapping with no more than one hour of reuse remaining is replaced without a preceding retrieve call. The index never stores the API key. An upload is indexed only after the response returns a valid native file object with a matching byte count and the client establishes the protocol’s reuse deadline. A missing or inconsistent response leaves no local mapping, so a later request uploads again. Concurrent upload resolution for one scoped `variantId` shares one provider operation; one waiter cannot cancel another, and the upload stops when every waiter has cancelled. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If the model endpoint reports expired, deleted, missing, or invalid ids and names one or more ids used by the request, only those mappings are removed. A stale-file response without a specific id removes every mapping used by that model request. The affected request bytes are uploaded again and the model request is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third model request. One upload quota error first lists the configured number of oldest harness-owned `dsh-` files, then deletes that collected set and retries once; deleting after pagination keeps provider cursors valid. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. Every Files request carries the shared Harness `User-Agent`. The client enforces a 128MiB upload limit, a 32MiB request-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range. diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index 247a57b801..4442347152 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -34,7 +34,7 @@ Status: implemented ### DeepSeek Files 生命周期 -直接 `deepseek-official` 适配器通常通过所选协议的 Files API 上传每张保留的请求版本,并发送 file-id 引用。[Messages](2026-09-07-deepseek-messages-adapter.zh.md) 与 Chat Completions 共享生命周期,各自保留端点和协议格式。文件解析失败时,[有界内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)会发送相同的确定性请求版本。默认 catalog 把 `deepseek-v4-flash-vision-exp` 公布为支持图片。上传 ID 按 Files 端点和 API key 作用域以及 `variantId` 写入索引。上传默认请求 7 天有效期。Chat 记录返回的 `expires_at`;Messages 元数据不含过期时间,因此本地复用期限使用原始上传时间加配置的生存期,但不保证远端文件删除。本地映射剩余复用时间不超过一小时时会直接替换,不会先查询远端文件。索引绝不存储 API key。 +直连 `deepseek-official` 适配器通过 [Messages Files API](2026-09-07-deepseek-messages-adapter.zh.md) 上传每个保留的请求版本,并发送 file-id 引用。文件解析失败时,[有界内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)发送相同的确定性请求版本。上传 id 按 Files 端点、API-key 作用域及 `variantId` 建立索引。上传默认请求七天有效期。Messages 元数据不含过期时间,因此本地复用使用原始上传时间加配置的有效期,不承诺远端删除。剩余复用时间不超过一小时的映射会直接替换,不先调用 retrieve。索引从不存储 API 密钥。 只有上传响应返回有效的原生文件对象、字节数匹配,且客户端确定了该协议的复用期限时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。同一作用域和 `variantId` 的并发解析共享一次提供方上传;单个等待方无法取消其他等待方,全部等待方取消时才会停止上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果模型端点报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次模型请求使用的全部映射。受影响的请求字节会重新上传,模型请求只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次模型请求。一次上传配额错误会先列出配置数量的最旧 `dsh-` 文件,再删除收集到的文件并重试一次;分页完成后才删除,避免游标失效。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。每个 Files 请求都携带 Harness 的共享 `User-Agent`。客户端执行 Files 单次上传 128MiB、请求单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml index d726d38ae1..59be879b5b 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md -2026-09-07-deepseek-messages-adapter.md: b79c2526461843a9534d6a997029281a584e0bed -2026-09-07-deepseek-messages-adapter.zh.md: 49a967828e22803296ae3d4c8eef943b9ec8e3c5 +2026-09-07-deepseek-messages-adapter.md: e8c84066e2ede5bdaf9e490b4ac0c9562cc8a557 +2026-09-07-deepseek-messages-adapter.zh.md: bde7fea769fbc5b4c2e622067b89ff8f29e91fb4 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md index b79c252646..e8c84066e2 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md @@ -10,25 +10,25 @@ Deployments expose DeepSeek through Anthropic Messages gateways as well as chat- ## Decision -The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves multiple protocols under one `deepseek-official` route and `llm-deepseek` settings namespace. `common/` shares configuration, the model catalog, capability resolution, and Files lifecycle; `protocols/chat-completions/` and `protocols/messages/` own serialization, stream conversion, and transport. Cordis YAML selects the implementation through `protocol`, defaulting to `messages`; shipped first-party compositions inherit that default. The existing `PreparedAdapterCall` freezes protocol, endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration. +The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves Messages under one `deepseek-official` route and `llm-deepseek` settings namespace. The [single-protocol decision](../simplification/2026-09-19-deepseek-messages-only.md) owns the transport scope. `PreparedAdapterCall` freezes the endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration. -The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. Both protocols forward explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration. +The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. The adapter forwards explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration. Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the protocol format, model identity, aligned block kinds, and signatures absent from those blocks. Same-model Messages continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. Unusable metadata follows the existing [replay degradation rule](../architecture/2026-07-14-provider-routed-llm-adapters.md): the request omits signatures with a warning while preserving durable content; historical tool arguments use the [empty-input fallback](../bug-fix/2026-09-16-messages-historical-tool-input.md) when Messages cannot represent them. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning. -Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md), while Chat Completions appends `/files`. Messages Files requests carry the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned Messages roots share uploads. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement. +The adapter prefers Files references for deterministic request images, with upload caching, refresh, quota recovery, and attachment offload. The Files client follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md) and sends the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned roots share uploads. Metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement. JSON syntax failure after a successful HTTP response does not establish a transport failure. Files decoding uses the existing `INVALID_RESPONSE` code with operation context instead of relabeling the failure as `TRANSPORT` or adding a retry; the [provider README](../../../../packages/llm/llm-deepseek/README.md) owns the error fields. System updates use the existing [route capability](2026-09-02-in-history-system-prompt-replacement.md) when explicitly declared for an endpoint/model. Messages retains the initial top-level system and emits later snapshots as native system turns after the corresponding user/tool-result turn, preserving previously sent prefixes. This placement differs from the loop's system-before-user admission; serialization changes neither the durable log nor conversation-turn order. Undeclared routes consolidate the latest snapshot at the top level, including direct compaction calls. Capability inference from protocol or model names is insufficient because support and update semantics depend on the deployed endpoint. -Web always displays DeepSeek without a protocol selector. Both protocols share `baseURL` and `apiKeyEnv`, with no nested per-protocol configuration map. Without an endpoint override, resolution uses the selected protocol’s official default; Messages uses `https://api.deepseek.com/anthropic`. Switching retains existing endpoint overrides, whose compatibility belongs to the deployment. Messages follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog includes `deepseek-flash` text/image and in-history system capabilities and retains the V4 entries. Explicit `chat-completions` remains supported with its own official default and appends `/chat/completions` without adding a version segment. +Web displays DeepSeek with an endpoint and credential reference. Without an endpoint override, resolution uses `https://api.deepseek.com/anthropic`. Overrides must support Messages. The adapter follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog advertises the capabilities declared by its entries. -Both transports use the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accept captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings. +The adapter uses the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accept captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings. ## Alternatives considered -**Separate plugins per protocol.** This duplicates credentials, catalogs, settings cards, and provider identities, and forces users to reselect models when the wire protocol changes. Protocol folders inside one plugin retain implementation isolation; Responses can add an implementation without changing the user configuration structure. +**Separate Messages provider identity.** This duplicates credentials, catalogs, and settings cards, and forces users to reselect models when adopting Messages. The `deepseek-official` identity keeps those user choices stable. **Delegate the new route to pi-ai or the Anthropic SDK.** Both provide maintained protocol implementations, but the requested direct adapter needs DeepSeek-specific configuration, attachment policy, credential resolution, and retry ownership. A small stream translator with a maintained SSE parser keeps these responsibilities explicit; the library-backed adapter remains available independently. @@ -38,6 +38,6 @@ Both transports use the existing [request-extension registry](../architecture/20 ## Consequences -The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol. +The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages serves the shared base, Web, and standalone first-party compositions. Explicit endpoint overrides must support Messages. Verification covers wire fixtures, real Loader composition, per-file unit coverage, [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml) with [unknown replay versions](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy. diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md index 49a967828e..bde7fea769 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md @@ -10,25 +10,25 @@ Status: implemented ## 决策 -[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)通过一个 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间支持多个协议。`common/` 共享配置、模型目录、能力解析和 Files 生命周期;`protocols/chat-completions/` 与 `protocols/messages/` 分别负责协议序列化、流转换和传输。`protocol` 配置在 Cordis YAML 中选择实现,默认 `messages`;随产品交付的官方组合继承该默认值。已有 `PreparedAdapterCall` 冻结协议、端点、凭据引用与模型能力,重试保持同一代配置,后续调用读取新配置。 +[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)在唯一的 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间下提供 Messages。[单协议决策](../simplification/2026-09-19-deepseek-messages-only.zh.md)拥有传输范围。`PreparedAdapterCall` 固定端点、凭据引用与模型能力;重试保留该代配置,后续调用读取新配置。 -适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。两种协议都转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。 +适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。适配器转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。 助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存协议格式、模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;Messages 无法表示历史工具参数时使用[空输入兜底](../bug-fix/2026-09-16-messages-historical-tool-input.zh.md)。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。 -两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 遵循[严格匹配 `/v1` 的根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md),Chat Completions 则追加 `/files`。Messages Files 请求携带必需的 beta 标头。缓存 id 按解析后的 Files 根地址和凭据限定作用域,因此等价的 `/v1` 与无版本 Messages 根地址可以复用上传。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。 +适配器优先使用确定性请求图片的 Files 引用,并管理上传缓存、刷新、配额恢复与附件省略。Files 客户端遵循[精确 `/v1` 根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md)并发送必需的 beta 标头。缓存 id 按解析后的 Files 根地址与凭据限定作用域,因此等价的 `/v1` 与无版本根地址共用上传。元数据不含过期时间,因此本地复用以原始上传时间为界,不断言远端删除。Files 解析失败会按独立内联图片预算重建完整请求;调用方取消会停止它。图片策略在请求和 token 计量中保留 128 MiB 保留图片预算、20 MiB 内联 base64 预算及最旧前缀省略。 成功 HTTP 响应之后的 JSON 语法错误不足以证明传输故障。Files 解码复用已有的 `INVALID_RESPONSE` code 并补充操作上下文,不将其改标为 `TRANSPORT` 或添加重试;错误字段由 [provider README](../../../../packages/llm/llm-deepseek/README.zh.md) 维护。 系统提示词更新在端点与模型显式声明支持时,使用现有[路由能力](2026-09-02-in-history-system-prompt-replacement.zh.md)。Messages 保留初始顶层 system,在对应的用户或工具结果轮次之后,将后续快照发送为原生 system 轮次,保留此前发送的前缀。这个位置不同于循环先 system、后 user 的接纳顺序;序列化既不改写持久化日志,也不改变对话轮次的顺序。未声明能力的路由将最新快照归并到顶层,直接压缩调用也如此。仅凭协议或模型名称推断能力并不充分,因为支持情况和更新语义取决于实际部署的端点。 -Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseURL` 与 `apiKeyEnv`,没有嵌套的协议配置表。未提供地址覆盖时使用当前协议的官方默认值;Messages 为 `https://api.deepseek.com/anthropic`。切换协议保留已有端点覆盖,部署者负责其兼容性。Messages 遵循严格匹配 `/v1` 的根地址规则,不根据其他版本式后缀推断兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。显式 `chat-completions` 仍受支持,并使用自己的官方默认值,追加 `/chat/completions` 而不增加版本段。 +Web 显示 DeepSeek,并提供端点与凭据引用。没有端点覆盖时,解析使用 `https://api.deepseek.com/anthropic`。覆盖地址必须支持 Messages。适配器遵循精确 `/v1` 根地址规则,不从其他类似版本的后缀推断兼容性。单一模型目录公布各条目声明的能力。 -两种传输都在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。 +适配器在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。 ## 考虑过的替代方案 -**每个协议独立插件。** 这会重复凭据配置、模型目录、设置卡片和 provider ID,并迫使用户在底层协议变化时重选模型。单插件中的协议目录保留实现隔离;Responses 可以增加自己的实现而不改变用户配置结构。 +**独立的 Messages 提供方身份。** 这会重复凭据、目录与设置卡,并迫使用户在采用 Messages 时重新选择模型。`deepseek-official` 身份使这些用户选择保持稳定。 **把新路由委托给 pi-ai 或 Anthropic SDK。** 两者均提供持续维护的协议实现,但所需的直接适配器需要 DeepSeek 专属配置、附件策略、凭证解析和重试所有权。小型流转换器配合持续维护的 SSE 解析器使这些职责保持明确;库实现适配器仍可独立使用。 @@ -38,6 +38,6 @@ Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseU ## 结果 -该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。 +该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 服务共享 base、Web 和独立的官方组合。显式端点覆盖必须支持 Messages。 验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml)与[未知回放版本](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。 diff --git a/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.i18n.yaml new file mode 100644 index 0000000000..07287b00f8 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md +2026-09-19-deepseek-messages-only.md: c10ecae950bb12f4556021de21c9716ad2906227 +2026-09-19-deepseek-messages-only.zh.md: 3cfadac6ebc0b4bb1587a8b72e9b169d2ade2ee9 diff --git a/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md new file mode 100644 index 0000000000..c10ecae950 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md @@ -0,0 +1,25 @@ +# Agent Note: One Messages transport for the official DeepSeek route + +Status: implemented + +English | [中文](2026-09-19-deepseek-messages-only.zh.md) + +## Problem + +The official DeepSeek route needs Messages thinking replay, image uploads, and in-history updates. Selecting a second transport duplicates serializers, stream handling, Files wire formats, configuration branches, and fixtures without adding a required capability to this route. + +## Decision + +`dsh-llm-deepseek` uses Messages exclusively. One adapter resolves a request-local configuration snapshot and performs the request. Configuration exposes one endpoint and no protocol selector. The Files client uses Messages authentication, metadata, and pagination directly; quota cleanup scans every page before selecting the oldest owned files. + +The [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md) continues to own native thinking replay, system-message placement, image recovery, and request-extension acceptance. Replay discriminators and upload-index namespaces identify durable data and retain their existing values. The separate pi-ai adapter retains its provider-specific protocols. + +## Alternatives considered + +**Keep Chat Completions behind an optional selector.** This retains the duplicated implementation and test matrix even though the official route requires only Messages. + +**Keep a generic protocol dispatcher with one implementation.** The dispatcher adds a second dependency representation and forwards every adapter operation without selecting anything. + +## Consequences + +The official route requires a Messages-compatible endpoint. Its mock-backed recovery, Loader, CLI, and Web tests exercise Messages; removing a wire implementation does not remove those behavior checks. Committed Session generations remain readable through provider-neutral content and the retained Messages replay metadata. diff --git a/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.zh.md b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.zh.md new file mode 100644 index 0000000000..3cfadac6eb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.zh.md @@ -0,0 +1,25 @@ +# Agent Note: One Messages transport for the official DeepSeek route + +Status: implemented + +[English](2026-09-19-deepseek-messages-only.md) | 中文 + +## 问题 + +官方 DeepSeek 路由需要 Messages 思考回放、图片上传与历史内更新。选择第二种传输会重复序列化器、流处理、Files 协议格式、配置分支与夹具,却没有为这条路由增加必需能力。 + +## 决策 + +`dsh-llm-deepseek` 仅使用 Messages。一个适配器解析请求局部的配置快照并执行请求。配置暴露一个端点,不提供协议选择器。Files 客户端直接使用 Messages 认证、元数据与分页;配额清理扫描全部页面后再选择最旧的自有文件。 + +[Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)继续拥有原生思考回放、系统消息位置、图片恢复与请求扩展接受规则。回放判别值和上传索引命名空间标识持久数据,并保留现有值。独立的 pi-ai 适配器保留各提供方协议。 + +## 考虑过的替代方案 + +**在可选选择器后保留 Chat Completions。** 即使官方路由只需要 Messages,这仍会保留重复实现与测试矩阵。 + +**保留只有一个实现的通用协议分派器。** 分派器增加第二种依赖表示,并转发每个适配器操作,却没有进行任何选择。 + +## 后果 + +官方路由要求兼容 Messages 的端点。其基于 mock 的恢复、Loader、CLI 与 Web 测试使用 Messages;移除一种协议实现不会移除这些行为检查。已提交的 Session 代际继续通过提供方无关内容与保留的 Messages 回放元数据读取。 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 1cd1c1636c..874e216499 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -520,7 +520,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', successText: 'ACP BUILT PROFILE OK', }) const home = mkdtempSync(join(tmpdir(), 'dsh-built-acp-')) - writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n') const child = execa(process.execPath, [dshBin, '--profile', 'acp'], { cwd: home, reject: false, @@ -606,7 +605,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', successText: 'published headless profile reached the mock', }) const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-')) - writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n') try { const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], { DSH_HOME: home, @@ -618,7 +616,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(result.stdout).toBe('published headless profile reached the mock') expect(result.stderr).toBe('dsh: reasoning:\nInspecting the published entry.') expect(server.requests.length).toBeGreaterThan(0) - expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true) + expect(server.requests.every(request => request.path === '/v1/messages')).toBe(true) expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry') } finally { await server.close() @@ -743,7 +741,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', successText: 'launching endpoint reached the mock', }) const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-')) - writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n') const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-')) writeFileSync(join(home, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 }) createEnvironmentProbeProfile(home, project) @@ -766,8 +763,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(result.stdout).not.toContain(apiKey) expect(result.stderr).not.toContain(apiKey) expect(server.requests).toHaveLength(1) - expect(server.requests[0]?.path).toBe('/chat/completions') - expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`) + expect(server.requests[0]?.path).toBe('/v1/messages') + expect(server.requests[0]?.headers['x-api-key']).toBe(apiKey) expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey) } finally { await server.close() diff --git a/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml b/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml index e369eb59f3..aec25ed875 100644 --- a/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml +++ b/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml @@ -4,7 +4,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - protocol: !!js process.env.DSH_SNAPSHOT_PROTOCOL apiKeyEnv: DSH_SNAPSHOT_API_KEY baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL thinking: disabled diff --git a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts index 16da9a81d7..59195c7669 100644 --- a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts +++ b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts @@ -18,9 +18,9 @@ const IMAGE_OFFLOAD_CONFIG = fileURLToPath(new URL('./fixtures/image-offload.cor const SNAPSHOTS_DIR = fileURLToPath(new URL('../../../../../../snapshots/acp/', import.meta.url)) const READ_IMAGE_WORKSPACE = fileURLToPath(new URL('../../../../../../snapshots/session/read-image/workspace/', import.meta.url)) -it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and inline fallback in assembled requests', async (protocol) => { - const filesPath = protocol === 'messages' ? '/v1/files' : '/files' - const modelPath = protocol === 'messages' ? '/v1/messages' : '/chat/completions' +it('pins Messages Files offload and inline fallback in assembled requests', async () => { + const filesPath = '/v1/files' + const modelPath = '/v1/messages' const requests: Record[] = [] const fileRequests: Array<{ method: string; path: string; bytes: number }> = [] let rejectFiles = false @@ -49,21 +49,13 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in return } const createdAt = Math.floor(Date.now() / 1_000) - response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(protocol === 'messages' ? { + response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ id: 'file-api-snapshot-1', type: 'file', size_bytes: file.size, created_at: new Date(createdAt * 1_000).toISOString(), filename: 'dsh-snapshot.png', mime_type: file.type, - } : { - id: 'file-api-snapshot-1', - object: 'file', - bytes: file.size, - created_at: createdAt, - filename: 'dsh-snapshot.png', - purpose: 'user_data', - expires_at: createdAt + Number(form.get('expires_after[seconds]')), })) return } @@ -73,35 +65,18 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in } requests.push(JSON.parse(body.toString('utf8')) as Record) response.writeHead(200, { 'content-type': 'text/event-stream' }) - if (protocol === 'messages') { - const toolCall = requests.length === 1 - const events = [ - { type: 'message_start', message: { id: 'offload-response', model: 'deepseek-v4-flash-vision-exp', usage: { input_tokens: 3, output_tokens: 0 } } }, - { type: 'content_block_start', index: 0, content_block: toolCall - ? { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } } - : { type: 'text', text: 'DONE' } }, - { type: 'content_block_stop', index: 0 }, - { type: 'message_delta', delta: { stop_reason: toolCall ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 1 } }, - { type: 'message_stop' }, - ] - response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')) - return - } - const events = requests.length === 1 - ? [ - 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"native-read-image","type":"function","function":{"name":"read_image","arguments":"{\\"file_path\\":\\"red.png\\"}"}}]},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ] - : [ - 'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ] - response.end(events.join('\n\n')) + const toolCall = requests.length === 1 + const events = [ + { type: 'message_start', message: { id: 'offload-response', model: 'deepseek-v4-flash-vision-exp', usage: { input_tokens: 3, output_tokens: 0 } } }, + { type: 'content_block_start', index: 0, content_block: toolCall + ? { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } } + : { type: 'text', text: 'DONE' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: toolCall ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, + ] + response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')) + return })().catch((error: unknown) => { response.writeHead(500, { 'content-type': 'text/plain' }).end(String(error)) }) @@ -137,7 +112,6 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'), workspaceDir: READ_IMAGE_WORKSPACE, env: { - DSH_SNAPSHOT_PROTOCOL: protocol, DSH_SNAPSHOT_API_KEY: 'snapshot-key', DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`, }, @@ -162,14 +136,9 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in + ' Copy to a writable path ending in .png before editing.' } const normalizedAccess = accessText(result.cwd) - const imagePrefix = protocol === 'messages' ? '' : '\n' - const fileImage = protocol === 'messages' - ? { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } } - : { type: 'file', file_id: 'file-api-snapshot-1' } + const fileImage = { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } } const offloadedImage = `[image omitted to fit request image limits; ${attachmentId}.${normalizedAccess}]` const imageHandle = `Image ${attachmentId}; request preview 1x1px.${normalizedAccess}` - const normalizedToolImageHandle = `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` - .replaceAll(result.cwd, '{{cwd}}') const runtimeContext = 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n' + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n' + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).' @@ -179,80 +148,31 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in { type: 'text', text: 'Compare the older image ' }, { type: 'text', text: offloadedImage }, { type: 'text', text: ' with the newer image ' }, - { type: 'text', text: `${imagePrefix}${imageHandle}` }, + { type: 'text', text: imageHandle }, fileImage, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, - ...protocol === 'messages' ? [{ type: 'text', text: runtimeContext }] : [], + { type: 'text', text: runtimeContext }, ]) - if (protocol === 'messages') { - const followup = requests[1]?.messages as Array<{ role: string; content: Array> }> - expect(followup).toHaveLength(3) - expect(followup[0]?.content.filter(block => block.type === 'image')).toEqual([]) - expect(followup[0]?.content.filter(block => block.text === offloadedImage)).toHaveLength(2) - expect(followup[1]).toEqual({ role: 'assistant', content: [ - { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } }, - ] }) - expect(followup[2]?.role).toBe('user') - expect(followup[2]?.content).toHaveLength(1) - const toolResult = followup[2]!.content[0]! - expect(toolResult.type).toBe('tool_result') - expect(toolResult.tool_use_id).toBe('native-read-image') - const content = toolResult.content as Array> - expect(content[0]?.type).toBe('text') - expect(content[0]?.text).toContain('image/png image, 1x1 px, 69 bytes') - expect(content.slice(1)).toEqual([ - { type: 'text', text: `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` }, - fileImage, - ]) - } else { - const followup = structuredClone((requests[1]?.messages as unknown[]).slice(1)) as Array<{ - role?: unknown - content?: unknown - }> - const toolMessage = followup.find(message => message.role === 'tool') - if (toolMessage === undefined || typeof toolMessage.content !== 'string') { - throw new Error('native read_image request has no tool content') - } - const cwdSpellings = [...new Set([result.cwd, ...result.cwdAliases].flatMap(cwd => ( - cwd.startsWith('/private/') ? [cwd, cwd.slice('/private'.length)] : [cwd, `/private${cwd}`] - )))] - let toolContent = toolMessage.content - for (const cwd of cwdSpellings) toolContent = toolContent.replaceAll(cwd, '{{cwd}}') - toolMessage.content = toolContent - expect(followup).toEqual([ - { - role: 'user', - content: `Compare the older image ${offloadedImage} with the newer image ${offloadedImage}, then use read_image on red.png and reply with DONE.`, - }, - { - role: 'user', - content: runtimeContext, - }, - { - role: 'assistant', - content: '', - tool_calls: [{ - id: 'native-read-image', - type: 'function', - function: { name: 'read_image', arguments: '{"file_path":"red.png"}' }, - }], - }, - { - role: 'tool', - tool_call_id: 'native-read-image', - content: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n' - + `\n${normalizedToolImageHandle}`, - }, - { - role: 'user', - content: [ - { type: 'text', text: 'Attached image(s) from tool result:' }, - { type: 'file', file_id: 'file-api-snapshot-1' }, - ], - }, - ]) - } + const followup = requests[1]?.messages as Array<{ role: string; content: Array> }> + expect(followup).toHaveLength(3) + expect(followup[0]?.content.filter(block => block.type === 'image')).toEqual([]) + expect(followup[0]?.content.filter(block => block.text === offloadedImage)).toHaveLength(2) + expect(followup[1]).toEqual({ role: 'assistant', content: [ + { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } }, + ] }) + expect(followup[2]?.role).toBe('user') + expect(followup[2]?.content).toHaveLength(1) + const toolResult = followup[2]!.content[0]! + expect(toolResult.type).toBe('tool_result') + expect(toolResult.tool_use_id).toBe('native-read-image') + const content = toolResult.content as Array> + expect(content[0]?.type).toBe('text') + expect(content[0]?.text).toContain('image/png image, 1x1 px, 69 bytes') + expect(content.slice(1)).toEqual([ + { type: 'text', text: `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` }, + fileImage, + ]) rejectFiles = true const fallback = await runScenario(input, { @@ -262,7 +182,6 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'), workspaceDir: READ_IMAGE_WORKSPACE, env: { - DSH_SNAPSHOT_PROTOCOL: protocol, DSH_SNAPSHOT_API_KEY: 'snapshot-fallback-key', DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`, }, @@ -280,12 +199,10 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in { type: 'text', text: 'Compare the older image ' }, { type: 'text', text: `[image omitted to fit request image limits; ${attachmentId}.${fallbackAccess}]` }, { type: 'text', text: ' with the newer image ' }, - { type: 'text', text: `${imagePrefix}Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` }, - protocol === 'messages' - ? { type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } } - : { type: 'image_url', image_url: { url: `data:image/png;base64,${image}` } }, + { type: 'text', text: `Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } }, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, - ...protocol === 'messages' ? [{ type: 'text', text: runtimeContext }] : [], + { type: 'text', text: runtimeContext }, ]) } finally { await new Promise(resolve => server.close(() => { resolve() })) diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 4724981169..93e6e27ad4 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -83,7 +83,9 @@ async function expectHeadlessStream(normalized: string, expectedPath: string): P } /** Serve one deterministic DeepSeek-compatible response while retaining its request body. */ -async function deepseekDefaultsServer(options: { waitForTitleRequest?: boolean; protocol?: 'messages' } = {}): Promise { +async function deepseekDefaultsServer( + options: { waitForTitleRequest?: boolean; piAiCompatibility?: true } = {}, +): Promise { const requests: JsonObject[] = [] const paths: string[] = [] const server = createServer((request: IncomingMessage, response: ServerResponse) => { @@ -103,7 +105,7 @@ async function deepseekDefaultsServer(options: { waitForTitleRequest?: boolean; timer = setTimeout(write, 60) return } - if (options.protocol === 'messages') { + if (options.piAiCompatibility !== true) { response.end([ { type: 'message_start', message: { id: 'defaults-response', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } }, { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, @@ -582,7 +584,7 @@ describe('headless stream-json snapshots', () => { }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('keeps provider comments alive and sends DeepSeek defaults through the one-shot app', async () => { - const server = await deepseekDefaultsServer({ protocol: 'messages' }) + const server = await deepseekDefaultsServer() try { const result = await runLoaderSmoke({ label: 'DeepSeek adapter defaults headless stream-json snapshot', @@ -638,7 +640,7 @@ describe('headless stream-json snapshots', () => { }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('keeps the compatibility stream open until the title request arrives', async () => { - const server = await deepseekDefaultsServer({ waitForTitleRequest: true }) + const server = await deepseekDefaultsServer({ waitForTitleRequest: true, piAiCompatibility: true }) try { const response = await fetch(server.url, { method: 'POST', @@ -675,7 +677,7 @@ describe('headless stream-json snapshots', () => { }) it('sends pi-ai DeepSeek compatibility through the one-shot app', async () => { - const server = await deepseekDefaultsServer({ waitForTitleRequest: true }) + const server = await deepseekDefaultsServer({ waitForTitleRequest: true, piAiCompatibility: true }) try { const result = await runLoaderSmoke({ label: 'pi-ai DeepSeek compatibility headless stream-json snapshot', diff --git a/apps/web/tests/deepseek-messages-chat.e2e.ts b/apps/web/tests/deepseek-messages-chat.e2e.ts index 36b37a70ab..d6bbf88260 100644 --- a/apps/web/tests/deepseek-messages-chat.e2e.ts +++ b/apps/web/tests/deepseek-messages-chat.e2e.ts @@ -24,7 +24,6 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', () beforeAll(async () => { replayFixture = await selectedSessionFixture(FIXTURE, false) scaffold = await launchWebScaffold({ - deepSeekMessages: true, replayFixture, paceMs: 5, replayProviders: [{ @@ -36,6 +35,7 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', () }], }], }) + await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' }) browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 32fd2a613e..3018cc432e 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -1,4 +1,4 @@ -/** Opt-in Web Messages configuration, credential reuse, and recovery from a saved Chat Completions selection. */ +/** Web DeepSeek configuration, credential reuse, and saved model selection. */ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -12,14 +12,14 @@ import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './s const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url)) -describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-in', () => { +describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages settings', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, deepSeekMessages: true }) + scaffold = await launchWebScaffold({ deepSeekMissingCredential: true }) browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) @@ -68,7 +68,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt- await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({ name: 'Messages Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', }) - expect(scaffold.ctx.settings.get('llm-deepseek')).toMatchObject({ protocol: 'messages' }) + expect(scaffold.ctx.settings.get('llm-deepseek')).not.toHaveProperty('protocol') expect(settings).not.toContain('sk-e2e-') const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8') expect(credentials).toContain('DEEPSEEK_API_KEY: sk-e2e-messages') @@ -84,7 +84,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt- expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('keeps a saved Chat Completions selection available after the YAML protocol switch', async () => { + it('keeps a saved DeepSeek selection available after editing provider settings', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default')) await page.keyboard.press('Escape') await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index e7249a0b67..544c717a60 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -261,10 +261,9 @@ class RouteOnlyAdapter extends LlmAdapter { } } -function replayProviders(contextWindow: number | undefined, messages: boolean): typeof REPLAY_PROVIDERS { +function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS { return REPLAY_PROVIDERS.map(provider => ({ ...provider, - id: messages ? 'deepseek-messages' : provider.id, models: provider.models.map(model => ({ ...model, ...contextWindow === undefined ? {} : { contextWindow }, @@ -391,8 +390,6 @@ export interface LaunchOptions { * keyless first-run configuration lane; the default disables the adapter. */ deepSeekMissingCredential?: boolean - /** Record or replay a Messages scenario; older scenarios explicitly retain their recorded Chat Completions route. */ - deepSeekMessages?: boolean /** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */ welcomeNoticePending?: boolean /** @@ -494,7 +491,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { @@ -571,13 +567,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ({ + providers: (options.replayProviders ?? replayProviders(options.replayContextWindow)).map(provider => ({ ...provider, ...(options.replayRetryPolicy === undefined ? {} : { retryPolicy: options.replayRetryPolicy }), })), @@ -885,8 +874,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ctx.llm.registerAdapter( - replayProviders(options.replayContextWindow, messages).map(provider => provider.id), - new RouteOnlyAdapter(replayProviders(options.replayContextWindow, messages)), + replayProviders(options.replayContextWindow).map(provider => provider.id), + new RouteOnlyAdapter(replayProviders(options.replayContextWindow)), ), 'web e2e scaffold: route-only adapter') } if (publicProxy === undefined || publicHost === undefined || publicPrefix === undefined) { diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ecb1a48a7b..ca589a4782 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 92b24a0948883b2974c5cb8ab650bd3db3b50326 -config-catalog.zh.md: 4ba0378810f3db4797c0d41fa5230f7069438b3f +config-catalog.md: ed3f3f691a0082a07926e6a2108cf7d929464a55 +config-catalog.zh.md: 00968de695ebde8599be7ca23e246fa6e00e4578 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 92b24a0948..ed3f3f691a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1161,8 +1161,6 @@ Requires: `llm` * reasoning effort resolves to `high`. */ export interface Config { - /** Wire protocol; defaults to messages. Configure through Cordis YAML. */ - protocol?: DeepSeekProtocol /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ @@ -1203,9 +1201,6 @@ export interface Config { retryPolicy?: RetryPolicyConfig } -/** Supported wire implementations; Responses is not yet implemented. */ -export type DeepSeekProtocol = 'chat-completions' | 'messages' - /** One optional model entry advertised by the direct-fetch adapter. */ export interface DeepSeekCatalogModel { /** Wire model id accepted by the configured endpoint. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4ba0378810..00968de695 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1163,8 +1163,6 @@ export interface Config { * reasoning effort resolves to `high`. */ export interface Config { - /** Wire protocol; defaults to messages. Configure through Cordis YAML. */ - protocol?: DeepSeekProtocol /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ @@ -1205,9 +1203,6 @@ export interface Config { retryPolicy?: RetryPolicyConfig } -/** Supported wire implementations; Responses is not yet implemented. */ -export type DeepSeekProtocol = 'chat-completions' | 'messages' - /** One optional model entry advertised by the direct-fetch adapter. */ export interface DeepSeekCatalogModel { /** Wire model id accepted by the configured endpoint. */ diff --git a/docs/deepseek-llm-api-wire-extensions.i18n.yaml b/docs/deepseek-llm-api-wire-extensions.i18n.yaml index 7329ae0f17..547583134d 100644 --- a/docs/deepseek-llm-api-wire-extensions.i18n.yaml +++ b/docs/deepseek-llm-api-wire-extensions.i18n.yaml @@ -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 docs/deepseek-llm-api-wire-extensions.md -deepseek-llm-api-wire-extensions.md: f45508f375d3e624b4d5eded26393a737c3a0907 -deepseek-llm-api-wire-extensions.zh.md: ff72d62a9cdc2a33f6d6fd5fb08975b73839cae7 +deepseek-llm-api-wire-extensions.md: 47ecc9db5438d5992f3b84c1ba3cc9b15561dc2b +deepseek-llm-api-wire-extensions.zh.md: d79f97fc3a029cce04801cdf58eb713007b2eb43 diff --git a/docs/deepseek-llm-api-wire-extensions.md b/docs/deepseek-llm-api-wire-extensions.md index f45508f375..47ecc9db54 100644 --- a/docs/deepseek-llm-api-wire-extensions.md +++ b/docs/deepseek-llm-api-wire-extensions.md @@ -2,7 +2,7 @@ English | [中文](deepseek-llm-api-wire-extensions.zh.md) -This reference defines every DeepSeek Harness-specific HTTP header and additive JSON field sent by [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.md) on `deepseek-official` Messages and Chat Completions requests. It does not redefine fields owned by the upstream DeepSeek API. The provider-neutral LLM interface and `llm-pi-ai` do not implement these additions. +This reference defines every DeepSeek Harness-specific HTTP header and additive JSON field sent by [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.md) on `deepseek-official` Messages requests. It does not redefine fields owned by the upstream DeepSeek API. The provider-neutral LLM interface and `llm-pi-ai` do not implement these additions. The adapter sends the additions to its resolved `baseURL`, including a configured gateway. They remain outside `messages`, system prompts, and tool schemas, so they do not add model-input tokens or alter the model-visible prefix. diff --git a/docs/deepseek-llm-api-wire-extensions.zh.md b/docs/deepseek-llm-api-wire-extensions.zh.md index ff72d62a9c..d79f97fc3a 100644 --- a/docs/deepseek-llm-api-wire-extensions.zh.md +++ b/docs/deepseek-llm-api-wire-extensions.zh.md @@ -2,7 +2,7 @@ [English](deepseek-llm-api-wire-extensions.md) | 中文 -本参考文档定义 [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.zh.md) 在 `deepseek-official` Messages 与 Chat Completions 请求中发送的全部 DeepSeek Harness 特有 HTTP 标头和附加 JSON 字段。本文不重复定义 DeepSeek 上游 API 持有的字段。提供方无关的 LLM(大语言模型)接口与 `llm-pi-ai` 均不实现这些扩展。 +本参考定义 [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.zh.md) 在 `deepseek-official` Messages 请求中发送的全部 DeepSeek Harness 专用 HTTP 标头与附加 JSON 字段。它不重新定义上游 DeepSeek API 自有字段。提供方无关的 LLM 接口与 `llm-pi-ai` 不实现这些扩展。 适配器将这些扩展发送至已解析的 `baseURL`,包括已配置的网关。扩展位于 `messages`、系统提示词和工具 schema 之外,因此不会增加模型输入 token,也不会改变模型可见前缀。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 1aaf9da6b4..079942f1d1 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -449,7 +449,7 @@ # preset; products with a stricter network policy override tool-web. DeepSeek # search resolves the same DEEPSEEK_API_KEY # credential the Models page manages for chat, at each search; its Messages - # endpoint is separate from the chat-completions endpoint, so it takes its own + # endpoint is configured independently from the model route, so it takes its own # base-URL override. Anonymous fetch accepts only public HTTP(S) destinations, # resolves and validates every destination, and pins every actual connection. # Search is a full auxiliary model request with server-side retrieval, so this diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 705ff4a9a4..ad5bbccf32 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -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/bundle/web-app/README.md -README.md: 00f523098ada6646a2cf22ce57ca5fef6c6d9afb -README.zh.md: 2a3ea0d7d480ab2a29aa1d4adcb01836a9b70007 +README.md: 8fea1287610a0a71f2a351a1b8e00af87b20ba44 +README.zh.md: 603108991cd74ba379b3b9d8de9a2ff11bf7e101 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 00f523098a..8fea128761 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -36,9 +36,9 @@ dsh --profile web --no-open --port 8080 After startup you see a `dsh web:` line whose root URL carries a fresh process token. Unless `--no-open` or an SSH session suppresses it, the default browser opens that URL, receives a signed cookie, and redirects to the same directory without the token. You know it worked when the page loads and you can chat with the agent. Two failures to expect: if the frontend is not built, startup stops with a build hint (`pnpm run build` in a checkout); if the browser cannot be opened, a credential-free diagnostic prints to stderr while the server keeps running — open the printed startup URL yourself. -**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V41-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#choose-a-protocol) defaults to Messages; set `protocol: chat-completions` in Cordis YAML to select Chat Completions. Web has no protocol selector. +**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V41-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#endpoint-and-wire-format) uses the Messages API. -Saved model selections override the composition default. Both protocols share `deepseek-official` and `llm-deepseek` settings, so switching preserves model selections and credential references. Endpoint overrides retain their values; the settings card lets users supply a compatible API address. +Saved model selections override the composition default. The settings card accepts a Messages-compatible API address and a credential reference. ### Configuration diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 2a3ea0d7d4..603108991c 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -36,9 +36,9 @@ dsh --profile web --no-open --port 8080 启动后你会看到 `dsh web:` 行,其根 URL 携带新的进程 token。除非 `--no-open` 或 SSH 会话抑制,否则默认浏览器会打开该 URL、取得签名 cookie,再重定向到不含认证参数的同一目录。页面加载且你可以与 agent 对话,就说明成功了。两种可预期的失败:前端未构建时,启动会以构建提示停止(checkout 中运行 `pnpm run build`);浏览器无法打开时,stderr 会打印不含凭据的诊断,但服务器会继续运行——请自行打开已打印的启动 URL。 -**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V41-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#choose-a-protocol)默认使用 Messages;在 Cordis YAML 中设置 `protocol: chat-completions` 可选择 Chat Completions。Web 不提供协议选择器。 +**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V41-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#endpoint-and-wire-format)使用 Messages API。 -已保存的模型选择优先于组合默认值。两种协议共用 `deepseek-official` 与 `llm-deepseek` 设置,因此切换协议不改变模型选择或复制凭据。端点覆盖保持原值;设置卡片允许用户填写兼容的 API 地址。 +已保存的模型选择覆盖组合默认值。设置卡接受兼容 Messages 的 API 地址与凭据引用。 ### 配置 diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index bc89f43fc8..0f21aaff9a 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -414,7 +414,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { type="text" value={stringAt(draft, 'baseURL') ?? ''} placeholder={family === 'deepseek' - ? t(stringAt(fallback, 'protocol') === 'messages' ? 'deepSeekMessagesBaseUrl' : 'deepSeekChatBaseUrl') + ? t('deepSeekBaseUrl') : stringAt(fallback, 'baseURL') ?? t('baseUrlDefault')} aria-describedby={family === 'deepseek' ? `${props.provider}-endpoint-hint` : undefined} aria-label={t('baseUrl')} diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 2b0f92d8df..64cb2c73f8 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -35,8 +35,7 @@ export const en = { customized: 'Customized settings', baseUrl: 'Base URL', baseUrlDefault: 'Provider default', - deepSeekChatBaseUrl: 'https://api.deepseek.com', - deepSeekMessagesBaseUrl: 'https://api.deepseek.com/anthropic', + deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', deepSeekEndpointHint: 'Use an endpoint compatible with the configured connection.', models: 'Models', modelsInherited: 'Using the adapter defaults', @@ -147,8 +146,7 @@ export const zh: { [Key in keyof typeof en]: string } = { customized: '自定义设置', baseUrl: 'API 地址', baseUrlDefault: '提供方默认', - deepSeekChatBaseUrl: 'https://api.deepseek.com', - deepSeekMessagesBaseUrl: 'https://api.deepseek.com/anthropic', + deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', deepSeekEndpointHint: '请填写与当前连接配置兼容的 API 地址。', models: '模型目录', modelsInherited: '正在使用适配器默认模型', diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index e9cec03e60..14236346c1 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -618,7 +618,7 @@ describe('ModelsSection', () => { const baseURL = screen.getByLabelText(en.baseUrl) // The deepseek placeholder is pinned to the public endpoint, not the // effective value (which may reflect a launch-environment override). - expect(baseURL.placeholder).toBe('https://api.deepseek.com') + expect(baseURL.placeholder).toBe('https://api.deepseek.com/anthropic') fireEvent.change(baseURL, { target: { value: 'https://next2' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) @@ -666,11 +666,11 @@ describe('ModelsSection', () => { ]) }) - it('edits the shared DeepSeek card while preserving the YAML protocol selection', async () => { + it('edits the DeepSeek endpoint and credential without a protocol selection', async () => { const namespace: SettingsNamespaceView = { ...wireNamespaces()[0]!, ns: 'llm-deepseek', - value: { protocol: 'messages', apiKeyEnv: 'DEEPSEEK_API_KEY', models: DEFAULT_DEEPSEEK_MODELS }, + value: { apiKeyEnv: 'DEEPSEEK_API_KEY', models: DEFAULT_DEEPSEEK_MODELS }, user: {}, } const { face, mutate, set } = scriptedFace({ @@ -1102,7 +1102,7 @@ describe('ModelsSection', () => { />) fireEvent.click(screen.getByText(en.customized)) const baseURL = screen.getByLabelText(en.baseUrl) - expect(baseURL.placeholder).toBe('https://api.deepseek.com') + expect(baseURL.placeholder).toBe('https://api.deepseek.com/anthropic') fireEvent.change(baseURL, { target: { value: 'https://x' } }) expect(baseURL.value).toBe('https://x') fireEvent.change(baseURL, { target: { value: '' } }) diff --git a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts index 2123638197..001b307a65 100644 --- a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts +++ b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts @@ -10,9 +10,10 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import BasicCompactionEngine from '@deepseek-ai/dsh-compaction-basic' import TokenMeter from '@deepseek-ai/dsh-token-meter' import { ImageVariantId } from '@deepseek-ai/dsh-attachment' -import { serializeRequestWithImages } from '@deepseek-ai/dsh-llm-deepseek/src/protocols/chat-completions/serialize.ts' +import { resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' +import { inlineImages } from '@deepseek-ai/dsh-llm-deepseek/src/images.ts' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import { createAssistantMessage, createToolResultMessage, createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' +import { createAssistantMessage, createToolResultMessage, createUserMessage, projectOffloadedImages, offloadedImageText, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { isReplacementSurfaceEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' @@ -32,15 +33,15 @@ class ScriptedAdapter extends LlmAdapter { async * stream(options: GenerateOptions): AsyncIterable { this.requests.push(options) if (this.serializeSummary && options.purpose === 'compaction') { - await serializeRequestWithImages(options, { - representation: { kind: 'base64' }, - requestImages: new Map([image('first').attachment].map(ref => [ref.attachmentId, { + inlineImages( + projectOffloadedImages(options.messages, ref => offloadedImageText(ref)), + new Map([image('first').attachment].map(ref => [ref.attachmentId, { variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), attachment: ref, data: new Uint8Array(ref.bytes), mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height, depth: 'uchar', space: 'srgb', hasAlpha: false, }])), - maxRequestImageBytes: 1, - }) + resolveAdapterOptions({ maxInlineRequestImageBytes: 1, inlineImageOffloadByteQuantum: 1 }), + ) } const entry = this.script.shift() if (entry === undefined) throw new Error('script exhausted') @@ -132,7 +133,7 @@ describe('summary image offload', () => { expect(decisions(agent.session)).toEqual([]) }) - it('recovers the real summary serializer with a tighter image budget and fresh pricing', async () => { + it('recovers real summary image preparation with a tighter budget and fresh pricing', async () => { const { compact, agent, adapter } = await summaryHarness([textResponse('answer'), textResponse('checkpoint')]) const span = await seedImages(agent, ['first', 'second']) adapter.serializeSummary = true diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts index 2c1f188bc2..198e39f35f 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -17,11 +17,18 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { renderPrompt, renderContextSnapshot } from '@deepseek-ai/dsh-system-prompt' import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import { serializeRequest } from '@deepseek-ai/dsh-llm-deepseek/src/protocols/chat-completions/serialize.ts' +import { resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' +import { serialize } from '@deepseek-ai/dsh-llm-deepseek/src/serialize.ts' +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import TeamService from '../../agent-team/src/index.ts' import * as toolTeam from '../src/index.ts' +function serializeRequest(request: GenerateOptions) { + const connection = resolveAdapterOptions({ models: [{ id: request.model, systemPromptUpdate: 'in-history' }] }) + return serialize(request, connection, request.messages, new Map(), () => undefined) +} + const SIGNAL = new AbortController().signal const TOOL_NAMES = [ 'spawn_teammate', @@ -263,18 +270,18 @@ describe('dsh-tool-team', () => { await waitNoAgent(ctx, childId) const childRequest = serializeRequest(adapter.requests[1]!) expect(childRequest.tools).toEqual(parentRequest.tools) - expect(childRequest.messages[0]).toEqual(parentRequest.messages[0]) - expect(JSON.stringify(childRequest.messages[0])).not.toContain('Your Team role') + expect(childRequest.system).toEqual(parentRequest.system) + expect(childRequest.system).not.toContain('Your Team role') if (mode === 'fork') { expect(childRequest.messages.slice(0, parentRequest.messages.length)).toEqual(parentRequest.messages) expect(adapter.requests[1]!.messages.slice(0, parentHistory.length)).toEqual(parentHistory) } else { expect(JSON.stringify(childRequest.messages)).not.toContain('Parent task') } - expect(childRequest.messages).toContainEqual({ - role: 'user', - content: '\nYou are teammate "reviewer".\n\n\nReview the work', - }) + expect(childRequest.messages.at(-1)?.content.slice(0, 2)).toEqual([ + { type: 'text', text: '\nYou are teammate "reviewer".\n\n\n' }, + { type: 'text', text: 'Review the work' }, + ]) await using persisted = await ctx.sessionPersistence.open(childId, 'read') const { events } = await persisted.read() const initial = events.findLast(event => event.type === 'user/message' && event.data.source.kind === 'user') @@ -302,7 +309,7 @@ describe('dsh-tool-team', () => { const childRequest = serializeRequest(adapter.requests[1]!) expect(childRequest.tools).toEqual(parentRequest.tools) expect(childRequest.messages.slice(0, parentRequest.messages.length)).toEqual(parentRequest.messages) - expect(childRequest.messages.at(-1)?.content).toBe('Continue independently') + expect(childRequest.messages.at(-1)?.content).toContainEqual({ type: 'text', text: 'Continue independently' }) expect(JSON.stringify(childRequest.messages)).not.toContain('system-reminder') expect(handle.agent.session.snapshotEvents().slice(0, seed.length)).toEqual(seed) expect(ctx.agentTeams.listMembers(handle.agent).map(member => member.name)).toEqual(['lead']) @@ -336,7 +343,7 @@ describe('dsh-tool-team', () => { expect(fork.tools).toEqual(original.tools) expect(fork.messages.slice(0, original.messages.length)).toEqual(original.messages) expect(forkRequest.messages.slice(0, inheritedHistory.length)).toEqual(inheritedHistory) - expect(fork.messages).toContainEqual({ role: 'user', content: 'Continue independently' }) + expect(fork.messages.at(-1)?.content).toContainEqual({ type: 'text', text: 'Continue independently' }) expect(JSON.stringify(fork.messages)).not.toContain('You are the Team Lead') expect(handle.agent.session.snapshotEvents().slice(0, seed.length)).toEqual(seed) } finally { @@ -372,7 +379,7 @@ describe('dsh-tool-team', () => { await waitNoAgent(ctx, childId) const reminder = '\nYou are teammate "reviewer".\n\n\n' const first = serializeRequest(adapter.requests[0]!).messages - expect(first).toContainEqual({ role: 'user', content: `${reminder}Review the work` }) + expect(first.at(-1)?.content.slice(0, 2)).toEqual([{ type: 'text', text: reminder }, { type: 'text', text: 'Review the work' }]) expect(adapter.requests[1]!.messages.filter(message => message.content.some(block => block.type === 'text' && block.text === reminder))).toHaveLength(1) expect(JSON.stringify(serializeRequest(adapter.requests[2]!).messages)).not.toContain('You are teammate') @@ -411,8 +418,10 @@ describe('dsh-tool-team', () => { }) expect(spawned.isError, text(spawned)).toBe(false) await waitNoAgent(ctx, spawnedChildId(ctx, lead, spawned)) - expect(serializeRequest(adapter.requests[0]!).messages.at(-1)?.content) - .toBe('\nYou are teammate "reviewer".\n\n\nReview the work') + expect(serializeRequest(adapter.requests[0]!).messages.at(-1)?.content).toEqual([ + { type: 'text', text: '\nYou are teammate "reviewer".\n\n\n' }, + { type: 'text', text: 'Review the work' }, + ]) }) it('returns actionable no-progress output and renders structured wait cancellation', async () => { diff --git a/packages/llm/README.i18n.yaml b/packages/llm/README.i18n.yaml index f98e218e74..8b480a856c 100644 --- a/packages/llm/README.i18n.yaml +++ b/packages/llm/README.i18n.yaml @@ -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/llm/README.md -README.md: 9f7e8f444345bb60677a57679ad559f1e06be83f -README.zh.md: fbb27ed1567fa3319a7fc1bdf61b6436113e0aba +README.md: cbd9e386f0f219eea4f41470bd5da076db00ebe0 +README.zh.md: a3a31dfddd38a07ac95aae037f157ac12763974e diff --git a/packages/llm/README.md b/packages/llm/README.md index 9f7e8f4443..cbd9e386f0 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -25,7 +25,7 @@ The llm group provides the harness's model-call capability: one provider-neutral | Package | Role | ctx key | |---|---|---| | [`llm/`](llm/README.md) | Streams one model call through a registered provider adapter and shares the harness message, block, and chunk vocabulary | `ctx.llm` | -| [`llm-deepseek/`](llm-deepseek/README.md) | Serves the `deepseek-official` route with direct DeepSeek chat-completions, thinking, and image input | registers on `ctx.llm` | +| [`llm-deepseek/`](llm-deepseek/README.md) | Serves the `deepseek-official` route through DeepSeek Messages with thinking and image input | registers on `ctx.llm` | | [`llm-pi-ai/`](llm-pi-ai/README.md) | Serves configured provider routes through pi-ai catalogs and wire protocols, including hand-declared gateways | registers on `ctx.llm` | | [`deepseek-llm-api-extensions/`](deepseek-llm-api-extensions/README.md) | Registers lifecycle-owned top-level fields on official DeepSeek requests | `ctx.deepseekLlmApiExtensions` | | [`plugin-package-inventory-deepseek/`](plugin-package-inventory-deepseek/README.md) | Contributes the active Loader package inventory to official DeepSeek requests | contributes `dsh_plugin_packages` | diff --git a/packages/llm/README.zh.md b/packages/llm/README.zh.md index fbb27ed156..a3a31dfddd 100644 --- a/packages/llm/README.zh.md +++ b/packages/llm/README.zh.md @@ -25,7 +25,7 @@ llm 组提供 harness 的模型调用能力:一个提供方无关的服务, | 包 | 职责 | ctx key | |---|---|---| | [`llm/`](llm/README.zh.md) | 通过已注册的提供方适配器流式发起一次模型调用,并共享 harness 的消息、块与分片词汇 | `ctx.llm` | -| [`llm-deepseek/`](llm-deepseek/README.zh.md) | 以 DeepSeek chat-completions 直连、thinking 与图片输入服务 `deepseek-official` 路由 | 注册到 `ctx.llm` | +| [`llm-deepseek/`](llm-deepseek/README.zh.md) | 通过 DeepSeek Messages 为 `deepseek-official` 路由提供思考与图片输入 | 注册到 `ctx.llm` | | [`llm-pi-ai/`](llm-pi-ai/README.zh.md) | 通过 pi-ai 目录与协议格式服务配置的提供方路由,包括手工声明的网关 | 注册到 `ctx.llm` | | [`deepseek-llm-api-extensions/`](deepseek-llm-api-extensions/README.zh.md) | 在官方 DeepSeek 请求上注册具有生命周期归属的顶层字段 | `ctx.deepseekLlmApiExtensions` | | [`plugin-package-inventory-deepseek/`](plugin-package-inventory-deepseek/README.zh.md) | 为官方 DeepSeek 请求贡献当前启用的 Loader 包清单 | 贡献 `dsh_plugin_packages` | diff --git a/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml b/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml index caa80e03ad..44a416eea5 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml +++ b/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml @@ -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/llm/deepseek-llm-api-extensions/README.md -README.md: 45a4182d77eb293b55d39d043bf8a1e1075873cf -README.zh.md: 0131791ca5837d3143c17640bf10cb1f4d215555 +README.md: 1505c644a32a8c90c38720ac6229eb3b654693a1 +README.zh.md: d27eca175c19bb914045958d1d748f427a4971b7 diff --git a/packages/llm/deepseek-llm-api-extensions/README.md b/packages/llm/deepseek-llm-api-extensions/README.md index 45a4182d77..1505c644a3 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.md +++ b/packages/llm/deepseek-llm-api-extensions/README.md @@ -27,7 +27,7 @@ Provider-specific registry for additive top-level fields on official DeepSeek LL - `prepare(request)` snapshots the registered providers, prepares them concurrently, clones and freezes returned JSON values, and returns `{ fields, accept }`. A preparation failure rejects before HTTP dispatch; request cancellation stops awaiting providers even when one ignores its signal. - `accept()` runs every captured post-2xx callback once. Concurrent calls join the same settlement, every callback settles before failures are reported, and several failures become one `AggregateError`. -Both Chat Completions and Messages prepare these fields. Each provider sees the exact serialized base body in the selected protocol, the request `AbortSignal`, plus optional `sessionId` and auxiliary-call `purpose`. It must stop its own work promptly after cancellation and returns `undefined` when its field does not apply to that request. A prepared operation retains the providers it captured even if HMR removes their registrations before HTTP acceptance. +Each provider sees the exact serialized Messages body, the request `AbortSignal`, plus optional `sessionId` and auxiliary-call `purpose`. It must stop its own work promptly after cancellation and returns `undefined` when its field does not apply to that request. A prepared operation retains the providers it captured even if HMR removes their registrations before HTTP acceptance. The registry owns addition and lifecycle, not field semantics. `@deepseek-ai/dsh-session-log-deepseek` owns `dsh_session_log`; `@deepseek-ai/dsh-plugin-package-inventory-deepseek` owns `dsh_plugin_packages`. The provider-neutral LLM seam and `llm-pi-ai` do not consume this registry. diff --git a/packages/llm/deepseek-llm-api-extensions/README.zh.md b/packages/llm/deepseek-llm-api-extensions/README.zh.md index 0131791ca5..d27eca175c 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.zh.md +++ b/packages/llm/deepseek-llm-api-extensions/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" - `prepare(request)` 对已注册提供方取快照,并发准备贡献,克隆并冻结返回的 JSON 值,然后返回 `{ fields, accept }`。准备失败会在 HTTP 分发前拒绝请求;请求取消后,即使某个提供方忽略信号,注册表也会停止等待。 - `accept()` 对每个捕获的 2xx 后回调只运行一次。并发调用会等待同一次结算,所有回调都在报告失败前完成,多个失败会合并为一个 `AggregateError`。 -Chat Completions 和 Messages 都会准备这些字段。每个提供方都会看到所选协议中确切的已序列化基础请求体、请求 `AbortSignal`,以及可选的 `sessionId` 与辅助调用 `purpose`。提供方必须在取消后迅速停止自身工作;字段不适用于当前请求时返回 `undefined`。即使 HMR(热模块替换)在 HTTP 接受前移除了注册,已准备的操作仍会保留其捕获的提供方。 +每个提供方都会看到确切的已序列化 Messages 请求体、请求 `AbortSignal`,以及可选的 `sessionId` 与辅助调用 `purpose`。提供方必须在取消后迅速停止自身工作;字段不适用于当前请求时返回 `undefined`。即使 HMR(热模块替换)在 HTTP 接受前移除了注册,已准备的操作仍会保留其捕获的提供方。 注册表拥有字段添加与生命周期,不拥有字段语义。`@deepseek-ai/dsh-session-log-deepseek` 拥有 `dsh_session_log`;`@deepseek-ai/dsh-plugin-package-inventory-deepseek` 拥有 `dsh_plugin_packages`。提供方无关的 LLM seam 与 `llm-pi-ai` 都不消费该注册表。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index bcc2683e92..b77dcd559e 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -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/llm/llm-deepseek/README.md -README.md: 5bad4d36296c350ef2bc88ad2ff58fec33b93805 -README.zh.md: 1c1c5a8255c3cd894e0aeecfe49079a78fd03eeb +README.md: 696db921f266ea0a5187aa9b98cf316343b6c74c +README.zh.md: 8e152a93de50dea7fc62cd3638957e6b1a879d23 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5bad4d3629..696db921f2 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -1,5 +1,5 @@ --- -description: "Configure DeepSeek Messages, Chat Completions overrides, reasoning, and image input through one first-party provider." +description: "Configure DeepSeek Messages, reasoning, and image input." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Stream DeepSeek models through `deepseek-official` with Messages by default, or select Chat Completions in Cordis YAML. Both protocols share credentials, endpoint settings, image handling, and the model catalog. Valid settings changes affect subsequent calls while in-flight calls retain their configuration. Web shows one DeepSeek provider with an editable API base and key. This package can run beside the [pi-ai adapter](../llm-pi-ai/README.md). +Stream DeepSeek models through `deepseek-official` using the Messages API. Configure the endpoint, credentials, reasoning, and image handling from Cordis YAML or Web settings. Valid settings changes affect subsequent calls while in-flight calls retain their configuration. This package can run beside the [pi-ai adapter](../llm-pi-ai/README.md). ## Table of Contents @@ -27,11 +27,11 @@ Stream DeepSeek models through `deepseek-official` with Messages by default, or Mount this plugin when a composition streams DeepSeek models through the harness LLM service. It registers the single `deepseek-official` route and resolves connection facts per request, so a composition entry plus an optional user settings section drive the whole adapter. -Both protocols accept the LLM service's [request-only user inputs](../llm/README.md#use-this-package) alongside durable history; omitting request-only identity and attribution does not alter provider content. +The adapter accepts the LLM service's [request-only user inputs](../llm/README.md#use-this-package) alongside durable history; omitting request-only identity and attribution does not alter provider content. ### When to choose it -Choose this adapter for DeepSeek's official API or a gateway that supports the selected protocol through `baseURL`. Choose `dsh-llm-pi-ai` when the same composition also routes other providers or hand-declared gateways through pi-ai's catalogs; the two adapters can be mounted together because their route names do not collide. Registering any other adapter for `deepseek-official` fails with `DUPLICATE_ADAPTER`. +Choose this adapter for DeepSeek's official API or a Messages-compatible gateway through `baseURL`. Choose `dsh-llm-pi-ai` when the same composition also routes other providers or hand-declared gateways through pi-ai's catalogs; the two adapters can be mounted together because their route names do not collide. Registering any other adapter for `deepseek-official` fails with `DUPLICATE_ADAPTER`. ### Minimal configuration @@ -51,9 +51,8 @@ A request selects the route with `provider: deepseek-official`; the model id pas | Field | Default | Meaning | |---|---|---| -| `protocol` | `messages` | Choose `messages` or `chat-completions` in Cordis YAML; Web has no protocol selector | | `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved per request through the credentials seam, then the environment | -| `baseURL` | Selected protocol’s official root | Explicit value, then `$DEEPSEEK_BASE_URL`, then the selected protocol default | +| `baseURL` | `https://api.deepseek.com/anthropic` | Explicit value, then `$DEEPSEEK_BASE_URL`, then the official root | | `thinking` | `enabled` | Deployment policy; `disabled` locks every request to `off` | | `reasoningEffort` | `high` | Default effort: `off`, `low`, `high`, or `max` | | `maxTokens` | `256,000` | Per-request output cap; a model's own cap and explicit request values win | @@ -76,32 +75,24 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a When [proactive compaction](../../compaction/compaction-basic/README.md#use-this-package) is enabled, `models[].contextWindow` (or `defaultContextWindow` when absent) must exceed the effective request `maxTokens` plus the compaction policy’s `headroomTokens`. Requests without an explicit output cap use the model’s `maxTokens` or the adapter default. For small-window deployments, configure headroom within that capacity; lower `thresholdRatio` to compact earlier. - -### Choose a protocol + +### Endpoint and wire format -To select Chat Completions explicitly, patch the existing plugin: +The official root is `https://api.deepseek.com/anthropic`. An explicit `baseURL` or `$DEEPSEEK_BASE_URL` supplies a Messages-compatible root. Model and Files requests append `/v1/messages` and `/v1/files`, except that an exact final `/v1` segment is reused. Trailing slashes do not change these results. A base URL must use HTTP(S) without credentials, query, or fragment. -```yaml -- id: llm-deepseek - config: - protocol: chat-completions -``` - -`protocol` defaults to `messages`, with official root `https://api.deepseek.com/anthropic`; `chat-completions` uses `https://api.deepseek.com`. Shipped first-party compositions inherit this default. Neither protocol requires `baseURL`: its official default applies when both `baseURL` and `$DEEPSEEK_BASE_URL` are absent. Switching protocols retains endpoint overrides, so users must supply an address compatible with the selected protocol. An explicit `https://api.deepseek.com` override selects the Chat root: remove that override to use the official Messages default, or set it to `https://api.deepseek.com/anthropic`. Chat appends `/chat/completions`. Messages and its Files API treat only an exact final `/v1` path segment as the existing Anthropic API version and append `/messages` or `/files`; every other base receives `/v1/messages` or `/v1/files`. The official Messages root therefore retains its recommended `/anthropic/v1` request paths without granting compatibility to arbitrary version-like suffixes. Trailing slashes do not change these results. Both protocols share the `llm-deepseek` settings section, `apiKeyEnv`, and `deepseek-official`, so saved model selections remain valid. - -Messages sends text, thinking, tool calls, and tool results as content blocks, reasoning effort as `output_config.effort`, and images as Files references or inline base64. Models declaring `systemPromptUpdate: in-history` retain the initial top-level system and send new system snapshots after their corresponding user/tool-result turn; undeclared models use the latest snapshot as the top-level system. Replay metadata identifies the Messages format, model, and signatures. Chat requests serialize durable content without those signatures. Invalid Messages replay metadata emits a warning and omits signatures while retaining text and tool history. +Messages sends text, thinking, tool calls, and tool results as content blocks, reasoning effort as `output_config.effort`, and images as Files references or inline base64. Models declaring `systemPromptUpdate: in-history` retain the initial top-level system and send new system snapshots after their corresponding user/tool-result turn; undeclared models use the latest snapshot as the top-level system. Replay metadata preserves the model and thinking signatures. Invalid replay metadata emits a warning and omits signatures while retaining text and tool history. ### Streaming with thinking and images An image-capable route chooses each durable reference's request target and resolves it into a deterministic request version. Omitting `imagePixelBudget` sizes the target on the published vision token grid of 14px patches, 3:1 downsampling, and at most 1024 tokens per image, so a square image keeps up to 1302×1302 pixels and a 16:9 image is sent as 1708×961 for the provider's 1708×966 grid; a positive integer replaces the grid with a total-pixel budget, and `low` uses 512×512 total pixels. Every request image is capped at 4096 pixels per side, the provider limit for requests carrying 15 or more images, and `imageMaxBytes` defaults to 2 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references. -Both protocols normally upload those exact request bytes through their DeepSeek Files endpoint and send file-id references. Messages uses `/v1/files` under its configured base and includes `anthropic-beta: files-api-2025-04-14` on Files requests and Messages requests containing file ids; Chat uses `/files`. Messages model requests and all Files requests reject redirects so credentials remain on the configured origin. A failed or timed-out file resolution rebuilds the whole model request with inline base64 under the inline budget; one request never mixes file ids and inline images. Caller cancellation stops the request. +The adapter normally uploads those exact request bytes through `/v1/files` and sends file-id references. Files requests and model requests containing file ids include `anthropic-beta: files-api-2025-04-14`. All requests reject redirects so credentials remain on the configured origin. A failed or timed-out file resolution rebuilds the whole model request with inline base64 under the inline budget; one request never mixes file ids and inline images. Caller cancellation stops the request. -Cached ids are scoped by endpoint and API key, refreshed before expiry, invalidated from provider stale-file errors, and resolved through singleflight with waiter-local cancellation. Both uploads request expiry through `expires_after[anchor]=created_at` and `expires_after[seconds]`. Messages file metadata omits remote expiry, so its local reuse deadline uses the original upload time plus `fileExpiresAfterSeconds`; this does not guarantee remote deletion. Quota failure deletes one configured batch of the oldest harness-owned files before one upload retry. +Cached ids are scoped by endpoint and API key, refreshed before expiry, invalidated from provider stale-file errors, and resolved through singleflight with waiter-local cancellation. Uploads request expiry through `expires_after[anchor]=created_at` and `expires_after[seconds]`. Messages file metadata omits remote expiry, so its local reuse deadline uses the original upload time plus `fileExpiresAfterSeconds`; this does not guarantee remote deletion. Quota failure deletes one configured batch of the oldest harness-owned files before one upload retry. Files mode bounds retained request versions by `maxRequestFilesBytes` and `maxImagesPerRequest`; inline fallback has its own base64 budget. Both remove an oldest prefix in configured byte or count quanta. Each omitted image gets its own model-visible placeholder with its display name or attachment id and, when available, normalized dimensions, media type, and current read-only path. The stepped high-watermark policy avoids rewriting an old request prefix after every new image. -`reasoningEffort` selects the advertised default. Exact-model metadata exposes ordered `off`, `low`, `high`, and `max` efforts with selection guidance when deployment policy permits thinking. `low`, `high`, and `max` enable thinking and serialize as `reasoning_effort` for Chat Completions or `output_config.effort` for Messages, while adapter-owned `off` sends `thinking.type: disabled` instead. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O, and `thinking: disabled` rejects any non-`off` effort at plugin load. Requests with `purpose: 'session-title'` force thinking off to reserve output for visible title text. Both protocols forward an explicit `temperature`; DeepSeek accepts it with thinking enabled but ignores its value in that mode. +`reasoningEffort` selects the advertised default. Exact-model metadata exposes ordered `off`, `low`, `high`, and `max` efforts with selection guidance when deployment policy permits thinking. `low`, `high`, and `max` enable thinking and serialize as `output_config.effort`, while adapter-owned `off` sends `thinking.type: disabled` instead. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O, and `thinking: disabled` rejects any non-`off` effort at plugin load. Requests with `purpose: 'session-title'` force thinking off to reserve output for visible title text. The adapter forwards an explicit `temperature`; DeepSeek accepts it with thinking enabled but ignores its value in that mode. ### Dynamic configuration @@ -109,7 +100,7 @@ Connection facts are re-read once per operation through the optional settings an ### Provider-specific request fields -For either protocol, when `ctx.deepseekLlmApiExtensions` is present, the adapter prepares its registered top-level fields from the exact serialized base request before `fetch`. Preparation or field collisions fail before HTTP; after a 2xx response, the adapter accepts every captured contribution before consuming SSE. Transport and non-2xx failures do not accept them. Shipped compositions use this for the default-on incremental `dsh_session_log` field and the default-on active `dsh_plugin_packages` inventory; both stay outside model input. +When `ctx.deepseekLlmApiExtensions` is present, the adapter prepares its registered top-level fields from the exact serialized base request before `fetch`. Preparation or field collisions fail before HTTP; after a 2xx response, the adapter accepts every captured contribution before consuming SSE. Transport and non-2xx failures do not accept them. Shipped compositions use this for the default-on incremental `dsh_session_log` field and the default-on active `dsh_plugin_packages` inventory; both stay outside model input. ### Failures and recovery @@ -133,17 +124,7 @@ The plugin is built on one explicit resolve step and one registration fact. `res ### Source map -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Settings, credentials, and provider registration | -| [`src/config.ts`](src/config.ts) | Schema and request-local configuration resolution | -| [`src/adapter.ts`](src/adapter.ts) | Protocol dispatch with frozen prepared-call configuration | -| [`src/common/models.ts`](src/common/models.ts) | Shared model catalog | -| [`src/common/model-info.ts`](src/common/model-info.ts) | Shared model capabilities and reasoning choices | -| [`src/common/file-store.ts`](src/common/file-store.ts) | Shared Files cache, refresh, quota cleanup, and cancellation | -| [`src/common/files-api.ts`](src/common/files-api.ts) | Protocol-specific Files endpoints and response mapping | -| [`src/protocols/chat-completions/adapter.ts`](src/protocols/chat-completions/adapter.ts) | Chat transport, image projection, and request extensions | -| [`src/protocols/messages/adapter.ts`](src/protocols/messages/adapter.ts) | Messages transport, image projection, request extensions, and native replay | +[`src/index.ts`](src/index.ts) registers the provider and resolves settings and credentials. [`src/adapter.ts`](src/adapter.ts) owns the request lifecycle; [`src/serialize.ts`](src/serialize.ts) and [`src/translate.ts`](src/translate.ts) map model input and streamed output. [`src/file-store.ts`](src/file-store.ts) owns upload reuse and recovery through [`src/files-api.ts`](src/files-api.ts). ### Wire flow @@ -177,11 +158,11 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. This silent fallback also applies after switching from Chat Completions. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results; omission of these two block types matches Chat Completions and the pi-ai adapter. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. #### Token effect -Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence selected by a logged image-offload decision as its placeholder text and each retained image at its projected dimensions with the published vision accounting (14px patch grid, 3:1 downsampling, 544×544 scale-up floor, 1024-token cap). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries. Cache-read usage is reported when available. Messages totals include uncached input, output, cache-read, and cache-write tokens. Chat Completions uses `prompt_tokens + completion_tokens` and omits `totalTokens` if a supplied `total_tokens` disagrees. +Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence selected by a logged image-offload decision as its placeholder text and each retained image at its projected dimensions with the published vision accounting (14px patch grid, 3:1 downsampling, 544×544 scale-up floor, 1024-token cap). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries. Cache-read usage is reported when available. Messages totals include uncached input, output, cache-read, and cache-write tokens. #### KV Cache effect @@ -203,7 +184,6 @@ Loop-retained response blocks append to the next request and preserve its earlie ## Known Limitations and Deferred Work -- Responses is not implemented; configuration rejects `responses`. @@ -213,23 +193,14 @@ These limits define where the adapter stops and future work begins. They are cur - **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. - **`tool_choice` is not mapped** — not part of the core vocabulary (shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy or interception configuration. -- **Chat Completions skips plugin-added content block types** — core text and supported image blocks are serialized, and empty tool output crosses the wire as the literal `(no output)`. - **Messages in-history system updates require a retained user or tool-result turn** — if all user input after an update is omitted and the preceding wire turn is assistant, serialization fails with `UNSUPPORTED_CONTENT` before the next assistant or at the end of the request. Text or an empty tool result can retain that turn. Moving the update to an earlier turn is not supported; the [input-history decision](../../../.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md) records the ordering constraint. - **Images are input-only durable attachments** — direct external URLs and assistant image output are not supported; DeepSeek input normally uses the Files API and uses inline base64 only for per-request recovery. -- The default catalog pre-registers `deepseek-flash` and its text/image and in-history capabilities without probing gateway availability. Requests can fail with `INVALID_REQUEST` until the gateway enables the id. With `DEEPSEEK_API_KEY` and a supporting gateway configured, `DEEPSEEK_FLASH_E2E=1` enables the Chat Completions check in [this package's e2e suite](tests/adapter.e2e.ts). -- The [Messages system-update e2e checks](tests/messages/adapter.e2e.ts) require `DEEPSEEK_IN_HISTORY_MODEL` to name a supported model, such as `deepseek-flash`, and run with `high` effort. They skip when that variable is unset or empty; ordinary `off` text checks remain enabled with credentials. Known instruction-following instability with thinking disabled makes these system-update checks unsuitable for `off`. +- The default catalog advertises `deepseek-flash` and its text/image and in-history capabilities without probing gateway availability. Requests can fail with `INVALID_REQUEST` until the gateway enables the id. +- The [Messages system-update e2e checks](tests/adapter.e2e.ts) require `DEEPSEEK_IN_HISTORY_MODEL` to name a supported model, such as `deepseek-flash`, and run with `high` effort. They skip when that variable is unset or empty; ordinary `off` text checks remain enabled with credentials. Known instruction-following instability with thinking disabled makes these system-update checks unsuitable for `off`. ### Dev Note -
      -Working context for maintainers — click to expand - -This Dev Note is non-authoritative working context: undecided directions and notes for maintainers. Shipped behavior and accepted rationale live in the sections above, the package code, and the linked Agent Notes. - -- OpenRouter-specific app attribution headers are deferred to a future explicit OpenRouter adapter or mode; OpenAI-compatible gateway requests carry only the shared attribution baseline. -- The `off` reasoning effort never crosses the wire as `reasoning_effort: 'off'`; it serializes as `thinking: { type: 'disabled' }` and omits the field, which keeps the wire spelling valid for gateways that reject unknown effort values. - -
      +None. **Runtime invariant:** No companion is published. This package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 1c1c5a8255..8e152a93de 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -1,5 +1,5 @@ --- -description: "通过同一官方提供方配置 DeepSeek Messages、Chat Completions 覆盖、推理与图片输入。" +description: "配置 DeepSeek Messages、推理与图片输入。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -通过 `deepseek-official` 流式调用 DeepSeek 模型,默认使用 Messages,也可在 Cordis YAML 中选择 Chat Completions。两种协议共用凭据、端点配置、图片处理和模型目录。有效的设置更改在后续请求生效,进行中的请求保留原配置。Web 显示一个 DeepSeek 提供方,并提供 API 地址和密钥编辑。本包可与 [pi-ai 适配器](../llm-pi-ai/README.zh.md)并用。 +通过 `deepseek-official` 使用 Messages API 流式调用 DeepSeek 模型。在 Cordis YAML 或 Web 设置中配置端点、凭据、推理与图片处理。有效的设置更改在后续请求生效,进行中的请求保留原配置。本包可与 [pi-ai 适配器](../llm-pi-ai/README.zh.md)并用。 ## 目录 @@ -27,11 +27,11 @@ kind: "package-reference" 当组合需要通过 harness LLM(大语言模型)服务流式调用 DeepSeek 模型时挂载本插件。它注册唯一的 `deepseek-official` 路由,并按请求解析连接事实,因此组合条目加可选用户设置分节即可驱动整个适配器。 -两种协议都接受 LLM 服务的[仅供请求使用的 user 输入](../llm/README.zh.md#use-this-package),并可将其与持久历史混用;省略请求输入的身份与来源不会改变提供方内容。 +适配器接受 LLM 服务的[仅供请求使用的 user 输入](../llm/README.zh.md#use-this-package),并可将其与持久历史混用;省略请求输入的身份与来源不会改变提供方内容。 ### 何时选择 -面向 DeepSeek 官方 API,或通过 `baseURL` 连接支持所选协议的网关时,选择本适配器。当同一组合还要通过 pi-ai 目录路由其他提供方或手工声明的网关时,选择 `dsh-llm-pi-ai`;两个适配器可以同时挂载,因为它们的路由名不冲突。为 `deepseek-official` 注册任何其他适配器会以 `DUPLICATE_ADAPTER` 失败。 +面向 DeepSeek 官方 API,或通过 `baseURL` 连接兼容 Messages 的网关时,选择本适配器。当同一组合还要通过 pi-ai 目录路由其他提供方或手工声明的网关时,选择 `dsh-llm-pi-ai`;两个适配器可以同时挂载,因为它们的路由名不冲突。为 `deepseek-official` 注册任何其他适配器会以 `DUPLICATE_ADAPTER` 失败。 ### 最小配置 @@ -51,9 +51,8 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| -| `protocol` | `messages` | Cordis YAML 中选择 `messages` 或 `chat-completions`;Web 不提供选择器 | | `apiKeyEnv` | `DEEPSEEK_API_KEY` | 按请求解析的凭据引用:先经凭据 seam,再到环境变量 | -| `baseURL` | 按协议选择官方根地址 | 显式值优先,其次 `$DEEPSEEK_BASE_URL`,最后采用当前协议的官方端点 | +| `baseURL` | `https://api.deepseek.com/anthropic` | 显式值优先,其次为 `$DEEPSEEK_BASE_URL`,最后为官方根地址 | | `thinking` | `enabled` | 部署策略;`disabled` 把所有请求锁定为 `off` | | `reasoningEffort` | `high` | 默认强度:`off`、`low`、`high` 或 `max` | | `maxTokens` | `256,000` | 单次请求输出上限;模型自身上限与显式请求值优先 | @@ -76,32 +75,24 @@ kind: "package-reference" 启用[主动压缩](../../compaction/compaction-basic/README.zh.md#use-this-package)时,`models[].contextWindow`(未声明时使用 `defaultContextWindow`)必须大于生效请求的 `maxTokens` 与压缩策略 `headroomTokens` 之和。请求未覆盖输出上限时,使用模型的 `maxTokens` 或适配器默认值。小窗口部署应在容量范围内配置余量;降低 `thresholdRatio` 可以提早压缩。 - -### 选择协议 + +### 端点与协议格式 -通过补丁为已有插件显式选择 Chat Completions: +官方根地址为 `https://api.deepseek.com/anthropic`。显式 `baseURL` 或 `$DEEPSEEK_BASE_URL` 提供兼容 Messages 的根地址。模型与 Files 请求分别追加 `/v1/messages` 和 `/v1/files`,但末尾严格匹配的 `/v1` 路径段会直接复用。末尾斜线不改变这些结果。基址必须使用 HTTP(S),且不含凭据、查询或片段。 -```yaml -- id: llm-deepseek - config: - protocol: chat-completions -``` - -`protocol` 默认为 `messages`,官方根地址为 `https://api.deepseek.com/anthropic`;`chat-completions` 使用 `https://api.deepseek.com`。随产品交付的官方组合继承该默认值。两种协议都不要求填写 `baseURL`:当 `baseURL` 与 `$DEEPSEEK_BASE_URL` 均未设置时使用当前协议的官方默认值。切换协议保留已有端点覆盖,用户需要填写与选定协议兼容的地址。显式填写的 `https://api.deepseek.com` 是 Chat 根地址:删除该覆盖即可使用官方 Messages 默认值,也可以改填 `https://api.deepseek.com/anthropic`。Chat 追加 `/chat/completions`。Messages 与其 Files API 仅把末尾严格匹配的 `/v1` 路径段视为已有 Anthropic API 版本,并追加 `/messages` 或 `/files`;其他基址均追加 `/v1/messages` 或 `/v1/files`。因此,官方 Messages 根地址仍使用推荐的 `/anthropic/v1` 请求路径,同时不为任意版本式后缀提供兼容性。末尾斜线不改变这些结果。两种协议共用 `llm-deepseek` 设置、`apiKeyEnv` 与 `deepseek-official`,因此已保存的模型选择仍然有效。 - -Messages 以内容块发送文本、思考、工具调用和工具结果,以 `output_config.effort` 发送推理强度,并以 Files 引用或内联 base64 发送图片。声明 `systemPromptUpdate: in-history` 的模型保留初始顶层 system,在对应 user/tool-result 轮次之后发送新的 system 快照;未声明能力时,使用最新快照作为顶层 system。回放元数据记录 Messages 格式、模型和签名;Chat 请求只序列化持久化内容,不发送这些签名。无效的 Messages 回放元数据产生警告并省略签名,不丢弃文本或工具历史。 +Messages 以内容块发送文本、思考、工具调用和工具结果,以 `output_config.effort` 发送推理强度,并以 Files 引用或内联 base64 发送图片。声明 `systemPromptUpdate: in-history` 的模型保留初始顶层 system,在对应 user/tool-result 轮次之后发送新的 system 快照;未声明能力时,使用最新快照作为顶层 system。回放元数据保留模型与思考签名。无效的回放元数据产生警告并省略签名,不丢弃文本或工具历史。 ### 带 thinking 与图片的流式调用 支持图片的路由为每个持久引用选定请求目标,再把它解析为确定性请求版本。省略 `imagePixelBudget` 时按官方公布的视觉 token 网格定目标,即 14 px patch、3:1 降采样、单图最多 1024 token,因此正方形图片最多保留 1302×1302 像素,16:9 图片以 1708×961 发送、对应提供方 1708×966 的网格;正整数会用总像素预算取代网格,`low` 使用总计 512×512 像素。每张请求图片单边最多 4096 像素,这是提供方对包含 15 张及以上图片的请求的限制;`imageMaxBytes` 默认为 2 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。 -两种协议通常通过各自的 DeepSeek Files 端点上传这些确切请求字节,并发送 file-id 引用。Messages 在配置的基址下使用 `/v1/files`,Files 请求与包含 file id 的 Messages 请求均携带 `anthropic-beta: files-api-2025-04-14`;Chat 使用 `/files`。Messages 模型请求与所有 Files 请求拒绝重定向,确保凭据仅发送到配置的源。文件解析失败或超时会按内联预算,用内联 base64 重建整份模型请求;一次请求绝不混用 file id 与内联图片。调用方取消会停止请求。 +适配器通常通过 `/v1/files` 上传这些确切请求字节,并发送 file-id 引用。Files 请求与包含 file id 的模型请求均携带 `anthropic-beta: files-api-2025-04-14`。全部请求拒绝重定向,确保凭据仅发送到配置的源。文件解析失败或超时会按内联预算,用内联 base64 重建整份模型请求;一次请求绝不混用 file id 与内联图片。调用方取消会停止请求。 -缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。两种上传都通过 `expires_after[anchor]=created_at` 与 `expires_after[seconds]` 请求过期。Messages 文件元数据不含远端过期时间,因此本地复用期限使用原始上传时间加 `fileExpiresAfterSeconds`;这不保证远端文件删除。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。 +缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。上传通过 `expires_after[anchor]=created_at` 与 `expires_after[seconds]` 请求过期。Messages 文件元数据不含远端过期时间,因此本地复用期限使用原始上传时间加 `fileExpiresAfterSeconds`;这不保证远端文件删除。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。 Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留请求版本;内联回退有独立 base64 预算。两种模式都按配置的字节或数量量子移除最旧前缀。每张省略图片都有自己的模型可见占位符,包含显示名或附件 id,以及可用时的规范化尺寸、媒体类型与当前只读路径。分阶高水位策略避免每新增一张图片都改写旧请求前缀。 -`reasoningEffort` 选择公布的默认值。当部署策略允许 thinking 时,确切模型元数据会按顺序公开 `off`、`low`、`high` 与 `max` 强度及选择指引。`low`、`high` 与 `max` 启用 thinking,在 Chat Completions 中以 `reasoning_effort` 序列化,在 Messages 中以 `output_config.effort` 序列化,适配器自有的 `off` 则发送 `thinking.type: disabled`。不支持的取值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败;`thinking: disabled` 会在插件加载时拒绝任何非 `off` 强度。`purpose: 'session-title'` 的请求会强制关闭 thinking,把有界输出留给可见标题文本。两种协议都转发显式 `temperature`;DeepSeek 在启用 thinking 时接受该参数,但忽略其值。 +`reasoningEffort` 选择公布的默认值。当部署策略允许 thinking 时,确切模型元数据会按顺序公开 `off`、`low`、`high` 与 `max` 强度及选择指引。`low`、`high` 与 `max` 启用 thinking,并以 `output_config.effort` 序列化,适配器自有的 `off` 则发送 `thinking.type: disabled`。不支持的取值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败;`thinking: disabled` 会在插件加载时拒绝任何非 `off` 强度。`purpose: 'session-title'` 的请求会强制关闭 thinking,把有界输出留给可见标题文本。适配器转发显式 `temperature`;DeepSeek 在启用 thinking 时接受该参数,但忽略其值。 ### 动态配置 @@ -109,7 +100,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 ### 提供方专用请求字段 -两种协议中,存在 `ctx.deepseekLlmApiExtensions` 时,适配器都会在 `fetch` 前根据确切序列化基础请求准备已注册顶层字段。准备或字段冲突在 HTTP 前失败;2xx 响应后,适配器会在消费 SSE(Server-Sent Events)前接受每项已捕获贡献。传输与非 2xx 失败不会接受它们。随产品交付的组合用它提供默认启用的增量 `dsh_session_log` 字段和默认启用的活跃 `dsh_plugin_packages` 清单;两者都留在模型输入之外。 +存在 `ctx.deepseekLlmApiExtensions` 时,适配器会在 `fetch` 前根据确切序列化基础请求准备已注册顶层字段。准备或字段冲突在 HTTP 前失败;2xx 响应后,适配器会在消费 SSE(Server-Sent Events)前接受每项已捕获贡献。传输与非 2xx 失败不会接受它们。随产品交付的组合用它提供默认启用的增量 `dsh_session_log` 字段和默认启用的活跃 `dsh_plugin_packages` 清单;两者都留在模型输入之外。 ### 失败与恢复 @@ -131,19 +122,9 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 插件建立在一个显式解析步骤与一条注册事实之上。`resolveAdapterOptions()` 是从原始配置到已校验连接事实的唯一路径,适配器通过 thunk 每次操作重新读取这些事实——基址、目录、请求默认值、图片与 Files 策略及空闲预算都会作用于下一个请求,而进行中的流保持其启动时的事实。注册时捕获的唯一事实是重试策略:解析值变化时,插件会在一个同步区段内原位重新注册路由,因此任何请求都观察不到空档。 -### 源码地图 +### 源码导航 -| 文件 | 职责 | -|---|---| -| [`src/index.ts`](src/index.ts) | settings、凭据与提供方注册 | -| [`src/config.ts`](src/config.ts) | schema 与请求配置解析 | -| [`src/adapter.ts`](src/adapter.ts) | 按协议分派,并冻结已准备请求的配置 | -| [`src/common/models.ts`](src/common/models.ts) | 共享模型目录 | -| [`src/common/model-info.ts`](src/common/model-info.ts) | 共享模型能力与推理选项 | -| [`src/common/file-store.ts`](src/common/file-store.ts) | 共享 Files 缓存、刷新、配额清理与取消 | -| [`src/common/files-api.ts`](src/common/files-api.ts) | 各协议的 Files 端点与响应映射 | -| [`src/protocols/chat-completions/adapter.ts`](src/protocols/chat-completions/adapter.ts) | Chat 传输、图片投影与请求扩展 | -| [`src/protocols/messages/adapter.ts`](src/protocols/messages/adapter.ts) | Messages 传输、图片投影、请求扩展与原生回放 | +[`src/index.ts`](src/index.ts) 注册提供方并解析设置与凭据。[`src/adapter.ts`](src/adapter.ts) 管理请求生命周期;[`src/serialize.ts`](src/serialize.ts) 和 [`src/translate.ts`](src/translate.ts) 映射模型输入与流式输出。[`src/file-store.ts`](src/file-store.ts) 通过 [`src/files-api.ts`](src/files-api.ts) 管理上传复用与恢复。 ### 协议流程 @@ -177,11 +158,11 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### 模型看到什么 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。从 Chat Completions 切换后也适用此静默兜底。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块;对这两类块的省略行为与 Chat Completions 和 pi-ai 适配器一致。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 #### Token 影响 -提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把日志中的图片省略决策选中的每个出现位置按其占位文本计价,并按投影后的尺寸使用公开的视觉计量规则(14 px patch 网格、3:1 降采样、544×544 放大下限、单图 1024 token 上限)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试。可用时报告缓存读取用量。Messages 的 token 总数包含未缓存输入、输出、缓存读取与缓存写入 token。Chat Completions 使用 `prompt_tokens + completion_tokens`,提供方给出的 `total_tokens` 不一致时省略 `totalTokens`。 +提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把日志中的图片省略决策选中的每个出现位置按其占位文本计价,并按投影后的尺寸使用公开的视觉计量规则(14 px patch 网格、3:1 降采样、544×544 放大下限、单图 1024 token 上限)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试。可用时报告缓存读取用量。Messages 的 token 总数包含未缓存输入、输出、缓存读取与缓存写入 token。 #### KV Cache 影响 @@ -203,7 +184,6 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 ## 已知限制与延期工作 -- Responses 协议尚未实现,配置值 `responses` 会被拒绝。 @@ -213,23 +193,14 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 - **设置中的 `models` 列表会整体替换组合列表**——设置层按字段合并,数组只算一个字段;按条目合并目录需要带键的形状。 - **不映射 `tool_choice`**——不属于核心词汇(与 pi-ai 孪生共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**——没有共享代理或拦截配置。 -- **Chat Completions 跳过插件新增的内容块类型**——核心文本与受支持图片块会被序列化,空工具输出以字面量 `(no output)` 过线。 - **Messages 历史内 system 更新需要保留用户或工具结果轮次**——若更新后的全部用户输入都被省略,且前一个协议轮次是 assistant,序列化会在下一个 assistant 之前或请求结束处以 `UNSUPPORTED_CONTENT` 失败。文本或空工具结果可以保留该轮次。不支持将更新移到更早的轮次;[输入历史决策](../../../.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md)记录了排序约束。 - **图片是仅用于输入的持久附件**——不支持直接外部 URL 与 assistant 图片输出;DeepSeek 输入通常使用 Files API,仅在单次请求恢复时使用内联 base64。 -- 默认目录预注册 `deepseek-flash` 及其文本、图片和历史内更新能力,不探测网关可用性。网关开放该 ID 前,请求可能以 `INVALID_REQUEST` 失败。配置 `DEEPSEEK_API_KEY` 和支持该 ID 的网关后,设置 `DEEPSEEK_FLASH_E2E=1` 可启用[本包 e2e 测试文件](tests/adapter.e2e.ts)中的 Chat Completions 协议验证。 -- [Messages system 更新 e2e](tests/messages/adapter.e2e.ts) 要求通过 `DEEPSEEK_IN_HISTORY_MODEL` 指定支持该能力的模型,例如 `deepseek-flash`,并使用 `high` 思考强度。该变量未设置或为空时跳过;普通 `off` 文本检查仍在有凭据时运行。关闭思考时已知的指令遵循不稳定,使这些 system 更新检查不适合使用 `off`。 +- 默认目录公布 `deepseek-flash` 及其文本、图片和历史内更新能力,不探测网关可用性。网关开放该 ID 前,请求可能以 `INVALID_REQUEST` 失败。 +- [Messages system 更新 e2e](tests/adapter.e2e.ts) 要求通过 `DEEPSEEK_IN_HISTORY_MODEL` 指定支持该能力的模型,例如 `deepseek-flash`,并使用 `high` 思考强度。该变量未设置或为空时跳过;普通 `off` 文本检查仍在有凭据时运行。关闭思考时已知的指令遵循不稳定,使这些 system 更新检查不适合使用 `off`。 ### 开发备注 -
      -维护者的工作上下文——点击展开 - -本开发备注是不具权威性的工作上下文:尚未决定的探索方向与维护者备注。已交付的行为与既定理由以上文、包代码和相关 Agent Note 为准。 - -- OpenRouter 专属应用归因标头延期到未来显式 OpenRouter 适配器或模式;OpenAI 兼容网关请求只携带共享归因基线。 -- `off` 推理强度绝不会以 `reasoning_effort: 'off'` 过线;它序列化为 `thinking: { type: 'disabled' }` 并省略该字段,从而对拒绝未知强度取值的网关保持协议拼写有效。 - -
      +无。 **运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index e3f69ca464..92a8643857 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", - "description": "DeepSeek adapter with Chat Completions and Messages protocols", + "description": "DeepSeek Messages adapter", "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 8e46799185..4b0f3704b9 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -1,58 +1,140 @@ -/** Select a DeepSeek wire implementation from one validated configuration generation. */ -import { assertNever } from '@deepseek-ai/dsh-util-values' -import { LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm' -import type { DeepSeekAdapterOptions } from './common/types.ts' -import { ChatCompletionsAdapter } from './protocols/chat-completions/adapter.ts' -import { DeepSeekFileStore } from './common/file-store.ts' -import { DeepSeekMessagesAdapter } from './protocols/messages/adapter.ts' +/** Direct Messages transport with one cancellable lifecycle per model request. */ -/** One provider route with protocol-local transport and shared credentials and model configuration. */ +import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, ImageAttachmentAccessResolver, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { DeepSeekLlmApiJson } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { catalogModelInfo, modelInfo } from './model-info.ts' +import type { DeepSeekAdapterOptions, DeepSeekConnectionOptions as Connection } from './types.ts' +import { DeepSeekFileStore } from './file-store.ts' +import { MESSAGES_FILES_BETA, messagesApiRoot } from './messages-api.ts' +import { FileResolutionFailure, RequestFiles } from './request-files.ts' +import { prepareRequestExtensions } from './request-extensions.ts' +import { imagePricing, inlineImages, prepareFileIds, prepareImages } from './images.ts' +import { serialize } from './serialize.ts' +import { parseSse } from './sse.ts' +import { translate } from './translate.ts' +import { providerError, providerErrorDetail } from './transport.ts' + +/** DeepSeek provider using Messages content and native thinking replay. */ export class DeepSeekAdapter extends LlmAdapter { private readonly files: DeepSeekFileStore + private readonly imageAccess: ImageAttachmentAccessResolver = (ref) => { + const attachments = this.dependencies.resolveAttachments?.() + return attachments === undefined ? undefined : this.dependencies.resolveImageAccess?.(attachments, ref) + } constructor(private readonly dependencies: DeepSeekAdapterOptions) { super() this.files = dependencies.resolveFiles?.() ?? new DeepSeekFileStore() } - private implementation(): LlmAdapter { + override providerInfo(provider: string) { return { id: provider, name: 'DeepSeek' } } + override providerRetryPolicy(_provider: string) { return this.dependencies.options().retryPolicy } + override listModels(provider: string) { const connection = this.dependencies.options() - switch (connection.protocol) { - case 'messages': - return new DeepSeekMessagesAdapter({ - connection: () => connection, - apiKey: this.dependencies.resolveApiKey, - userId: this.dependencies.resolveUserId, - attachments: () => this.dependencies.resolveAttachments?.(), - imageAccess: (ref) => { - const attachments = this.dependencies.resolveAttachments?.() - return attachments === undefined ? undefined : this.dependencies.resolveImageAccess?.(attachments, ref) - }, - files: () => this.files, - prepareExtensions: this.dependencies.prepareExtensions, - ...this.dependencies.onReplayDegrade === undefined ? {} : { onReplayDegrade: this.dependencies.onReplayDegrade }, - }) - case 'chat-completions': - return new ChatCompletionsAdapter({ ...this.dependencies, options: () => connection, resolveFiles: () => this.files }) - /* v8 ignore next -- protocol is validated at configuration resolution. */ - default: return assertNever(connection.protocol, 'DeepSeek protocol') + return Promise.resolve(connection.models.map(model => catalogModelInfo(provider, model))) + } + override resolveModel(provider: string, model: string, _signal?: AbortSignal) { + return Promise.resolve(modelInfo(this.dependencies.options(), provider, model)) + } + override imageRequestPricing(_provider: string, model: string) { + return imagePricing(this.dependencies.options(), model, this.imageAccess) + } + override prepareCall(provider: string, model: string, _signal?: AbortSignal): Promise { + const connection = this.dependencies.options() + return Promise.resolve({ model: modelInfo(connection, provider, model), stream: options => this.generate(options, connection) }) + } + stream(options: GenerateOptions): AsyncIterable { + return this.generate(options, this.dependencies.options()) + } + + private async * generate(options: GenerateOptions, connection: Connection): AsyncGenerator { + const consumer = new AbortController() + const signal = options.signal === undefined ? consumer.signal : AbortSignal.any([consumer.signal, options.signal]) + using watchdog = idleWatchdog(signal, connection.streamIdleTimeoutMs, 'MESSAGES_IDLE') + const iterator = this.request(options, connection, watchdog.signal, () => { watchdog.pulse() }) + try { + while (true) { + const next = await watchdog.next(iterator) + if (next.done) return + yield next.value + } + } catch (error) { + if (timeoutOf(watchdog.signal, 'MESSAGES_IDLE') !== undefined) throw new LlmError('DeepSeek Messages stream idle timeout', 'TIMEOUT', { cause: error }) + if (options.signal?.aborted) throw new LlmError('DeepSeek Messages request aborted', 'ABORTED', { cause: error }) + if (error instanceof LlmError) throw error + throw new LlmError('DeepSeek Messages transport failed', 'TRANSPORT', { cause: error }) + } finally { + consumer.abort() + try { await iterator.return(undefined) } catch (_abortedRequestCleanup) { + // The request already settled; aborting its reader cannot replace that outcome. + } } } - override providerInfo(provider: string) { return this.implementation().providerInfo(provider) } - override providerRetryPolicy(provider: string) { return this.implementation().providerRetryPolicy(provider) } - override listModels(provider: string) { return this.implementation().listModels(provider) } - override resolveModel(provider: string, model: string, signal?: AbortSignal) { - return this.implementation().resolveModel(provider, model, signal) - } - override imageRequestPricing(provider: string, model: string) { - return this.implementation().imageRequestPricing(provider, model) - } - override prepareCall(provider: string, model: string, signal?: AbortSignal): Promise { - return this.implementation().prepareCall(provider, model, signal) - } - stream(options: GenerateOptions): AsyncIterable { - return this.implementation().stream(options) + private async * request( + options: GenerateOptions, connection: Connection, signal: AbortSignal, activity: () => void, + ): AsyncGenerator { + signal.throwIfAborted() + const { messages, versions } = await prepareImages( + options.messages, connection, options.model, this.dependencies.resolveAttachments?.(), this.imageAccess, signal, + ) + const key = await this.dependencies.resolveApiKey(connection) + const files = new RequestFiles(this.files, { baseURL: connection.baseURL, apiKey: key }, + connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) + let inline = false + while (true) { + signal.throwIfAborted() + files.beginAttempt() + let fileIds: Awaited> | undefined + if (!inline) { + try { + fileIds = await prepareFileIds(messages, versions, files) + } catch (error) { + if (!(error instanceof FileResolutionFailure)) throw error + inline = true + continue + } + } + const history = inline ? inlineImages(messages, versions, connection) : messages + const body = serialize(options, connection, history, versions, this.imageAccess, (reason) => { + this.dependencies.onReplayDegrade?.({ provider: options.provider, model: options.model, reason }) + }, fileIds) + const extensions = await prepareRequestExtensions(body as Readonly>, { + signal, + ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, + ...options.purpose === undefined ? {} : { purpose: options.purpose }, + }, this.dependencies.prepareExtensions) + signal.throwIfAborted() + const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, { + method: 'POST', signal, body: extensions.payload, redirect: 'error', + headers: { + ...attributionHeaders(), + 'content-type': 'application/json', 'accept': 'text/event-stream', + 'x-api-key': key, 'anthropic-version': '2023-06-01', + ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, + 'x-deepseek-harness-user-id': this.dependencies.resolveUserId(), + ...options.sessionId === undefined ? {} : { 'x-deepseek-harness-session-id': String(options.sessionId) }, + ...options.purpose === 'compaction' ? { 'x-deepseek-harness-compact': '1' } : {}, + }, + }) + if (!response.ok) { + const text = await response.text() + let raw: unknown + try { raw = JSON.parse(text) } catch (_nonJsonGatewayError) { + // HTTP status is authoritative when a gateway does not return JSON. + } + const detail = providerErrorDetail(raw) + if (await files.retry(detail)) continue + const failure = providerError(raw, response.status, response.headers) + const message = files.errorMessage(response.status, failure.message, detail) + throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) }) + } + await extensions.accept() + if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE') + yield* translate(parseSse(response.body, activity), options.model) + return + } } } diff --git a/packages/llm/llm-deepseek/src/config.ts b/packages/llm/llm-deepseek/src/config.ts index 3bf9f5a6d2..edfbc252b4 100644 --- a/packages/llm/llm-deepseek/src/config.ts +++ b/packages/llm/llm-deepseek/src/config.ts @@ -5,10 +5,10 @@ import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import type { DeepSeekCatalogModel, DeepSeekConnectionOptions, DeepSeekProtocol } from './common/types.ts' -import { DEFAULT_MODELS } from './common/models.ts' -import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES, DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM, DEFAULT_FILE_EXPIRY_SECONDS, DEFAULT_FILE_REFRESH_MARGIN_SECONDS, DEFAULT_FILE_QUOTA_CLEANUP_BATCH, DEFAULT_FILES_API_TIMEOUT_MS } from './common/defaults.ts' -import { DEFAULT_MAX_IMAGES_PER_REQUEST, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_REQUEST_IMAGE_MAX_BYTES } from './common/request-pricing.ts' +import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './types.ts' +import { DEFAULT_MODELS } from './models.ts' +import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES, DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM, DEFAULT_FILE_EXPIRY_SECONDS, DEFAULT_FILE_REFRESH_MARGIN_SECONDS, DEFAULT_FILE_QUOTA_CLEANUP_BATCH, DEFAULT_FILES_API_TIMEOUT_MS } from './defaults.ts' +import { DEFAULT_MAX_IMAGES_PER_REQUEST, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_REQUEST_IMAGE_MAX_BYTES } from './request-pricing.ts' const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY' @@ -23,8 +23,6 @@ const MODEL_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModa * reasoning effort resolves to `high`. */ export interface Config { - /** Wire protocol; defaults to messages. Configure through Cordis YAML. */ - protocol?: DeepSeekProtocol /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ @@ -78,7 +76,6 @@ const catalogModel: z = z.object({ }) export const Config: z = z.object({ - protocol: z.union(['chat-completions', 'messages']).default('messages'), apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV), baseURL: z.string(), thinking: z.union(['enabled', 'disabled']), @@ -101,10 +98,7 @@ export const Config: z = z.object({ }) /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ -export const PUBLIC_BASE_URL = 'https://api.deepseek.com' - -/** Official Messages protocol root. */ -export const MESSAGES_BASE_URL = 'https://api.deepseek.com/anthropic' +export const PUBLIC_BASE_URL = 'https://api.deepseek.com/anthropic' /** Environment variable naming this provider's endpoint, honored only from trusted layers. */ const BASE_URL_ENV = 'DEEPSEEK_BASE_URL' @@ -204,9 +198,8 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee */ export function resolveAdapterOptions(config: Config, environment?: LaunchEnvironmentSnapshot): ResolvedDeepSeekOptions { // Settings updates can reach this resolver without schema validation. - const protocol: string = config.protocol ?? 'messages' - if (protocol !== 'chat-completions' && protocol !== 'messages') { - throw new Error('llm-deepseek: protocol must be chat-completions or messages') + if (Object.hasOwn(config, 'protocol')) { + throw new Error('llm-deepseek: protocol is not configurable; remove it and use a Messages-compatible baseURL') } if (config.thinking === 'disabled' && config.reasoningEffort !== undefined @@ -289,16 +282,12 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro || fileQuotaCleanupBatch > 1_000) { throw new Error('llm-deepseek: fileQuotaCleanupBatch must be an integer from 1 through 1000') } - const baseURL = config.baseURL ?? environment?.get(BASE_URL_ENV)?.value - ?? (protocol === 'messages' ? MESSAGES_BASE_URL : PUBLIC_BASE_URL) - if (protocol === 'messages') { - const parsed = new URL(baseURL) - if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) { - throw new Error('llm-deepseek: Messages baseURL must be an HTTP(S) root without credentials, query, or fragment') - } + const baseURL = config.baseURL ?? environment?.get(BASE_URL_ENV)?.value ?? PUBLIC_BASE_URL + const parsed = new URL(baseURL) + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error('llm-deepseek: Messages baseURL must be an HTTP(S) root without credentials, query, or fragment') } return { - protocol, apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL, defaults: { diff --git a/packages/llm/llm-deepseek/src/common/defaults.ts b/packages/llm/llm-deepseek/src/defaults.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/defaults.ts rename to packages/llm/llm-deepseek/src/defaults.ts diff --git a/packages/llm/llm-deepseek/src/common/file-id.ts b/packages/llm/llm-deepseek/src/file-id.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/file-id.ts rename to packages/llm/llm-deepseek/src/file-id.ts diff --git a/packages/llm/llm-deepseek/src/common/file-store.ts b/packages/llm/llm-deepseek/src/file-store.ts similarity index 93% rename from packages/llm/llm-deepseek/src/common/file-store.ts rename to packages/llm/llm-deepseek/src/file-store.ts index 5fcab0cbe1..0963d2eeab 100644 --- a/packages/llm/llm-deepseek/src/common/file-store.ts +++ b/packages/llm/llm-deepseek/src/file-store.ts @@ -7,7 +7,6 @@ import type { DeepSeekFileId } from './file-id.ts' import { messagesApiRoot } from './messages-api.ts' import { deepSeekFileScope, DeepSeekUploadIndex } from './upload-index.ts' import type { DeepSeekUploadRecord } from './upload-index.ts' -import type { DeepSeekProtocol } from './types.ts' /** Shared Files-store limit for each request image, including file-id references. */ export const MAX_IMAGE_BYTES = 32 * 1024 * 1024 @@ -24,8 +23,6 @@ export interface DeepSeekFilePolicy { export interface DeepSeekFileConnection { baseURL: string apiKey: string - /** Files wire protocol selected by the resolved connection. */ - protocol: DeepSeekProtocol } /** Result of one file-id resolution. */ @@ -47,10 +44,10 @@ interface SharedUpload { waiters: number } -/** The Files resource's parent URL distinguishes custom protocol namespaces. */ +/** The Files resource's parent URL identifies the upload namespace. */ function fileScope(connection: DeepSeekFileConnection) { return deepSeekFileScope( - connection.protocol === 'messages' ? messagesApiRoot(connection.baseURL) : connection.baseURL, + messagesApiRoot(connection.baseURL), connection.apiKey, ) } @@ -139,7 +136,6 @@ export class DeepSeekFileStore { return new DeepSeekFilesClient({ baseURL: connection.baseURL, apiKey: connection.apiKey, - protocol: connection.protocol, ...this.fetchImpl === undefined ? {} : { fetch: this.fetchImpl }, }) } @@ -306,23 +302,19 @@ export class DeepSeekFileStore { const client = this.client(connection) let after: DeepSeekFileId | undefined const owned: { id: DeepSeekFileId; createdAt: number }[] = [] - while (connection.protocol === 'messages' || owned.length < count) { + while (true) { const page = await client.list({ ...after === undefined ? {} : { after }, limit: 1_000, - order: 'asc', ...signal === undefined ? {} : { signal }, }) for (const file of page.data) { if (!file.filename.startsWith(OWNED_FILE_PREFIX)) continue owned.push({ id: file.id, createdAt: file.createdAt }) - if (connection.protocol === 'chat-completions' && owned.length === count) break - } - if (connection.protocol === 'messages') { - // Messages offers no ascending-order query; retain the oldest candidates across every page. - owned.sort((left, right) => left.createdAt - right.createdAt) - owned.splice(count) } + // The API offers no ascending-order query; retain the oldest candidates across every page. + owned.sort((left, right) => left.createdAt - right.createdAt) + owned.splice(count) if (!page.hasMore || page.lastId === undefined || page.lastId === after) break after = page.lastId } diff --git a/packages/llm/llm-deepseek/src/common/files-api.ts b/packages/llm/llm-deepseek/src/files-api.ts similarity index 63% rename from packages/llm/llm-deepseek/src/common/files-api.ts rename to packages/llm/llm-deepseek/src/files-api.ts index 8c05165bea..abf9f12191 100644 --- a/packages/llm/llm-deepseek/src/common/files-api.ts +++ b/packages/llm/llm-deepseek/src/files-api.ts @@ -1,11 +1,10 @@ -/** DeepSeek Files API transport for Chat Completions and Messages endpoints. @module dsh-llm-deepseek/files-api */ +/** DeepSeek Files API transport. @module dsh-llm-deepseek/files-api */ import { attributionHeaders, LlmError } from '@deepseek-ai/dsh-llm' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' import { DeepSeekFileId } from './file-id.ts' import type { DeepSeekFileId as DeepSeekFileIdType } from './file-id.ts' import { messagesApiRoot, MESSAGES_FILES_BETA } from './messages-api.ts' -import type { DeepSeekProtocol } from './types.ts' /** Minimum provider-supported file lifetime. */ export const MIN_FILE_EXPIRY_SECONDS = 3_600 @@ -18,15 +17,13 @@ export const MAX_STORED_FILE_COUNT = 10_000 /** Current per-key storage quota. */ export const MAX_STORED_FILE_BYTES = 25 * 1024 * 1024 * 1024 -/** Validated file metadata normalized from either DeepSeek Files protocol. */ +/** Validated provider file metadata. */ export interface DeepSeekFileObject { id: DeepSeekFileIdType bytes: number createdAt: number filename: string - /** Chat Completions purpose; synthesized as `user_data` for Messages. */ - purpose: 'user_data' - /** Remote Chat Completions expiry or the upload-time Messages reuse deadline; Messages list/retrieve omit this field. */ + /** Upload-time reuse deadline; list and retrieve responses omit this field. */ expiresAt?: number } @@ -74,20 +71,9 @@ export function isFilesQuotaError(error: unknown): error is DeepSeekFilesError { interface FilesApiOptions { baseURL: string apiKey: string - protocol: DeepSeekProtocol fetch?: typeof fetch } -interface WireFileObject { - id?: unknown - object?: unknown - bytes?: unknown - created_at?: unknown - filename?: unknown - purpose?: unknown - expires_at?: unknown -} - function invalidResponse(operation: string): LlmError { return new LlmError(`DeepSeek Files API returned an invalid ${operation} response.`, 'INVALID_RESPONSE') } @@ -107,39 +93,25 @@ async function responseJson(response: Response, operation: string): Promise + const createdAt = typeof wire.created_at === 'string' ? Math.floor(Date.parse(wire.created_at) / 1_000) : NaN if (typeof wire.id !== 'string' || wire.id.length === 0 - || wire.object !== 'file' - || !Number.isSafeInteger(wire.bytes) || (wire.bytes as number) < 0 - || !Number.isSafeInteger(wire.created_at) || (wire.created_at as number) < 0 + || wire.type !== 'file' + || typeof wire.mime_type !== 'string' + || typeof wire.size_bytes !== 'number' || !Number.isSafeInteger(wire.size_bytes) || wire.size_bytes < 0 + || !Number.isSafeInteger(createdAt) || createdAt < 0 || typeof wire.filename !== 'string' || wire.filename.length === 0 - || wire.purpose !== 'user_data' - || (wire.expires_at !== undefined - && (!Number.isSafeInteger(wire.expires_at) || (wire.expires_at as number) < 0))) { + ) { throw invalidResponse(operation) } return { id: DeepSeekFileId(wire.id), - bytes: wire.bytes as number, - createdAt: wire.created_at as number, + bytes: wire.size_bytes, + createdAt, filename: wire.filename, - purpose: 'user_data', - ...wire.expires_at === undefined ? {} : { expiresAt: wire.expires_at as number }, } } -/** Normalize the Messages wire object without interpreting omitted expiration as permanence. */ -function parseMessagesFile(value: unknown, operation: string): DeepSeekFileObject { - if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalidResponse(operation) - const wire = value as Record - const createdAt = typeof wire.created_at === 'string' ? Math.floor(Date.parse(wire.created_at) / 1_000) : NaN - if (wire.type !== 'file' || typeof wire.mime_type !== 'string' || !Number.isSafeInteger(createdAt)) throw invalidResponse(operation) - return parseFileObject({ - id: wire.id, object: 'file', bytes: wire.size_bytes, created_at: createdAt, - filename: wire.filename, purpose: 'user_data', - }, operation) -} - function providerErrorDetail(value: unknown): { message?: string; detail: string } { if (value === null || typeof value !== 'object' || Array.isArray(value)) return { detail: '' } const error = (value as { error?: unknown }).error @@ -159,8 +131,6 @@ export class DeepSeekFilesClient { private readonly baseURL: string private readonly apiKey: string private readonly fetchImpl: typeof fetch - private readonly protocol: DeepSeekProtocol - private readonly path: string /** * @param options - endpoint, API-key snapshot, and optional test transport. @@ -168,26 +138,16 @@ export class DeepSeekFilesClient { constructor(options: FilesApiOptions) { this.apiKey = options.apiKey this.fetchImpl = options.fetch ?? globalThis.fetch - this.protocol = options.protocol - this.baseURL = this.protocol === 'messages' - ? messagesApiRoot(options.baseURL) - : options.baseURL.replace(/\/+$/u, '') - this.path = '/files' - } - - private parseFile(value: unknown, operation: string): DeepSeekFileObject { - return this.protocol === 'messages' ? parseMessagesFile(value, operation) : parseFileObject(value, operation) + this.baseURL = messagesApiRoot(options.baseURL) } private async request(path: string, init: RequestInit, signal?: AbortSignal): Promise { let response: Response try { const headers = new Headers(attributionHeaders()) - if (this.protocol === 'messages') { - headers.set('x-api-key', this.apiKey) - headers.set('anthropic-version', '2023-06-01') - headers.set('anthropic-beta', MESSAGES_FILES_BETA) - } else headers.set('authorization', `Bearer ${this.apiKey}`) + headers.set('x-api-key', this.apiKey) + headers.set('anthropic-version', '2023-06-01') + headers.set('anthropic-beta', MESSAGES_FILES_BETA) response = await this.fetchImpl(`${this.baseURL}${path}`, { ...init, redirect: 'error', @@ -235,45 +195,40 @@ export class DeepSeekFilesClient { throw new LlmError('DeepSeek file expiry must be between 3600 and 2592000 seconds.', 'INVALID_REQUEST') } const form = new FormData() - if (this.protocol === 'chat-completions') form.set('purpose', 'user_data') form.set('expires_after[anchor]', 'created_at') form.set('expires_after[seconds]', String(input.expiresAfterSeconds)) form.set('file', new Blob([Uint8Array.from(input.data).buffer], { type: input.mediaType }), input.filename) - const response = await this.request(this.path, { method: 'POST', body: form }, input.signal) - const file = this.parseFile(await responseJson(response, 'upload'), 'upload') - if (this.protocol === 'messages') return { ...file, expiresAt: file.createdAt + input.expiresAfterSeconds } - if (file.expiresAt === undefined) throw invalidResponse('upload') - return { ...file, expiresAt: file.expiresAt } + const response = await this.request('/files', { method: 'POST', body: form }, input.signal) + const file = parseFileObject(await responseJson(response, 'upload'), 'upload') + return { ...file, expiresAt: file.createdAt + input.expiresAfterSeconds } } /** - * List one page of files. Ordering applies only to Chat Completions; Messages owns its page order. - * @param options - pagination, ordering, and cancellation. - * @returns the validated page with null Messages cursors omitted. + * List one provider-ordered page of files. + * @param options - pagination and cancellation. + * @returns the validated page with null cursors omitted. */ async list(options: { after?: DeepSeekFileIdType limit?: number - order?: 'asc' | 'desc' signal?: AbortSignal } = {}): Promise { - const query = new URLSearchParams(this.protocol === 'messages' ? {} : { purpose: 'user_data' }) - if (options.after !== undefined) query.set(this.protocol === 'messages' ? 'after_id' : 'after', options.after) + const query = new URLSearchParams() + if (options.after !== undefined) query.set('after_id', options.after) if (options.limit !== undefined) query.set('limit', String(options.limit)) - if (options.order !== undefined && this.protocol === 'chat-completions') query.set('order', options.order) - const response = await this.request(`${this.path}?${query.toString()}`, { method: 'GET' }, options.signal) + const response = await this.request(`/files?${query.toString()}`, { method: 'GET' }, options.signal) const value: unknown = await responseJson(response, 'list') if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalidResponse('list') - const wire = value as { object?: unknown; data?: unknown; first_id?: unknown; last_id?: unknown; has_more?: unknown } - const firstId = this.protocol === 'messages' ? wire.first_id ?? undefined : wire.first_id - const lastId = this.protocol === 'messages' ? wire.last_id ?? undefined : wire.last_id - if ((this.protocol === 'chat-completions' && wire.object !== 'list') || !Array.isArray(wire.data) || typeof wire.has_more !== 'boolean' + const wire = value as { data?: unknown; first_id?: unknown; last_id?: unknown; has_more?: unknown } + const firstId = wire.first_id ?? undefined + const lastId = wire.last_id ?? undefined + if (!Array.isArray(wire.data) || typeof wire.has_more !== 'boolean' || (firstId !== undefined && typeof firstId !== 'string') || (lastId !== undefined && typeof lastId !== 'string')) { throw invalidResponse('list') } return { - data: wire.data.map(item => this.parseFile(item, 'list')), + data: wire.data.map(item => parseFileObject(item, 'list')), ...typeof firstId === 'string' ? { firstId: DeepSeekFileId(firstId) } : {}, ...typeof lastId === 'string' ? { lastId: DeepSeekFileId(lastId) } : {}, hasMore: wire.has_more, @@ -287,8 +242,8 @@ export class DeepSeekFilesClient { * @returns the validated file object. */ async retrieve(fileId: DeepSeekFileIdType, signal?: AbortSignal): Promise { - const response = await this.request(`${this.path}/${encodeURIComponent(fileId)}`, { method: 'GET' }, signal) - return this.parseFile(await responseJson(response, 'retrieve'), 'retrieve') + const response = await this.request(`/files/${encodeURIComponent(fileId)}`, { method: 'GET' }, signal) + return parseFileObject(await responseJson(response, 'retrieve'), 'retrieve') } /** @@ -297,12 +252,10 @@ export class DeepSeekFilesClient { * @param signal - request cancellation. */ async delete(fileId: DeepSeekFileIdType, signal?: AbortSignal): Promise { - const response = await this.request(`${this.path}/${encodeURIComponent(fileId)}`, { method: 'DELETE' }, signal) + const response = await this.request(`/files/${encodeURIComponent(fileId)}`, { method: 'DELETE' }, signal) const value: unknown = await responseJson(response, 'delete') if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalidResponse('delete') - const wire = value as { id?: unknown; object?: unknown; deleted?: unknown; type?: unknown } - if (wire.id !== fileId || (this.protocol === 'messages' - ? wire.type !== 'file_deleted' - : wire.object !== 'file' || wire.deleted !== true)) throw invalidResponse('delete') + const wire = value as { id?: unknown; type?: unknown } + if (wire.id !== fileId || wire.type !== 'file_deleted') throw invalidResponse('delete') } } diff --git a/packages/llm/llm-deepseek/src/common/image-tokens.ts b/packages/llm/llm-deepseek/src/image-tokens.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/image-tokens.ts rename to packages/llm/llm-deepseek/src/image-tokens.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/images.ts b/packages/llm/llm-deepseek/src/images.ts similarity index 93% rename from packages/llm/llm-deepseek/src/protocols/messages/images.ts rename to packages/llm/llm-deepseek/src/images.ts index f2f32f003e..9d72f4c5dc 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/images.ts +++ b/packages/llm/llm-deepseek/src/images.ts @@ -3,12 +3,12 @@ import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, projectOffloadedImages, requiredImageOffload } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ImageAttachmentAccessResolver, RequestMessage } from '@deepseek-ai/dsh-llm' -import type { DeepSeekConnectionOptions as Connection } from '../../common/types.ts' -import { resolveRequestImageTarget } from '../../common/request-pricing.ts' -import type { DeepSeekFileId } from '../../common/file-id.ts' -import type { RequestFiles } from '../../common/request-files.ts' +import type { DeepSeekConnectionOptions as Connection } from './types.ts' +import { resolveRequestImageTarget } from './request-pricing.ts' +import type { DeepSeekFileId } from './file-id.ts' +import type { RequestFiles } from './request-files.ts' -export { deepSeekImageRequestPricing as imagePricing } from '../../common/request-pricing.ts' +export { deepSeekImageRequestPricing as imagePricing } from './request-pricing.ts' function bounds(connection: Connection, representation: 'raw' | 'base64') { return { diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index d38246b971..579e977a9c 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,4 +1,4 @@ -/** Register DeepSeek with protocol selection and request-local settings and credentials. */ +/** Register DeepSeek Messages with request-local settings and credentials. */ import type { Context } from '@deepseek-ai/cordis' import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-fs' @@ -10,7 +10,7 @@ import { DeepSeekAdapter } from './adapter.ts' import { Config, resolveAdapterOptions } from './config.ts' import type { ResolvedDeepSeekOptions } from './config.ts' -export { Config, resolveAdapterOptions, PUBLIC_BASE_URL, MESSAGES_BASE_URL } from './config.ts' +export { Config, resolveAdapterOptions, PUBLIC_BASE_URL } from './config.ts' export type { ResolvedDeepSeekOptions } from './config.ts' export { DEFAULT_CONTEXT_WINDOW, @@ -24,10 +24,9 @@ export { DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, -} from './common/defaults.ts' +} from './defaults.ts' export { DeepSeekAdapter } from './adapter.ts' -export type { DeepSeekProtocol } from './common/types.ts' -export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './common/types.ts' +export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './types.ts' export { DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET, DEFAULT_MAX_IMAGES_PER_REQUEST, @@ -37,18 +36,18 @@ export { deepSeekImageRequestPricing, resolveRequestImageMaxBytes, resolveRequestImageTarget, -} from './common/request-pricing.ts' -export { deepSeekImageTokens, deepSeekRequestImageDimensions } from './common/image-tokens.ts' -export { DeepSeekFileStore, MAX_IMAGE_BYTES } from './common/file-store.ts' -export type { DeepSeekFileConnection, DeepSeekFilePolicy, DeepSeekFileReference } from './common/file-store.ts' -export { DeepSeekFilesClient, MAX_FILE_EXPIRY_SECONDS, MAX_FILE_UPLOAD_BYTES, MAX_STORED_FILE_BYTES, MAX_STORED_FILE_COUNT, MIN_FILE_EXPIRY_SECONDS } from './common/files-api.ts' -export type { DeepSeekFileObject, DeepSeekFilePage } from './common/files-api.ts' -export { DeepSeekFileId } from './common/file-id.ts' -export type { DeepSeekFileId as DeepSeekFileIdType } from './common/file-id.ts' -export { DeepSeekUploadIndex, deepSeekFileScope } from './common/upload-index.ts' -export type { DeepSeekUploadRecord } from './common/upload-index.ts' -export type { RequestDefaults } from './common/types.ts' -export type * from './protocols/chat-completions/types.ts' +} from './request-pricing.ts' +export { deepSeekImageTokens, deepSeekRequestImageDimensions } from './image-tokens.ts' +export { DeepSeekFileStore, MAX_IMAGE_BYTES } from './file-store.ts' +export type { DeepSeekFileConnection, DeepSeekFilePolicy, DeepSeekFileReference } from './file-store.ts' +export { DeepSeekFilesClient, MAX_FILE_EXPIRY_SECONDS, MAX_FILE_UPLOAD_BYTES, MAX_STORED_FILE_BYTES, MAX_STORED_FILE_COUNT, MIN_FILE_EXPIRY_SECONDS } from './files-api.ts' +export type { DeepSeekFileObject, DeepSeekFilePage } from './files-api.ts' +export { DeepSeekFileId } from './file-id.ts' +export type { DeepSeekFileId as DeepSeekFileIdType } from './file-id.ts' +export { DeepSeekUploadIndex, deepSeekFileScope } from './upload-index.ts' +export type { DeepSeekUploadRecord } from './upload-index.ts' +export type { RequestDefaults } from './types.ts' +export type * from './wire-types.ts' export const name = 'llm-deepseek' export const inject = ['llm'] diff --git a/packages/llm/llm-deepseek/src/common/messages-api.ts b/packages/llm/llm-deepseek/src/messages-api.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/messages-api.ts rename to packages/llm/llm-deepseek/src/messages-api.ts diff --git a/packages/llm/llm-deepseek/src/common/model-info.ts b/packages/llm/llm-deepseek/src/model-info.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/model-info.ts rename to packages/llm/llm-deepseek/src/model-info.ts diff --git a/packages/llm/llm-deepseek/src/common/models.ts b/packages/llm/llm-deepseek/src/models.ts similarity index 92% rename from packages/llm/llm-deepseek/src/common/models.ts rename to packages/llm/llm-deepseek/src/models.ts index 8f614db47b..6bad9113ce 100644 --- a/packages/llm/llm-deepseek/src/common/models.ts +++ b/packages/llm/llm-deepseek/src/models.ts @@ -1,4 +1,4 @@ -/** Default catalog shared by every DeepSeek protocol. */ +/** Default DeepSeek model catalog. */ import { DEFAULT_CONTEXT_WINDOW } from './defaults.ts' import type { DeepSeekCatalogModel } from './types.ts' diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts deleted file mode 100644 index 77d2bf6ca5..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts +++ /dev/null @@ -1,378 +0,0 @@ -/** - * `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible) - * chat-completions endpoint, emitting harness StreamChunks. The adapter is - * transport-only: connection facts arrive through a thunk resolved once per - * operation and the bearer token through a per-request resolver, so the - * registering plugin owns validation, layering, and credential policy. - * - * @module dsh-llm-deepseek/adapter - */ - -import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' -import type { - ContentBlock, - GenerateOptions, - ImageAttachmentAccess, - LlmModelInfo, - LlmProviderInfo, - PreparedAdapterCall, - LlmResolvedModelInfo, - ResolvedRetryPolicy, - StreamChunk, -} from '@deepseek-ai/dsh-llm' -import type { - AttachmentId, - AttachmentStore, - ImageAttachmentRef, - RequestImageAttachment, -} from '@deepseek-ai/dsh-attachment' -import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' -import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' -import type { - DeepSeekLlmApiJson, -} from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import { serializeRequest, serializeRequestWithImages } from './serialize.ts' -import { deepSeekImageRequestPricing, resolveRequestImageTarget } from '../../common/request-pricing.ts' -import { catalogModelInfo, modelInfo } from '../../common/model-info.ts' -import type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from '../../common/types.ts' -import type { DeepSeekFileStore } from '../../common/file-store.ts' -import { FileResolutionFailure, RequestFiles } from '../../common/request-files.ts' -import { prepareRequestExtensions } from '../../common/request-extensions.ts' -import { parseSse } from './sse.ts' -import { translate } from './translate.ts' -import type { WireError, WireRequest } from './types.ts' - -const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' - -function collectImageRefs( - content: readonly ContentBlock[], - refs: Map, -): void { - for (const block of content) { - if (block.type === 'image' && block.offloaded !== true) refs.set(block.attachment.attachmentId, block.attachment) - } -} - -async function prepareRequestImages( - options: GenerateOptions, - attachments: AttachmentStore, - model: DeepSeekCatalogModel, - signal: AbortSignal, -): Promise> { - const refs = new Map() - for (const message of options.messages) collectImageRefs(message.content, refs) - const orderedRefs = [...refs.values()] - const projected = await Promise.all(orderedRefs.map( - ref => attachments.readImageRequest(ref, resolveRequestImageTarget(model, ref), signal), - )) - return new Map(orderedRefs.map((ref, index) => ( - [ref.attachmentId, projected[index] as RequestImageAttachment] - ))) -} - - - -function providerRetryAfterMs(value: string | null): number | undefined { - if (value === null) return undefined - if (/^\d+$/.test(value)) { - const delay = Number(value) * 1_000 - return Number.isFinite(delay) && delay > 0 ? delay : undefined - } - const delay = Date.parse(value) - Date.now() - return Number.isFinite(delay) && delay > 0 ? delay : undefined -} - -function requestId(headers: Headers): ReturnType | undefined { - const value = headers.get('x-request-id') ?? headers.get('x-deepseek-request-id') - return value === null || value.length === 0 ? undefined : ProviderRequestId(value) -} - -/** - * Map an HTTP status to a stable LlmError code. - * @param status - status of a non-2xx provider response. - * @param error - parsed provider error body, when available. - * @returns the normalized harness error code. - */ -export function httpErrorCode(status: number, error?: WireError['error']): string { - if (status === 401 || status === 403) return 'AUTH' - if (status === 413) return 'INVALID_REQUEST' - const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ') - if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE - if (status === 429) return 'RATE_LIMIT' - if (status === 400) { - if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE - return 'INVALID_REQUEST' - } - if (status >= 500) return 'SERVER' - return `HTTP_${status}` -} - -/** - * The first real `LlmAdapter`. One instance serves every model name it was - * registered under (the harness model name IS the wire model name). - * - * One stable signal reaches both initial fetch and body reads. Caller aborts - * map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`. - */ -export class ChatCompletionsAdapter extends LlmAdapter { - private readonly files: DeepSeekFileStore - - constructor(private readonly config: DeepSeekAdapterOptions & { resolveFiles: () => DeepSeekFileStore }) { - super() - this.files = config.resolveFiles() - } - - override providerInfo(provider: string): LlmProviderInfo { - return { id: provider, name: 'DeepSeek' } - } - - override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { - return this.config.options().retryPolicy - } - - override imageRequestPricing(_provider: string, model: string): ReturnType { - // The same access resolution the serializer uses, so priced handle and - // placeholder text matches what the request actually sends. - const attachments = this.config.resolveAttachments?.() - const resolveAccess = attachments === undefined - ? undefined - : (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => ( - this.config.resolveImageAccess?.(attachments, ref) - ) - return deepSeekImageRequestPricing(this.config.options(), model, resolveAccess) - } - - override listModels(provider: string): Promise { - return Promise.resolve(this.config.options().models.map(model => catalogModelInfo(provider, model))) - } - - override resolveModel( - provider: string, - model: string, - _signal?: AbortSignal, - ): Promise { - return Promise.resolve(modelInfo(this.config.options(), provider, model)) - } - - override prepareCall(provider: string, model: string, _signal?: AbortSignal): Promise { - const connection = this.config.options() - return Promise.resolve({ - model: modelInfo(connection, provider, model), - stream: options => this.streamWithConnection(options, connection), - }) - } - - stream(options: GenerateOptions): AsyncIterable { - return this.streamWithConnection(options, this.config.options()) - } - - private async * streamWithConnection( - options: GenerateOptions, - connection: DeepSeekConnectionOptions, - ): AsyncIterable { - // One resolution per stream call: connection facts and the credential - // freeze here and hold for this whole request, so an in-flight stream - // never observes a configuration change and the next call re-resolves. - // The key resolves *from this snapshot*, so an endpoint and the secret - // sent to it can never come from different configuration generations. - const hasImages = options.messages.some(message => contentHasImage(message.content)) - let attachments: AttachmentStore | undefined - if (hasImages) { - const model = connection.models.find(entry => entry.id === options.model) - if (model?.inputModalities?.includes('image') !== true) { - throw new LlmError( - `DeepSeek model "${options.model}" does not accept image input.`, - 'UNSUPPORTED_CONTENT', - ) - } - attachments = this.config.resolveAttachments?.() - if (attachments === undefined) { - throw new LlmError( - 'DeepSeek image conversion requires the durable attachment service.', - 'UNSUPPORTED_CONTENT', - ) - } - } - const apiKey = await this.config.resolveApiKey(connection) - const userId = this.config.resolveUserId() - const consumer = new AbortController() - const upstream = options.signal === undefined - ? consumer.signal - : AbortSignal.any([options.signal, consumer.signal]) - using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE) - const iterator = this.request( - options, - watchdog.signal, - connection, - apiKey, - userId, - attachments, - () => { watchdog.pulse() }, - )[Symbol.asyncIterator]() - let exhausted = false - try { - while (true) { - const result = await watchdog.next(iterator) - if (result.done) { - exhausted = true - return - } - yield result.value - } - } catch (error: unknown) { - if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) { - throw new LlmError( - `DeepSeek stream idle timeout after ${connection.streamIdleTimeoutMs}ms`, - 'TIMEOUT', - { cause: error }, - ) - } - if (options.signal?.aborted) { - throw new LlmError('DeepSeek request aborted by caller', 'ABORTED', { cause: error }) - } - if (error instanceof LlmError) throw error - throw new LlmError(`DeepSeek API stream from ${connection.baseURL} failed`, 'TRANSPORT', { cause: error }) - } finally { - consumer.abort('DeepSeek stream consumer stopped') - if (!exhausted && iterator.return !== undefined) { - try { - await iterator.return() - } catch (_abortedTransportTeardown) { - // The consumer controller already owns termination; a return-time abort cannot add a second outcome. - } - } - } - } - - private async * request( - options: GenerateOptions, - signal: AbortSignal, - connection: DeepSeekConnectionOptions, - apiKey: string, - userId: AnonymousUserId, - attachments: AttachmentStore | undefined, - onActivity: () => void, - ): AsyncIterable { - const headers = { - 'authorization': `Bearer ${apiKey}`, - 'content-type': 'application/json', - 'accept': 'text/event-stream', - ...attributionHeaders(), - 'x-deepseek-harness-user-id': String(userId), - ...options.sessionId !== undefined - ? { 'x-deepseek-harness-session-id': String(options.sessionId) } - : {}, - ...options.purpose === 'compaction' - ? { 'x-deepseek-harness-compact': '1' } - : {}, - } - - const fileConnection = { baseURL: connection.baseURL, apiKey, protocol: connection.protocol } - const model = connection.models.find(entry => entry.id === options.model) - const resolveImageAccess = attachments === undefined - ? undefined - : (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => this.config.resolveImageAccess?.(attachments, ref) - const imageAccessOptions = resolveImageAccess === undefined ? {} : { resolveImageAccess } - const requestOptions = options - const requestImages = attachments === undefined || model === undefined - ? new Map() - : await prepareRequestImages(requestOptions, attachments, model, signal) - let representation: 'file' | 'base64' = 'file' - const requestFiles = new RequestFiles( - this.files, fileConnection, connection.filePolicy, connection.filesApiTimeoutMs, signal, onActivity, - ) - while (true) { - requestFiles.beginAttempt() - let body: WireRequest - if (attachments === undefined) { - body = serializeRequest(requestOptions, connection.defaults) - } else if (representation === 'base64') { - body = await serializeRequestWithImages(requestOptions, { - representation: { kind: 'base64' }, - requestImages, - ...imageAccessOptions, - maxRequestImageBytes: connection.maxInlineRequestImageBytes, - maxImagesPerRequest: connection.maxImagesPerRequest, - byteQuantum: connection.inlineImageOffloadByteQuantum, - countQuantum: connection.imageOffloadCountQuantum, - }, connection.defaults) - } else { - try { - body = await serializeRequestWithImages(requestOptions, { - representation: { - kind: 'file', - resolveFileId: (version, _block, location) => requestFiles.resolve(version, location), - }, - requestImages, - ...imageAccessOptions, - maxRequestImageBytes: connection.maxRequestFilesBytes, - maxImagesPerRequest: connection.maxImagesPerRequest, - byteQuantum: connection.imageOffloadByteQuantum, - countQuantum: connection.imageOffloadCountQuantum, - }, connection.defaults) - } catch (error: unknown) { - if (!(error instanceof FileResolutionFailure)) throw error - representation = 'base64' - continue - } - } - const extensions = await prepareRequestExtensions(body as unknown as Readonly>, { - signal, - ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, - ...options.purpose === undefined ? {} : { purpose: options.purpose }, - }, this.config.prepareExtensions) - - // TODO(http): adopt the Cordis HTTP service when shared transport configuration - // outweighs its additional runtime dependencies. - let response: Response - try { - response = await fetch(`${connection.baseURL}/chat/completions`, { - method: 'POST', - headers, - body: extensions.payload, - signal, - }) - } catch (error: unknown) { - if (signal.aborted) throw error - throw new LlmError( - `DeepSeek API request to ${connection.baseURL} failed`, - 'TRANSPORT', - { cause: error }, - ) - } - - if (!response.ok) { - let message = `DeepSeek API error (HTTP ${response.status})` - let providerError: WireError['error'] - const rawResponse = await response.text() - try { - const parsed = JSON.parse(rawResponse) as WireError - providerError = parsed.error - if (providerError?.message) message = providerError.message - } catch { - // The HTTP status remains authoritative when a gateway returns malformed JSON. - } - const detail = [providerError?.code, providerError?.type, providerError?.message] - .filter((field): field is string => typeof field === 'string') - .join(' ') - if (await requestFiles.retry(detail)) continue - message = requestFiles.errorMessage(response.status, message, detail) - const delay = providerRetryAfterMs(response.headers.get('retry-after')) - const id = requestId(response.headers) - throw new LlmError(message, httpErrorCode(response.status, providerError), { - cause: new Error(rawResponse.length > 0 ? rawResponse : `DeepSeek HTTP ${response.status}`), - status: response.status, - ...delay === undefined ? {} : { providerRetryAfterMs: delay }, - ...id === undefined ? {} : { requestId: id }, - }) - } - await extensions.accept() - if (!response.body) { - throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') - } - - yield* translate(parseSse(response.body, onActivity)) - return - } - } -} diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/serialize.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/serialize.ts deleted file mode 100644 index 803e4c6877..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/serialize.ts +++ /dev/null @@ -1,444 +0,0 @@ -/** - * Serialize harness messages into DeepSeek chat completions. Text-only - * requests retain string user content; the image path resolves durable - * attachments into ordered file-id or inline parts. Tool-role messages map to - * string-only `tool` wire messages; their image parts follow in one shared - * trailing user message. - * @module dsh-llm-deepseek/serialize - */ - -import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, projectOffloadedImages, requestImageHandleText, requiredImageOffload } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message, RequestMessage } from '@deepseek-ai/dsh-llm' -import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import type { - WireImageContentPart, - WireMessage, - WireRequest, - WireTextContentPart, - WireTool, - WireUserContentPart, -} from './types.ts' - -import type { RequestDefaults } from '../../common/types.ts' - -interface ResolvedThinking { - thinking?: 'enabled' | 'disabled' - reasoningEffort?: 'low' | 'high' | 'max' -} - -/** Provider representation for every retained image in one request. */ -export type ImageRequestRepresentation = - | { - kind: 'file' - /** Resolve a retained request version to a reusable DeepSeek file id. */ - resolveFileId: ( - version: RequestImageAttachment, - block: Extract, - location: ImageWireLocation, - ) => Promise - } - | { kind: 'base64' } - -/** Dependencies required only when the request contains image input. */ -export interface ImageSerializationOptions { - /** One representation used for every retained image in this request. */ - representation: ImageRequestRepresentation - /** Request versions prepared for the conservatively retained normalized attachments, keyed by attachment id. */ - requestImages: ReadonlyMap - /** Resolve current tool access independently from deterministic request-image versions. */ - resolveImageAccess?: ImageAttachmentAccessResolver - /** Positive bound on accumulated represented image bytes. */ - maxRequestImageBytes: number - /** Maximum represented images in one request. */ - maxImagesPerRequest?: number - /** Represented-byte removal step applied after the request exceeds its byte bound. */ - byteQuantum?: number - /** Image-count removal step applied after the request exceeds its count bound. */ - countQuantum?: number -} - -export type { ImageWireLocation } from '../../common/request-files.ts' -import type { ImageWireLocation } from '../../common/request-files.ts' - -const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result:' - -/** Validate the adapter-owned effort before resolving its DeepSeek wire fields. */ -function reasoningEffort(effort: NonNullable): 'off' | 'low' | 'high' | 'max' { - if (effort === 'off' || effort === 'low' || effort === 'high' || effort === 'max') { - return effort as 'off' | 'low' | 'high' | 'max' - } - throw new LlmError( - `DeepSeek does not support reasoning effort "${effort}"`, - 'UNSUPPORTED_REASONING_EFFORT', - ) -} - -/** Resolve one legal thinking/effort pair without exposing `off` as a wire effort. */ -function resolveThinking(options: GenerateOptions, defaults: RequestDefaults): ResolvedThinking { - if (options.purpose === 'session-title') return { thinking: 'disabled' } - const effort = options.reasoningEffort === undefined - ? defaults.reasoningEffort - : reasoningEffort(options.reasoningEffort) - if (defaults.thinking === 'disabled' && effort !== undefined && effort !== 'off') { - throw new LlmError( - `DeepSeek deployment does not support reasoning effort "${effort}"`, - 'UNSUPPORTED_REASONING_EFFORT', - ) - } - if (effort === 'off') return { thinking: 'disabled' } - if (effort === 'low' || effort === 'high' || effort === 'max') { - return { thinking: 'enabled', reasoningEffort: effort } - } - return defaults.thinking === undefined ? {} : { thinking: defaults.thinking } -} - -/** Join the text blocks of a message (used for user and tool-role content). */ -function flattenText(blocks: readonly ContentBlock[]): string { - return blocks - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') -} - -/** Reject core image content before any text-flattening path can silently erase it. */ -function assertTextOnly(blocks: readonly ContentBlock[]): void { - if (contentHasImage(blocks)) { - throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT') - } -} - -/** Reject developer history before text serialization or image offloading. */ -function assertSupportedRole(message: RequestMessage): void { - // Developer history is persisted for V4; provider serialization is intentionally deferred. - if (message.role === 'developer') throw new LlmError('Developer messages are not supported yet', 'UNSUPPORTED_CONTENT') - if (message.content.some(block => block.type === 'tool-addition' || block.type === 'tool-removal')) { - throw new LlmError('Tool-change blocks require developer role', 'UNSUPPORTED_CONTENT') - } -} - -/** Reject unsupported roles, tool-change blocks, and image roles before history conversion. */ -function assertSupportedHistory(messages: readonly RequestMessage[]): void { - for (const message of messages) { - assertSupportedRole(message) - if (message.role !== 'user' && message.role !== 'tool' && contentHasImage(message.content)) { - throw new LlmError( - `The DeepSeek chat-completions adapter cannot represent image content in a ${message.role} message.`, - 'UNSUPPORTED_CONTENT', - ) - } - } -} - -/** Describe the exact request preview and its model-callable coordinate system. */ -function imageHandle( - ref: ImageAttachmentRef, - version: RequestImageAttachment, - resolveAccess: ImageAttachmentAccessResolver | undefined, - precededByContent: boolean, -): WireTextContentPart { - return { - type: 'text', - text: `${precededByContent ? '\n' : ''}${requestImageHandleText(ref, version, resolveAccess?.(ref))}`, - } -} - -/** Resolve one durable image into its descriptor and transient DeepSeek image part. */ -async function imageParts( - block: Extract, - images: ImageSerializationOptions, - location: ImageWireLocation, - precededByContent: boolean, -): Promise<[WireTextContentPart, WireImageContentPart]> { - const version = images.requestImages.get(block.attachment.attachmentId) - if (version === undefined) { - throw new LlmError( - `DeepSeek request image ${block.attachment.attachmentId} was not prepared.`, - 'INVALID_REQUEST', - ) - } - const image: WireImageContentPart = images.representation.kind === 'file' - ? { type: 'file', file_id: await images.representation.resolveFileId(version, block, location) } - : { - type: 'image_url', - image_url: { url: `data:${version.mediaType};base64,${Buffer.from(version.data).toString('base64')}` }, - } - return [imageHandle(block.attachment, version, images.resolveImageAccess, precededByContent), image] -} - -/** Convert one message's content blocks into ordered wire parts. */ -async function contentParts( - blocks: readonly ContentBlock[], - images: ImageSerializationOptions, - message: number, - nextImage: { value: number }, -): Promise { - const parts: WireUserContentPart[] = [] - for (const block of blocks) { - switch (block.type) { - case 'text': - if (block.text.length > 0) parts.push({ type: 'text', text: block.text }) - break - case 'image': - nextImage.value += 1 - parts.push(...await imageParts(block, images, { message, image: nextImage.value }, parts.length > 0)) - break - default: - // Other merge-extensible blocks are not DeepSeek user-input vocabulary. - break - } - } - return parts -} - -/** Keep text-only user messages on the compact string wire form. */ -function userContent(parts: readonly WireUserContentPart[]): string | WireUserContentPart[] { - const text: string[] = [] - for (const part of parts) { - if (part.type !== 'text') return [...parts] - text.push(part.text) - } - return text.join('') -} - -/** Serialize one assistant message (text + reasoning + tool calls). */ -function serializeAssistant(message: Message): WireMessage { - const text = flattenText(message.content) - const reasoning = message.content - .filter(block => block.type === 'reasoning') - .map(block => block.text) - .join('') - const toolCalls = message.content - .filter(block => block.type === 'tool-call') - .map(block => ({ - id: block.id, - type: 'function' as const, - function: { name: block.name, arguments: block.arguments }, - })) - - return { - role: 'assistant', - // Text-less turns send "" — NEVER null. Pure tool-call turns: the - // official samples replay message.content verbatim (which is "") and - // some gateways reject null outright. Reasoning-ONLY turns (the model - // can answer entirely in the reasoning channel, e.g. a v4-flash - // greeting): the live API rejects null-content/no-tool_calls assistant - // messages with a 400 ("content or tool_calls must be set"), and since - // the message sits durably in the session log, a null here bricks every - // later turn of that session. - content: text, - // CoT passback on every reasoning-carrying turn. The official rule - // (guides/thinking_mode.mdx) requires it on tool-call turns and ignores it - // elsewhere; a gateway re-encoding the conversation for another vendor - // recovers that turn's upstream thinking signature by hashing this exact - // text, which a tool-call-free turn carries nowhere else. - ...reasoning.length > 0 ? { reasoning_content: reasoning } : {}, - ...toolCalls.length > 0 ? { tool_calls: toolCalls } : {}, - } -} - -/** - * Serialize the conversation. Tool-role messages map one-to-one onto - * `{role: 'tool'}` wire messages; user messages contribute their text as a - * plain `{role: 'user'}` wire message. - * @param messages - the harness conversation, in order. - * @returns the wire messages; order preserved. - */ -export function serializeMessages(messages: RequestMessage[]): WireMessage[] { - const wire: WireMessage[] = [] - for (const message of messages) { - assertSupportedRole(message) - assertTextOnly(message.content) - if (message.role === 'system') { - wire.push({ role: 'system', content: flattenText(message.content) }) - continue - } - if (message.role === 'assistant') { - wire.push(serializeAssistant(message)) - continue - } - if (message.role === 'tool') { - wire.push({ - role: 'tool', - tool_call_id: message.toolCallId, - // Empty tool output still needs SOME content on the wire. - content: flattenText(message.content) || '(no output)', - }) - continue - } - // User text always lands on the wire, including empty ones. - wire.push({ role: 'user', content: flattenText(message.content) }) - } - return wire -} - -/** - * Serialize image-capable history after resolving durable attachments. - * Consecutive tool messages keep string `tool` wire messages and share one - * following user message containing their images. - * @param messages - request history whose offloaded occurrences are already placeholder text. - * @param images - prepared request versions, one provider representation, and its budget. - * @returns ordered DeepSeek wire messages. - */ -export async function serializeMessagesWithImages( - messages: readonly RequestMessage[], - images: ImageSerializationOptions, -): Promise { - assertSupportedHistory(messages) - const wire: WireMessage[] = [] - let pendingToolImages: WireImageContentPart[] = [] - const flushToolImages = (): void => { - if (pendingToolImages.length === 0) return - wire.push({ - role: 'user', - content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages], - }) - pendingToolImages = [] - } - - for (const [messageIndex, message] of messages.entries()) { - const nextImage = { value: 0 } - if (message.role === 'system') { - flushToolImages() - wire.push({ role: 'system', content: flattenText(message.content) }) - continue - } - if (message.role === 'assistant') { - flushToolImages() - wire.push(serializeAssistant(message)) - continue - } - if (message.role === 'tool') { - const parts = await contentParts(message.content, images, messageIndex + 1, nextImage) - const imageParts = parts.filter((part): part is WireImageContentPart => part.type !== 'text') - const text = parts.filter(part => part.type === 'text').map(part => part.text).join('') - wire.push({ - role: 'tool', - tool_call_id: message.toolCallId, - content: text || '(no output)', - }) - pendingToolImages.push(...imageParts) - continue - } - // User text serializes as a user message, including empty content. This - // keeps the image-capable route aligned with the text-only route. - const content = userContent(await contentParts(message.content, images, messageIndex + 1, nextImage)) - flushToolImages() - wire.push({ role: 'user', content }) - } - flushToolImages() - return wire -} - -/** Assemble request fields shared by text-only and image-capable conversion. */ -function requestWithMessages( - options: GenerateOptions, - messages: WireMessage[], - defaults: RequestDefaults, -): WireRequest { - // Deferred definitions are persisted for V4; provider loading is intentionally deferred. - if (options.tools?.some(tool => tool.deferLoading === true)) { - throw new LlmError('Deferred tool loading is not supported yet', 'UNSUPPORTED_CONTENT') - } - const tools: WireTool[] | undefined = options.tools?.map(tool => ({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - }, - })) - const resolvedThinking = resolveThinking(options, defaults) - return { - model: options.model, - messages, - stream: true, - stream_options: { include_usage: true }, - ...resolvedThinking.thinking !== undefined ? { thinking: { type: resolvedThinking.thinking } } : {}, - ...resolvedThinking.reasoningEffort !== undefined - ? { reasoning_effort: resolvedThinking.reasoningEffort } - : {}, - ...tools !== undefined && tools.length > 0 ? { tools } : {}, - ...options.temperature !== undefined ? { temperature: options.temperature } : {}, - ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }, - ...options.stop !== undefined ? { stop: options.stop } : {}, - } -} - -/** - * Build the full wire request. Always streaming (`stream: true`, usage - * reporting on); optional fields are omitted rather than sent as null, so - * provider defaults apply. - * @param options - the harness request (model, history, system, tools, sampling). - * @param defaults - adapter-level thinking defaults; undefined fields put nothing on the wire. - * @returns the chat-completions request body. - */ -export function serializeRequest( - options: GenerateOptions, - defaults: RequestDefaults = {}, -): WireRequest { - const messages: WireMessage[] = [] - if (options.system !== undefined) { - messages.push({ role: 'system', content: options.system }) - } - messages.push(...serializeMessages(options.messages)) - - return requestWithMessages(options, messages, defaults) -} - -/** - * Reject a request whose retained occurrences, at their exact request-version - * byte lengths under this representation, still exceed the route budget. The - * failure names how many more oldest retained occurrences need durable - * omission before the request can be retried. - */ -function assertRetainedImagesFit(messages: readonly RequestMessage[], images: ImageSerializationOptions): void { - const representation = images.representation.kind === 'file' ? 'raw' : 'base64' - const offloadImages = requiredImageOffload(messages, { - representation, - maxBytes: images.maxRequestImageBytes, - ...images.maxImagesPerRequest === undefined ? {} : { maxImages: images.maxImagesPerRequest }, - ...images.byteQuantum === undefined ? {} : { byteQuantum: images.byteQuantum }, - ...images.countQuantum === undefined ? {} : { countQuantum: images.countQuantum }, - }, (block) => { - const version = images.requestImages.get(block.attachment.attachmentId) - if (version === undefined) { - throw new LlmError(`DeepSeek request image ${block.attachment.attachmentId} was not prepared.`, 'INVALID_REQUEST') - } - return version.bytes - }) - if (offloadImages > 0) { - throw new LlmError( - `DeepSeek ${representation} request images exceed the route budget; ${offloadImages} more oldest occurrence(s) must be offloaded.`, - IMAGE_OFFLOAD_REQUIRED_CODE, - { offloadImages }, - ) - } -} - -/** - * Build one image-capable request while keeping durable bytes out of session - * messages. Offloaded occurrences become per-image text; retained occurrences - * must fit the route budget at their exact request-version byte lengths. - * @param options - harness request containing image-capable user content. - * @param images - request versions, optional current access resolver, and request bounds. - * @param defaults - adapter-level thinking defaults. - * @returns the fully materialized DeepSeek request body. - */ -export async function serializeRequestWithImages( - options: GenerateOptions, - images: ImageSerializationOptions, - defaults: RequestDefaults = {}, -): Promise { - assertSupportedHistory(options.messages) - assertRetainedImagesFit(options.messages, images) - const requestMessages = projectOffloadedImages( - options.messages, - ref => offloadedImageText(ref, images.resolveImageAccess?.(ref)), - ) - const messages: WireMessage[] = [] - if (options.system !== undefined) { - messages.push({ role: 'system', content: options.system }) - } - messages.push(...await serializeMessagesWithImages(requestMessages, images)) - return requestWithMessages(options, messages, defaults) -} diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/sse.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/sse.ts deleted file mode 100644 index 9126a18940..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/sse.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Decode an SSE byte stream into event `data` payloads. Framing — chunk - * reassembly, UTF-8/CRLF/BOM handling, comment and non-data field skipping, - * multi-`data:` joining — is `eventsource-parser`'s. Comments are reported - * only through an optional transport-activity callback. This module keeps the - * DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns final - * flushing, and EOF before it raises {@link LlmError}. Framing is spec-strict: - * an event dispatches only on its blank-line terminator, so an unterminated - * tail at EOF is truncation, not a flushable payload. - * - * @module dsh-llm-deepseek/sse - */ - -import { EventSourceParserStream } from 'eventsource-parser/stream' -import { LlmError } from '@deepseek-ai/dsh-llm' - -/** The terminal payload DeepSeek (and OpenAI) send after the last chunk. */ -export const DONE = '[DONE]' - -/** - * Parse an SSE byte stream into data payloads. Yields `[DONE]` as the final - * value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends - * without it (truncated response — the model call cannot be trusted). - * @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence. - * @param onComment - optional transport-activity callback; comments never enter the yielded payload stream. - * @returns each event's data payload in arrival order, the `[DONE]` sentinel last. - */ -export async function* parseSse( - stream: ReadableStream, - onComment?: (comment: string) => void, -): AsyncGenerator { - const events = stream - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new EventSourceParserStream({ onComment })) - for await (const { data } of events) { - yield data - if (data === DONE) return - } - throw new LlmError('SSE stream ended without [DONE]', 'STREAM_CLOSED') -} diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/translate.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/translate.ts deleted file mode 100644 index 8dd939e547..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/translate.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Translate DeepSeek SSE payloads with one stateful harness block per content, reasoning, or tool - * call index. An empty initial reasoning delta does not open a block. Finish reason and the latest - * usage are deferred until `[DONE]`, covering both finish-attached and trailing usage-only shapes - * while ensuring no chunk follows `finish`. - * - * Translate DeepSeek wire chunks into the harness `StreamChunk` protocol. - * @module dsh-llm-deepseek/translate - */ - -import { brandString } from '@deepseek-ai/dsh-brand' -import { EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, FinishReason, StreamChunk, TokenUsage, ToolCallId } from '@deepseek-ai/dsh-llm' -import { DONE } from './sse.ts' -import type { WireChunk, WireUsage } from './types.ts' - -/** One open block under assembly. */ -interface OpenBlock { - index: number - kind: 'text' | 'reasoning' | 'tool-call' - text: string - /** tool-call only, absent until a delta carries a non-empty value. */ - callId?: string | undefined - name?: string | undefined -} - -/** - * Map the wire finish_reason vocabulary to the harness FinishReason. - * @param reason - the wire `finish_reason` string. - * @returns the mapped reason; unrecognized values (content_filter, …) become `{kind: 'error'}` with the uppercased value as `code`. - */ -export function mapFinishReason(reason: string): FinishReason { - switch (reason) { - case 'stop': return { kind: 'stop' } - case 'tool_calls': return { kind: 'tool-calls' } - case 'length': return { kind: 'max-tokens' } - default: - // content_filter, insufficient_system_resource, future additions. - return { - kind: 'error', - failure: { message: `model stopped: ${reason}`, code: reason.toUpperCase() }, - } - } -} - -/** - * Map wire usage fields. DeepSeek's `prompt_tokens` INCLUDES cache hits - * (`prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens`, - * api/create-chat-completion); the harness TokenUsage convention is - * DISJOINT counts, so cache reads are subtracted out of `inputTokens`. - * @param usage - wire usage from the finish chunk or the trailing usage-only chunk. - * @returns disjoint harness counts; an exact total is present only when the - * aggregate prompt/completion counters are valid and agree with any wire total. - */ -export function mapUsage(usage: WireUsage): TokenUsage { - const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens - const reasoning = usage.completion_tokens_details?.reasoning_tokens - const combined = usage.prompt_tokens + usage.completion_tokens - const hasExactTotal = Number.isSafeInteger(usage.prompt_tokens) - && usage.prompt_tokens >= 0 - && Number.isSafeInteger(usage.completion_tokens) - && usage.completion_tokens >= 0 - && Number.isSafeInteger(combined) - && (usage.total_tokens === undefined || usage.total_tokens === combined) - return { - inputTokens: usage.prompt_tokens - (cacheRead ?? 0), - outputTokens: usage.completion_tokens, - ...hasExactTotal ? { totalTokens: combined } : {}, - ...cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}, - ...reasoning !== undefined ? { reasoningTokens: reasoning } : {}, - } -} - -/** - * Accept one streamed identity field for a tool call. `id` and `name` are - * identity, not accumulation: the wire sends each once, on the call's first - * delta. A continuation delta that re-sends the field empty — or `null`, which - * some OpenAI-compatible gateways fill in — means "no update", never "clear". - * @param current - the identity established by an earlier delta of this call. - * @param incoming - the field as parsed from this delta. The wire type is a - * claim about a remote encoder, so anything but a non-empty string leaves the - * established value alone rather than overwriting it. - * @returns the identity in force after this delta. - */ -function acceptIdentity(current: string | undefined, incoming: unknown): string | undefined { - return typeof incoming === 'string' && incoming.length > 0 ? incoming : current -} - -/** Assemble the final ContentBlock for one open block. */ -function closeBlock(block: OpenBlock): ContentBlock { - switch (block.kind) { - case 'text': return { type: 'text', text: block.text } - case 'reasoning': return { type: 'reasoning', text: block.text } - case 'tool-call': return { - type: 'tool-call', - id: brandString(block.callId ?? ''), - name: block.name ?? '', - arguments: block.text, - } - } -} - -/** - * Consume SSE data payloads (ending with `[DONE]`) and yield StreamChunks. - * Malformed JSON payloads abort the stream with `MALFORMED_RESPONSE`. - * @param payloads - SSE data payloads from {@link parseSse}, `[DONE]`-terminated. - * @returns deltas as they arrive; `block-end`s, `usage`, and `finish` are all deferred to the `[DONE]` sentinel. - * A `stop` (or absent) finish with no opened blocks is a degenerate provider completion and maps to an - * `EMPTY_RESPONSE` error finish instead of a successful empty message. - */ -export async function* translate(payloads: AsyncIterable): AsyncGenerator { - let nextIndex = 0 - let textBlock: OpenBlock | undefined - let reasoningBlock: OpenBlock | undefined - const toolBlocks = new Map() - const order: OpenBlock[] = [] - let pendingFinish: FinishReason | undefined - let pendingUsage: TokenUsage | undefined - - function open(kind: OpenBlock['kind']): OpenBlock { - const block: OpenBlock = { index: nextIndex++, kind, text: '' } - order.push(block) - return block - } - - for await (const payload of payloads) { - if (payload === DONE) { - for (const block of order) { - yield { type: 'block-end', index: block.index, block: closeBlock(block) } - } - if (pendingUsage) yield { type: 'usage', usage: pendingUsage } - const reason = pendingFinish ?? { kind: 'stop' as const } - yield { - type: 'finish', - reason: reason.kind === 'stop' && order.length === 0 - ? { - kind: 'error', - failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE }, - } - : reason, - } - return - } - - let chunk: WireChunk - try { - chunk = JSON.parse(payload) as WireChunk - } catch { - throw new LlmError(`malformed SSE payload: ${payload.slice(0, 120)}`, 'MALFORMED_RESPONSE') - } - - for (const choice of chunk.choices ?? []) { - const delta = choice.delta - - // Reasoning first: thinking mode interleaves it before text. The - // empty-string first chunk must not open a block. - const reasoning = delta?.reasoning_content - if (typeof reasoning === 'string' && reasoning.length > 0) { - if (!reasoningBlock) { - reasoningBlock = open('reasoning') - yield { type: 'block-start', index: reasoningBlock.index, blockType: 'reasoning' } - } - reasoningBlock.text += reasoning - yield { type: 'reasoning-delta', index: reasoningBlock.index, text: reasoning } - } - - const content = delta?.content - if (typeof content === 'string' && content.length > 0) { - if (!textBlock) { - textBlock = open('text') - yield { type: 'block-start', index: textBlock.index, blockType: 'text' } - } - textBlock.text += content - yield { type: 'text-delta', index: textBlock.index, text: content } - } - - for (const call of delta?.tool_calls ?? []) { - let block = toolBlocks.get(call.index) - if (!block) { - block = open('tool-call') - toolBlocks.set(call.index, block) - yield { type: 'block-start', index: block.index, blockType: 'tool-call' } - } - block.callId = acceptIdentity(block.callId, call.id) - block.name = acceptIdentity(block.name, call.function?.name) - const fragment = call.function?.arguments ?? '' - block.text += fragment - yield { - type: 'tool-call-delta', - index: block.index, - id: brandString(block.callId ?? ''), - ...block.name !== undefined ? { name: block.name } : {}, - argumentsDelta: fragment, - } - } - - if (typeof choice.finish_reason === 'string') { - pendingFinish = mapFinishReason(choice.finish_reason) - } - } - - // Usage may arrive attached to the finish chunk or as a trailing - // usage-only chunk — keep the latest. - if (chunk.usage) pendingUsage = mapUsage(chunk.usage) - } - - // parseSse guarantees the [DONE] sentinel (or throws); reaching here means - // the payload source violated that contract. - throw new LlmError('SSE payload stream ended without [DONE]', 'STREAM_CLOSED') -} diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/types.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/types.ts deleted file mode 100644 index 38e73ed39e..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/types.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * DeepSeek chat-completions wire format (OpenAI-compatible). Types only. - * - * Source of truth: the official API docs at - * `~/repos/deepsuite-docs/apps/docs/docs` (api/create-chat-completion, - * guides/thinking_mode.mdx, guides/tool_calls.md), cross-checked against - * live streams from the internal endpoint (2026-06). - * - * @module dsh-llm-deepseek/types - */ - -/** Request body for `POST {baseURL}/chat/completions`. */ -export interface WireRequest { - model: string - messages: WireMessage[] - stream: true - stream_options: { include_usage: true } - /** Thinking-mode toggle (top level, NOT inside extra_body on the wire). */ - thinking?: { type: 'enabled' | 'disabled' } - /** Thinking effort (official levels). */ - reasoning_effort?: 'low' | 'high' | 'max' - tools?: WireTool[] - temperature?: number - max_tokens?: number - /** - * Stop sequences (OpenAI `stop`): generation halts as soon as the model - * produces any one of these strings. Mapped from `GenerateOptions.stop`. - */ - stop?: string[] -} - -/** System-role message: a single string of instructions. */ -export interface WireSystemMessage { - role: 'system' - content: string -} - -/** Text part inside a multimodal user message. */ -export interface WireTextContentPart { - type: 'text' - text: string -} - -/** Files API reference inside a multimodal user message. */ -export interface WireFileContentPart { - type: 'file' - file_id: string -} - -/** Inline base64 data URL inside a multimodal user message. */ -export interface WireImageUrlContentPart { - type: 'image_url' - image_url: { url: string } -} - -/** One image representation accepted by a multimodal user message. */ -export type WireImageContentPart = WireFileContentPart | WireImageUrlContentPart - -/** Ordered input part accepted by a multimodal user message. */ -export type WireUserContentPart = WireTextContentPart | WireImageContentPart - -/** User-role message: text-only string or ordered multimodal input. */ -export interface WireUserMessage { - role: 'user' - content: string | WireUserContentPart[] -} - -/** Tool-role message: the result of one tool call, keyed by its call id. */ -export interface WireToolMessage { - role: 'tool' - tool_call_id: string - content: string -} - -/** One entry of the request `messages` array, discriminated on `role`. */ -export type WireMessage = - | WireSystemMessage - | WireUserMessage - | WireAssistantMessage - | WireToolMessage - -/** - * Assistant-role history message. The harness replays `content: ""` (never - * null) on tool-call-only turns — some gateways reject null — and sends null - * only when the turn carried neither text nor tool calls. - */ -export interface WireAssistantMessage { - role: 'assistant' - content: string | null - /** - * CoT passback, present on every turn whose assistant content carried - * reasoning. REQUIRED on tool-call turns in thinking mode (see - * guides/thinking_mode.mdx § Tool Calls); DeepSeek ignores it elsewhere, - * while a gateway re-encoding for another vendor recovers that turn's - * thinking signature by hashing it. - */ - reasoning_content?: string - tool_calls?: WireToolCall[] -} - -/** A completed tool call replayed on an assistant history message; `arguments` is the raw JSON string. */ -export interface WireToolCall { - id: string - type: 'function' - function: { name: string; arguments: string } -} - -/** One entry of the request `tools` array; `parameters` is a JSON Schema object. */ -export interface WireTool { - type: 'function' - function: { - name: string - description: string - parameters: Record - } -} - -/** One parsed SSE `data:` payload (a chat.completion.chunk). */ -export interface WireChunk { - choices?: WireChoice[] - /** Arrives attached to the finish chunk and/or as a trailing usage-only chunk. */ - usage?: WireUsage | null -} - -/** One streamed choice (requests always ask for a single one); `finish_reason` is non-null only on its terminal chunk. */ -export interface WireChoice { - delta?: WireDelta - finish_reason?: string | null -} - -/** The incremental content of one streamed choice; any subset of fields may be present per chunk. */ -export interface WireDelta { - role?: string - /** Visible text. Null/empty on reasoning/tool-call chunks. */ - content?: string | null - /** - * Thinking-mode CoT. The FIRST chunk carries an empty string (must not - * open a reasoning block); absent entirely in non-thinking mode. - */ - reasoning_content?: string | null - tool_calls?: WireToolCallDelta[] -} - -/** A streamed fragment of one tool call; fragments sharing an `index` concatenate into one call. */ -export interface WireToolCallDelta { - /** Disambiguates parallel tool calls; stable across a call's deltas. */ - index: number - /** - * Carried by the first delta of each call. Gateways observed in the wild - * repeat it on continuation deltas as `''` or `null`; both mean "unchanged". - */ - id?: string | null - type?: 'function' - function?: { - /** Carried by the first delta of each call, with the same `''`/`null` repetition as {@link WireToolCallDelta.id}. */ - name?: string | null - /** Argument JSON fragment (concatenate across deltas). */ - arguments?: string | null - } -} - -/** - * Wire token accounting. `prompt_tokens` INCLUDES cache hits (it equals - * `prompt_cache_hit_tokens + prompt_cache_miss_tokens`); `mapUsage` subtracts - * them to keep the harness convention of disjoint counts. - * `prompt_tokens_details.cached_tokens` is the OpenAI-compat spelling of the - * hit count. - */ -export interface WireUsage { - prompt_tokens: number - completion_tokens: number - /** Provider-reported aggregate across prompt and completion tokens. */ - total_tokens?: number - prompt_cache_hit_tokens?: number - prompt_cache_miss_tokens?: number - prompt_tokens_details?: { cached_tokens?: number } - completion_tokens_details?: { reasoning_tokens?: number } -} - -/** Non-2xx error body. */ -export interface WireError { - error?: { message?: string; type?: string; code?: string } -} diff --git a/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts b/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts deleted file mode 100644 index e5304b8380..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** Direct Messages transport with one cancellable lifecycle per model request. */ - -import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, ImageAttachmentAccessResolver, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm' -import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import type { DeepSeekLlmApiJson } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { catalogModelInfo, modelInfo } from '../../common/model-info.ts' -import type { DeepSeekAdapterOptions, DeepSeekConnectionOptions as Connection } from '../../common/types.ts' -import type { DeepSeekFileStore } from '../../common/file-store.ts' -import { MESSAGES_FILES_BETA, messagesApiRoot } from '../../common/messages-api.ts' -import { FileResolutionFailure, RequestFiles } from '../../common/request-files.ts' -import { prepareRequestExtensions } from '../../common/request-extensions.ts' -import { imagePricing, inlineImages, prepareFileIds, prepareImages } from './images.ts' -import { serialize } from './serialize.ts' -import { parseSse } from './sse.ts' -import { translate } from './translate.ts' -import { providerError, providerErrorDetail } from './transport.ts' - -/** Request-local dependencies supplied by the owning Cordis plugin. */ -export interface AdapterDependencies { - /** Resolve a single validated configuration generation. */ - connection(): Connection - /** Resolve the key named by that same generation. */ - apiKey(connection: Connection): Promise - /** Stable anonymous Harness identity. */ - userId(): string - /** Current attachment service; absence is valid for text requests. */ - attachments(): AttachmentStore | undefined - /** Current execution-world attachment path. */ - imageAccess: ImageAttachmentAccessResolver - /** Process-wide Files upload reuse and recovery. */ - files(): DeepSeekFileStore - /** Prepare plugin-contributed fields for this exact HTTP request. */ - prepareExtensions: DeepSeekAdapterOptions['prepareExtensions'] - /** Report discarded replay metadata without exposing durable content or signatures. */ - onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void -} - -/** DeepSeek provider using Messages content and native thinking replay. */ -export class DeepSeekMessagesAdapter extends LlmAdapter { - constructor(private readonly dependencies: AdapterDependencies) { super() } - - override providerInfo(provider: string) { return { id: provider, name: 'DeepSeek' } } - override providerRetryPolicy(_provider: string) { return this.dependencies.connection().retryPolicy } - override listModels(provider: string) { - const connection = this.dependencies.connection() - return Promise.resolve(connection.models.map(model => catalogModelInfo(provider, model))) - } - override resolveModel(provider: string, model: string) { - return Promise.resolve(modelInfo(this.dependencies.connection(), provider, model)) - } - override imageRequestPricing(_provider: string, model: string) { - return imagePricing(this.dependencies.connection(), model, this.dependencies.imageAccess) - } - override prepareCall(provider: string, model: string): Promise { - const connection = this.dependencies.connection() - return Promise.resolve({ model: modelInfo(connection, provider, model), stream: options => this.generate(options, connection) }) - } - stream(options: GenerateOptions): AsyncIterable { - return this.generate(options, this.dependencies.connection()) - } - - private async * generate(options: GenerateOptions, connection: Connection): AsyncGenerator { - const consumer = new AbortController() - const signal = options.signal === undefined ? consumer.signal : AbortSignal.any([consumer.signal, options.signal]) - using watchdog = idleWatchdog(signal, connection.streamIdleTimeoutMs, 'MESSAGES_IDLE') - const iterator = this.request(options, connection, watchdog.signal, () => { watchdog.pulse() }) - try { - while (true) { - const next = await watchdog.next(iterator) - if (next.done) return - yield next.value - } - } catch (error) { - if (timeoutOf(watchdog.signal, 'MESSAGES_IDLE') !== undefined) throw new LlmError('DeepSeek Messages stream idle timeout', 'TIMEOUT', { cause: error }) - if (options.signal?.aborted) throw new LlmError('DeepSeek Messages request aborted', 'ABORTED', { cause: error }) - if (error instanceof LlmError) throw error - throw new LlmError('DeepSeek Messages transport failed', 'TRANSPORT', { cause: error }) - } finally { - consumer.abort() - try { await iterator.return(undefined) } catch (_abortedRequestCleanup) { - // The request already settled; aborting its reader cannot replace that outcome. - } - } - } - - private async * request( - options: GenerateOptions, connection: Connection, signal: AbortSignal, activity: () => void, - ): AsyncGenerator { - signal.throwIfAborted() - const { messages, versions } = await prepareImages( - options.messages, connection, options.model, this.dependencies.attachments(), this.dependencies.imageAccess, signal, - ) - const key = await this.dependencies.apiKey(connection) - const files = new RequestFiles(this.dependencies.files(), { baseURL: connection.baseURL, apiKey: key, protocol: 'messages' }, - connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) - let inline = false - while (true) { - signal.throwIfAborted() - files.beginAttempt() - let fileIds: Awaited> | undefined - if (!inline) { - try { - fileIds = await prepareFileIds(messages, versions, files) - } catch (error) { - if (!(error instanceof FileResolutionFailure)) throw error - inline = true - continue - } - } - const history = inline ? inlineImages(messages, versions, connection) : messages - const body = serialize(options, connection, history, versions, this.dependencies.imageAccess, (reason) => { - this.dependencies.onReplayDegrade?.({ provider: options.provider, model: options.model, reason }) - }, fileIds) - const extensions = await prepareRequestExtensions(body as unknown as Readonly>, { - signal, - ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, - ...options.purpose === undefined ? {} : { purpose: options.purpose }, - }, this.dependencies.prepareExtensions) - signal.throwIfAborted() - const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, { - method: 'POST', signal, body: extensions.payload, redirect: 'error', - headers: { - ...attributionHeaders(), - 'content-type': 'application/json', 'accept': 'text/event-stream', - 'x-api-key': key, 'anthropic-version': '2023-06-01', - ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, - 'x-deepseek-harness-user-id': this.dependencies.userId(), - ...options.sessionId === undefined ? {} : { 'x-deepseek-harness-session-id': String(options.sessionId) }, - ...options.purpose === 'compaction' ? { 'x-deepseek-harness-compact': '1' } : {}, - }, - }) - if (!response.ok) { - const text = await response.text() - let raw: unknown - try { raw = JSON.parse(text) } catch (_nonJsonGatewayError) { - // HTTP status is authoritative when a gateway does not return JSON. - } - const detail = providerErrorDetail(raw) - if (await files.retry(detail)) continue - const failure = providerError(raw, response.status, response.headers) - const message = files.errorMessage(response.status, failure.message, detail) - throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) }) - } - await extensions.accept() - if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE') - yield* translate(parseSse(response.body, activity), options.model) - return - } - } -} diff --git a/packages/llm/llm-deepseek/src/protocols/messages/replay.ts b/packages/llm/llm-deepseek/src/replay.ts similarity index 93% rename from packages/llm/llm-deepseek/src/protocols/messages/replay.ts rename to packages/llm/llm-deepseek/src/replay.ts index d6ff95804b..f285ebaeae 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/replay.ts +++ b/packages/llm/llm-deepseek/src/replay.ts @@ -56,9 +56,9 @@ function validateReplay(message: Message, model: string): ReplayBlock[] | undefi const blocks = envelope.blocks.map((value, index): ReplayBlock => { const block = object(value, 'INVALID_REPLAY_STATE') if (block.type !== message.content[index]?.type - || !['text', 'reasoning', 'tool-call'].includes(String(block.type))) return fail('block type mismatch') + || (block.type !== 'text' && block.type !== 'reasoning' && block.type !== 'tool-call')) return fail('block type mismatch') if (block.signature !== undefined && (block.type !== 'reasoning' || typeof block.signature !== 'string')) return fail('invalid signature') - return block as unknown as ReplayBlock + return { type: block.type, ...typeof block.signature === 'string' ? { signature: block.signature } : {} } }) return response.model === model ? blocks : undefined } diff --git a/packages/llm/llm-deepseek/src/common/request-extensions.ts b/packages/llm/llm-deepseek/src/request-extensions.ts similarity index 87% rename from packages/llm/llm-deepseek/src/common/request-extensions.ts rename to packages/llm/llm-deepseek/src/request-extensions.ts index 906e778382..e1cb10febb 100644 --- a/packages/llm/llm-deepseek/src/common/request-extensions.ts +++ b/packages/llm/llm-deepseek/src/request-extensions.ts @@ -5,9 +5,9 @@ import type { DeepSeekLlmApiExtensionRequest, PreparedDeepSeekLlmApiExtensions } import type { DeepSeekAdapterOptions } from './types.ts' /** - * Merge contributions without replacing protocol-owned fields. Preparation and - * acceptance failures retain the same error category across DeepSeek protocols. - * @param body - serialized protocol request before extension fields. + * Merge contributions without replacing Messages fields. Preparation and + * acceptance failures report REQUEST_EXTENSION. + * @param body - serialized Messages request before extension fields. * @param options - request identity, purpose, and cancellation. * @param prepare - contributor registry captured for this adapter. * @returns HTTP payload and a commit to invoke only after a successful HTTP response. diff --git a/packages/llm/llm-deepseek/src/common/request-files.ts b/packages/llm/llm-deepseek/src/request-files.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/request-files.ts rename to packages/llm/llm-deepseek/src/request-files.ts diff --git a/packages/llm/llm-deepseek/src/common/request-pricing.ts b/packages/llm/llm-deepseek/src/request-pricing.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/request-pricing.ts rename to packages/llm/llm-deepseek/src/request-pricing.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts similarity index 98% rename from packages/llm/llm-deepseek/src/protocols/messages/serialize.ts rename to packages/llm/llm-deepseek/src/serialize.ts index 78448e6e51..a78767df7d 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -3,10 +3,10 @@ import { LlmError, requestImageHandleText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message, RequestMessage } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import type { DeepSeekConnectionOptions as Connection } from '../../common/types.ts' -import type { DeepSeekFileId } from '../../common/file-id.ts' +import type { DeepSeekConnectionOptions as Connection } from './types.ts' +import type { DeepSeekFileId } from './file-id.ts' import { readReplay } from './replay.ts' -import type { WireBlock, WireInput, WireMessage, WireRequest } from './types.ts' +import type { WireBlock, WireInput, WireMessage, WireRequest } from './wire-types.ts' function unsupported(type: string): never { throw new LlmError(`DeepSeek Messages cannot represent ${type}`, 'UNSUPPORTED_CONTENT') diff --git a/packages/llm/llm-deepseek/src/protocols/messages/sse.ts b/packages/llm/llm-deepseek/src/sse.ts similarity index 100% rename from packages/llm/llm-deepseek/src/protocols/messages/sse.ts rename to packages/llm/llm-deepseek/src/sse.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/translate.ts b/packages/llm/llm-deepseek/src/translate.ts similarity index 100% rename from packages/llm/llm-deepseek/src/protocols/messages/translate.ts rename to packages/llm/llm-deepseek/src/translate.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/transport.ts b/packages/llm/llm-deepseek/src/transport.ts similarity index 100% rename from packages/llm/llm-deepseek/src/protocols/messages/transport.ts rename to packages/llm/llm-deepseek/src/transport.ts diff --git a/packages/llm/llm-deepseek/src/common/types.ts b/packages/llm/llm-deepseek/src/types.ts similarity index 92% rename from packages/llm/llm-deepseek/src/common/types.ts rename to packages/llm/llm-deepseek/src/types.ts index d1c4932317..180227f484 100644 --- a/packages/llm/llm-deepseek/src/common/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -1,4 +1,4 @@ -/** Shared catalog and request-local dependencies for DeepSeek protocols. */ +/** Model catalog and request-local dependencies for DeepSeek Messages. */ import type { ModelModality, SystemPromptUpdate, ResolvedRetryPolicy, ImageAttachmentAccess } from '@deepseek-ai/dsh-llm' import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' @@ -6,9 +6,6 @@ import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import type { DeepSeekLlmApiExtensionRequest, PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import type { DeepSeekFileStore, DeepSeekFilePolicy } from './file-store.ts' -/** Supported wire implementations; Responses is not yet implemented. */ -export type DeepSeekProtocol = 'chat-completions' | 'messages' - /** One optional model entry advertised by the direct-fetch adapter. */ export interface DeepSeekCatalogModel { /** Wire model id accepted by the configured endpoint. */ @@ -46,9 +43,7 @@ export interface DeepSeekCatalogModel { * makes a configuration change reach the next request without re-registration. */ export interface DeepSeekConnectionOptions { - /** Wire protocol selected by plugin configuration. */ - protocol: DeepSeekProtocol - /** Root compatible with the selected protocol; custom paths remain unchanged. */ + /** Messages API root; custom paths remain unchanged. */ baseURL: string /** * Credential reference of this same resolution, resolved per request. @@ -94,7 +89,7 @@ export interface DeepSeekAdapterOptions { /** Current validated connection facts; called once per operation. */ options: () => DeepSeekConnectionOptions /** - * Resolve the bearer token for the connection facts of one request. The + * Resolve the API key for the connection facts of one request. The * snapshot is passed in — never re-read — so the key can only ever come * from the same resolution as the endpoint it is sent to. Throws `LlmError` * `MISSING_CREDENTIAL` when no key is available anywhere. diff --git a/packages/llm/llm-deepseek/src/common/upload-index.ts b/packages/llm/llm-deepseek/src/upload-index.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/upload-index.ts rename to packages/llm/llm-deepseek/src/upload-index.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/types.ts b/packages/llm/llm-deepseek/src/wire-types.ts similarity index 95% rename from packages/llm/llm-deepseek/src/protocols/messages/types.ts rename to packages/llm/llm-deepseek/src/wire-types.ts index 582ffc7545..c72f0c9d47 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/types.ts +++ b/packages/llm/llm-deepseek/src/wire-types.ts @@ -13,13 +13,13 @@ export type WireBlock = WireInput | { type: 'tool_result'; tool_use_id: string; content: WireInput[]; is_error?: boolean } /** Conversation turn; capable routes retain later system updates in message history. */ -export interface WireMessage { +export type WireMessage = { role: 'user' | 'assistant' | 'system' content: WireBlock[] } /** JSON body submitted to the resolved Messages endpoint. */ -export interface WireRequest { +export type WireRequest = { model: string stream: true max_tokens: number diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 2a68e915cc..f8971f33e7 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,466 +1,280 @@ -import { readFileSync } from 'node:fs' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +/** + * Real Messages round trips use the official root and require credentials. + * System-update checks additionally require DEEPSEEK_IN_HISTORY_MODEL. + */ +import { randomUUID } from 'node:crypto' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { randomBytes } from 'node:crypto' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context, LoggerLevel } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' -import LlmRuntime, { createToolResultMessage, createUserMessage, ToolCallId, ReasoningEffortId, createMessage, createSystemMessage } from '@deepseek-ai/dsh-llm' -import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' -import AttachmentStore, { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import LocalAttachments from '@deepseek-ai/dsh-attachment-local' -import type { - ImageAttachmentLimits, - ImageAttachmentRef, - ImageRequestTarget, - RequestImageAttachment, - SaveImageAttachment, - StoredImageAttachment, -} from '@deepseek-ai/dsh-attachment' -import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import LlmRuntime, { BlockAssembler, createAssistantMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { Message } from '@deepseek-ai/dsh-llm' import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import type { Config } from '@deepseek-ai/dsh-llm-deepseek' -import type { WireMessage, WireRequest } from '../src/protocols/chat-completions/types.ts' -import { assemble, type AssembledResult } from './assemble.ts' +import * as Messages from '../src/index.ts' +import { DeepSeekFilesClient } from '../src/files-api.ts' +import { MESSAGES_FILES_BETA } from '../src/messages-api.ts' +import { assemble, options, user, sourceModuleLoader } from './helpers.ts' -/** - * Real-API e2e for the direct-fetch adapter: V4 Flash across thinking modes - * and a max-effort tool round trip with reasoning passback. The suite skips - * entirely without $DEEPSEEK_API_KEY; the pre-release vision smoke additionally - * requires $DEEPSEEK_VISION_E2E=1, and the Flash image/system-update smoke - * requires $DEEPSEEK_FLASH_E2E=1 (see vitest.e2e.config.ts). - */ - -const FLASH = 'deepseek-v4-flash' -const VISION = 'deepseek-v4-flash-vision-exp' -const VISION_E2E_ENABLED = process.env.DEEPSEEK_VISION_E2E === '1' -/** A model whose endpoint reads the latest `system` message at any position; unset skips the in-history smoke. */ const IN_HISTORY_MODEL = process.env.DEEPSEEK_IN_HISTORY_MODEL -const TEST_PNG = Uint8Array.from(readFileSync( - new URL('../../llm-pi-ai/tests/fixtures/qr-code.png', import.meta.url), -)) -const contexts: Context[] = [] -let identityHome: string - -class E2eAttachmentStore extends AttachmentStore { - readonly imageLimits: ImageAttachmentLimits = { - maxImageBytes: TEST_PNG.byteLength, - maxImagesPerMessage: 1, - maxMessageImageBytes: TEST_PNG.byteLength, - maxImagePixels: 256 * 256, - maxImageDimension: 256, - mediaTypes: ['image/png'], - } - readonly ref: ImageAttachmentRef = { - attachmentId: AttachmentId(`sha256:${randomBytes(32).toString('hex')}`), - mediaType: 'image/png', - bytes: TEST_PNG.byteLength, - width: 256, - height: 256, - name: 'files-api-e2e.png', - } - readonly version: RequestImageAttachment = { - variantId: ImageVariantId(`sha256:${randomBytes(32).toString('hex')}`), - attachment: this.ref, - data: TEST_PNG, - mediaType: 'image/png', - bytes: TEST_PNG.byteLength, - width: 256, - height: 256, - depth: 'uchar', - space: 'srgb', - hasAlpha: false, - } - - validateImage(_input: SaveImageAttachment): Promise { - return Promise.resolve() - } - - saveImage(_input: SaveImageAttachment): Promise { - return Promise.resolve(this.ref) - } - - readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { - return Promise.resolve({ ref, data: TEST_PNG }) - } - - override readImageRequest( - _ref: ImageAttachmentRef, - _target: ImageRequestTarget, - _signal?: AbortSignal, - ): Promise { - return Promise.resolve(this.version) - } -} - -beforeEach(async () => { - identityHome = await mkdtemp(join(tmpdir(), 'dsh-e2e-user-id-')) - vi.stubEnv('DSH_HOME', identityHome) +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!() + vi.unstubAllEnvs() + vi.unstubAllGlobals() }) - -async function harness(model: string, config: Partial = {}) { +async function boot(models?: Messages.Config['models']) { + const home = await mkdtemp(join(tmpdir(), 'dsh-messages-e2e-')) + cleanups.push(() => rm(home, { recursive: true, force: true })) + vi.stubEnv('DSH_HOME', home) const ctx = new Context() - contexts.push(ctx) + cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(LlmRuntime) - await ctx.plugin(E2eAttachmentStore) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: LlmDeepSeek.PUBLIC_BASE_URL, - ...model === VISION ? { models: [{ id: VISION, inputModalities: ['text', 'image'] }] } : {}, - ...config, + await ctx.plugin(Messages, { + baseURL: Messages.PUBLIC_BASE_URL, + maxTokens: 4096, + ...models === undefined ? {} : { models }, }) return ctx } +const tool = { name: 'lookup_value', description: 'Read the requested value. Always call this tool to obtain a value.', parameters: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] } } -afterEach(async () => { - await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) - vi.unstubAllGlobals() - vi.unstubAllEnvs() - await rm(identityHome, { recursive: true, force: true }) -}) - -function ask(text: string): Message[] { - return [createUserMessage({ - content: [{ type: 'text', text }], - source: { kind: 'user' }, - })] -} - -function textOf(result: AssembledResult): string { - return result.message.content - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') -} - -const weatherTool: ToolSchema = { - name: 'get_weather', - description: 'Get the current weather for a city.', - parameters: { - type: 'object', - properties: { city: { type: 'string', description: 'City name' } }, - required: ['city'], - }, -} - -describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { - it.skipIf(process.env.DEEPSEEK_FLASH_E2E !== '1')('deepseek-flash accepts images and retains system updates', async () => { - const ctx = new Context() - contexts.push(ctx) - await ctx.plugin(LlmRuntime) - await ctx.plugin(LocalAttachments) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: LlmDeepSeek.PUBLIC_BASE_URL, maxTokens: 4096 }) - const model = 'deepseek-flash' - await expect(ctx.llm.resolveModelInfo('deepseek-official', model)).resolves.toMatchObject({ - inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', - }) - const attachment = await ctx.attachments.saveImage({ data: readFileSync(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) - const message = ask('What is the dominant color of this image?')[0]! - const history: Message[] = [ - createSystemMessage('Answer with one English color word.'), - { ...message, content: [...message.content, { type: 'image', attachment }] }, - ] - const reply = async () => { - const response = await assemble(ctx, { model, messages: history, reasoningEffort: ReasoningEffortId('high') }) - expect(response.finish.kind, JSON.stringify(response.finish)).toBe('stop') +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () => { + it.skipIf(!IN_HISTORY_MODEL).each([false, true])('updates system instructions during a conversation, in-history=%s', async (inHistory) => { + const model = IN_HISTORY_MODEL as string + // Each case owns the capability, even for a model with an in-history catalog default. + const ctx = await boot([{ id: model, ...inHistory ? { systemPromptUpdate: 'in-history' as const } : {} }]) + const history: Message[] = [createSystemMessage('Reply to every user message with exactly PROMPT_FIRST.'), user('Answer now.')] + const reply = async (expected: string) => { + const saved = JSON.stringify(history) + const response = await assemble(ctx.llm.stream(options({ model, messages: history, reasoningEffort: ReasoningEffortId('high') })), model) + expect(response.assembler.finish.kind).toBe('stop') + expect(response.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain(expected) + expect(JSON.stringify(history)).toBe(saved) history.push(response.message) - return textOf(response).trim().toLowerCase() } - expect(await reply()).toMatch(/^red[.!]?$/) - history.push(createSystemMessage('Reply to every user message with exactly banana.'), ...ask('Answer now.')) - expect(await reply()).toBe('banana') - history.push(...ask('Answer again.')) - expect(await reply()).toBe('banana') + await reply('PROMPT_FIRST') + history.push(createSystemMessage('Reply to every user message with exactly PROMPT_SECOND.'), user('Answer again.')) + await reply('PROMPT_SECOND') + const withoutSystem = history.filter(message => message.role !== 'system') + history.splice(0, history.length, createSystemMessage(''), ...withoutSystem, user('Reply with exactly PROMPT_CLEARED.')) + await reply('PROMPT_CLEARED') }) - it.skipIf(!VISION_E2E_ENABLED)('uses the built-in official route to upload, reference, and delete one image', async () => { - const key = process.env.DEEPSEEK_API_KEY - if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') - const baseURL = LlmDeepSeek.PUBLIC_BASE_URL - const ctx = await harness(VISION, { baseURL }) - await ctx.plugin(E2eAttachmentStore) - const attachments = ctx.attachments as E2eAttachmentStore - let uploadedFile: LlmDeepSeek.DeepSeekFileIdType | undefined - const nativeFetch = globalThis.fetch - const observedFetch: typeof fetch = async (input, init) => { - const response = await nativeFetch(input, init) - const url = new URL(input instanceof Request ? input.url : input) - const method = init?.method ?? (input instanceof Request ? input.method : 'GET') - if (method === 'POST' && url.pathname.endsWith('/files') && response.ok) { - const value = await response.clone().json() as { id?: unknown } - if (typeof value.id === 'string') uploadedFile = LlmDeepSeek.DeepSeekFileId(value.id) + it('uploads, lists, retrieves, reuses, and replaces a deleted Files image across Messages requests', async () => { + const ctx = await boot() + await ctx.plugin(LocalAttachments) + const fetchImpl = globalThis.fetch + const uploads: string[] = [] + const bodies: string[] = [] + const files = new DeepSeekFilesClient({ + baseURL: Messages.PUBLIC_BASE_URL, apiKey: process.env.DEEPSEEK_API_KEY as string, fetch: fetchImpl, + }) + const ownedFiles = new Set>() + cleanups.push(async () => { + for (const id of ownedFiles) await files.delete(id) + }) + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + const response = await fetchImpl(input, init) + if (url === `${Messages.PUBLIC_BASE_URL}/v1/files` && init?.method === 'POST' && response.ok) { + const file = await response.clone().json() as { id: string } + uploads.push(file.id) + ownedFiles.add(Messages.DeepSeekFileId(file.id)) + } + if (url === `${Messages.PUBLIC_BASE_URL}/v1/messages`) { + expect(new Headers(init?.headers).get('anthropic-beta')).toBe(MESSAGES_FILES_BETA) + if (typeof init?.body !== 'string') throw new Error('expected a JSON Messages request') + bodies.push(init.body) } return response + }) + const attachment = await ctx.attachments.saveImage({ data: await readFile(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) + const message = user('What is the dominant color of this image? Reply with one English color word.') + const request = options({ + model: 'deepseek-flash', reasoningEffort: ReasoningEffortId('off'), + messages: [{ ...message, content: [...message.content, { type: 'image', attachment }] }], + }) + for (let run = 0; run < 2; run++) { + const result = await assemble(ctx.llm.stream(request), request.model) + expect(result.assembler.finish.kind).toBe('stop') + expect(result.message.content.filter(block => block.type === 'text').map(block => block.text).join('').toLowerCase()).toContain('red') } - vi.stubGlobal('fetch', observedFetch) - const files = new LlmDeepSeek.DeepSeekFilesClient({ protocol: 'chat-completions', baseURL, apiKey: key }) - - try { - const result = await assemble(ctx, { - model: VISION, - messages: [createUserMessage({ - content: [ - { type: 'text', text: 'Briefly describe this image.' }, - { type: 'image', attachment: attachments.ref }, - ], - source: { kind: 'user' }, - })], - maxTokens: 100, - }) - expect( - result.finish.kind, - `DeepSeek vision result: ${JSON.stringify(result.finish)}`, - ).toBe('stop') - expect(textOf(result).trim().length).toBeGreaterThan(0) - expect(uploadedFile).toMatch(/^file-api-/u) - } finally { - if (uploadedFile !== undefined) await files.delete(uploadedFile) + expect(uploads).toHaveLength(1) + expect(bodies).toHaveLength(2) + expect(bodies.every(body => body.includes(`"file_id":"${uploads[0]}"`) && !body.includes('"type":"base64"'))).toBe(true) + const fileId = Messages.DeepSeekFileId(uploads[0]!) + const retrieved = await files.retrieve(fileId) + expect(retrieved).toMatchObject({ id: fileId, bytes: attachment.bytes, purpose: 'user_data' }) + expect(retrieved.expiresAt).toBeUndefined() + let page = await files.list({ limit: 1_000 }) + const cursors = new Set() + while (!page.data.some(file => file.id === fileId) && page.hasMore) { + expect(page.lastId).toBeDefined() + const after = page.lastId! + expect(cursors.has(after)).toBe(false) + cursors.add(after) + page = await files.list({ after, limit: 1_000 }) } + expect(page.data).toContainEqual(retrieved) + await files.delete(fileId) + ownedFiles.delete(fileId) + const recovered = await assemble(ctx.llm.stream(request), request.model) + expect(recovered.assembler.finish.kind).toBe('stop') + expect(recovered.message.content.filter(block => block.type === 'text').map(block => block.text).join('').toLowerCase()).toContain('red') + expect(uploads).toHaveLength(2) + expect(uploads[1]).not.toBe(fileId) + expect(bodies).toHaveLength(4) + expect(bodies[2]).toContain(`"file_id":"${fileId}"`) + expect(bodies[3]).toContain(`"file_id":"${uploads[1]}"`) + expect(bodies[3]).not.toContain('"type":"base64"') }) - it('accepts the session-log and plugin-package request extension fields', async () => { - const ctx = new Context() - contexts.push(ctx) + it.each([false, true])('submits Loader package inventory and records HTTP acceptance with session-log enabled=%s', async (enabled) => { + const ctx = await boot() await ctx.plugin(Loader) await ctx.plugin(AgentRegistry) - await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(DeepSeekLlmApiExtensionRegistry) - await ctx.plugin(SessionLogDeepSeek, { enabled: true }) - await ctx.plugin(PluginPackageInventoryDeepSeek) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: LlmDeepSeek.PUBLIC_BASE_URL, thinking: 'disabled' }) - const session = ctx.sessions.create(SessionId('real-extension-fields')) + await ctx.plugin(SessionLogDeepSeek, enabled ? {} : { enabled: false }) + ctx.baseUrl = import.meta.url + // Select the source module while Loader owns its active package entry. + ctx.loader.internal = sourceModuleLoader(async (specifier) => { + if (specifier !== '@deepseek-ai/dsh-plugin-package-inventory-deepseek') throw new Error(`unexpected Loader import: ${specifier}`) + return PluginPackageInventoryDeepSeek + }) + await ctx.loader.create({ name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' }) + await ctx.loader.await() + const packageIdentity = JSON.parse(await readFile(new URL('../../../plugin-package-inventory-deepseek/package.json', import.meta.url), 'utf8')) as { name: string; version: string } + const session = ctx.sessions.create(SessionId(`real-messages-extensions-${randomUUID()}`)) session.append('turn/start', { turn: 1 }) - - const result = await assemble(ctx, { - model: FLASH, - messages: ask('Reply with exactly the word: pong'), - maxTokens: 50, - sessionId: session.id, + const fetchImpl = globalThis.fetch + let afterSeq: number = -1 + let throughSeq = 0 + let requests = 0 + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url !== `${Messages.PUBLIC_BASE_URL}/v1/messages`) return fetchImpl(input, init) + if (typeof init?.body !== 'string') throw new Error('expected a JSON Messages request') + const body = JSON.parse(init.body) as Record + expect(body).toMatchObject({ dsh_plugin_packages: { + version: 1, packages: [{ name: packageIdentity.name, version: packageIdentity.version }], + } }) + if (enabled) { + expect(body).toMatchObject({ dsh_session_log: { + version: 1, sessionFormatVersion: session.header.version, session: { id: session.id }, + afterSeq, throughSeq, + events: Array.from({ length: throughSeq - afterSeq }, (_, index) => ({ seq: afterSeq + index + 1 })), + } }) + } else expect(body).not.toHaveProperty('dsh_session_log') + expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(afterSeq) + const response = await fetchImpl(input, init) + expect(response.ok).toBe(true) + expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(afterSeq) + requests += 1 + return response }) - expect(result.finish.kind).toBe('stop') - expect(textOf(result).toLowerCase()).toContain('pong') - expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(0) - }) - - it('serves a real request with the key held only by a credentials-local document', async () => { - const key = process.env.DEEPSEEK_API_KEY - if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') - const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-')) - try { - // JSON.stringify quotes the value: YAML is a JSON superset, so a real - // key survives whatever characters it happens to carry. - await writeFile(join(dir, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 }) - // Scrub the ambient variable so only the credential seam can supply the - // key: this request proves the per-request resolution path end to end. - vi.stubEnv('DEEPSEEK_API_KEY', '') - const ctx = new Context() - contexts.push(ctx) - await ctx.plugin(LlmRuntime) - await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: LlmDeepSeek.PUBLIC_BASE_URL }) - - const result = await assemble(ctx, { - model: FLASH, - messages: ask('Reply with exactly the word: pong'), - maxTokens: 50, - }) - expect(result.finish.kind).toBe('stop') - expect(textOf(result).toLowerCase()).toContain('pong') - } finally { - vi.unstubAllEnvs() - await rm(dir, { recursive: true, force: true }) - } - }) - - it('flash dynamically switches from off to low', async () => { - const ctx = await harness(FLASH, { reasoningEffort: 'off' }) - const withoutThinking = await assemble(ctx,{ - model: FLASH, - messages: ask('Reply with exactly the word: pong'), - maxTokens: 50, - }) - expect(withoutThinking.finish.kind).toBe('stop') - expect(textOf(withoutThinking).toLowerCase()).toContain('pong') - expect(withoutThinking.message.content.some(block => block.type === 'reasoning')).toBe(false) - expect(withoutThinking.usage?.inputTokens).toBeGreaterThan(0) - expect(withoutThinking.usage?.outputTokens).toBeGreaterThan(0) - - const withThinking = await assemble(ctx,{ - model: FLASH, - reasoningEffort: ReasoningEffortId('low'), - messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'), - maxTokens: 2000, - }) - expect(withThinking.finish.kind).toBe('stop') - expect(withThinking.message.content.some(block => block.type === 'reasoning')).toBe(true) - expect(textOf(withThinking)).toContain('9.8') - expect(withThinking.usage?.reasoningTokens).toBeGreaterThan(0) - }) - - it( - 'flash + thinking enabled (effort max): tool-call round trip with reasoning passback', - async () => { - const ctx = await harness(FLASH, { thinking: 'enabled' }) - - // Turn 1: the model must call the tool (and think before it). - const first = await assemble(ctx,{ - model: FLASH, - reasoningEffort: ReasoningEffortId('max'), - messages: ask('What is the weather in Paris right now? Use the get_weather tool.'), - tools: [weatherTool], - maxTokens: 2000, - }) - expect( - first.finish.kind, - `DeepSeek Flash tool-call turn finished as ${JSON.stringify(first.finish)}`, - ).toBe('tool-calls') - const call = first.message.content.find(block => block.type === 'tool-call') - expect(call).toBeDefined() - expect(call!.name).toBe('get_weather') - expect(JSON.parse(call!.arguments)).toMatchObject({ city: expect.stringMatching(/paris/i) as string }) - - // Turn 2: send the tool result back WITH the assistant's reasoning - // block in history (the official thinking+tools passback rule). - const second = await assemble(ctx,{ - model: FLASH, - reasoningEffort: ReasoningEffortId('max'), - messages: [ - ...ask('What is the weather in Paris right now? Use the get_weather tool.'), - createMessage({ - role: 'assistant', content: first.message.content, - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - createToolResultMessage({ - callId: ToolCallId(call!.id), - content: [{ type: 'text', text: 'Sunny, 22°C' }], - isError: false, - }), - ], - tools: [weatherTool], - maxTokens: 2000, - }) - expect( - second.finish.kind, - `DeepSeek Flash tool-result turn finished as ${JSON.stringify(second.finish)}`, - ).toBe('stop') - expect(textOf(second).toLowerCase()).toMatch(/sunny|22/) - }, - ) - - it.skipIf(IN_HISTORY_MODEL === undefined)( - 'an in-history model follows a mid-history system message and keeps the cached prefix', - async () => { - const model = IN_HISTORY_MODEL as string - const ctx = await harness(model, { - thinking: 'disabled', - models: [{ id: model, systemPromptUpdate: 'in-history' }], - }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', model)) - .resolves.toMatchObject({ systemPromptUpdate: 'in-history' }) - const system = (text: string) => createSystemMessage(text) - // A nonce before the padding isolates the provider cache across runs and retries. - const nonce = randomBytes(16).toString('hex') - const padding = Array.from({ length: 40 }, (_, index) => `Rule ${String(index + 1)}: keep every answer short and factual.`).join('\n') - // Both update strategies use identical prompt bytes, with the changed instruction near the head. - const prompt = (word: string) => `Test ${nonce}. When the user says ping, reply with exactly the word: ${word}\n${padding}` - const initial = prompt('pong') - const latest = prompt('banana') - const history = [system(initial), ...ask('ping')] - const wireRequests: WireMessage[][] = [] - const nativeFetch = globalThis.fetch - const observedFetch: typeof fetch = async (input, init) => { - if (init?.method !== 'POST' || typeof init.body !== 'string') return nativeFetch(input, init) - const body = JSON.parse(init.body) as WireRequest - if (body.model === model) { - wireRequests.push(body.messages) - if (wireRequests.length === 1) { - expect(body.messages).toEqual([ - { role: 'system', content: initial }, { role: 'user', content: 'ping' }, - ]) - } else if (wireRequests.length === 2) { - expect(JSON.stringify(body.messages)).toBe(JSON.stringify(wireRequests[0])) - } else if (wireRequests.length === 3) { - expect(JSON.stringify(body.messages.slice(0, history.length))) - .toBe(JSON.stringify(wireRequests[0])) - expect(body.messages[3]).toEqual({ role: 'system', content: latest }) - } else if (wireRequests.length === 4) { - expect(body.messages[0]).toEqual(wireRequests[2]![3]) - expect(body.messages.slice(1)).toEqual([ - wireRequests[2]![1], wireRequests[2]![2], wireRequests[2]![4], - ]) - } - } - return nativeFetch(input, init) + for (let run = 0; run < 2; run++) { + afterSeq = SessionLogDeepSeek.acceptedThrough(session) + throughSeq = Number(session.seq) - 1 + const assembler = new BlockAssembler() + for await (const chunk of ctx.llm.stream(options({ sessionId: session.id, reasoningEffort: ReasoningEffortId('off'), messages: [user('Reply with exactly PONG.')] }))) { + expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(enabled ? throughSeq : -1) + assembler.push(chunk) } - vi.stubGlobal('fetch', observedFetch) - try { - const first = await assemble(ctx, { model, messages: history, maxTokens: 50 }) - expect(first.finish.kind).toBe('stop') - expect(textOf(first).trim()).toBe('pong') - const initialTokens = first.usage?.inputTokens ?? 0 - expect(initialTokens).toBeGreaterThan(0) - - // Measure reusable tokens rather than assuming a provider cache-block size. - const warm = await assemble(ctx, { model, messages: history, maxTokens: 50 }) - expect(warm.finish.kind).toBe('stop') - expect(textOf(warm).trim()).toBe('pong') - const reusableTokens = warm.usage?.cacheReadTokens ?? 0 - expect(reusableTokens).toBeLessThanOrEqual(initialTokens) - expect(reusableTokens).toBeGreaterThan(0) - const assistant = createMessage({ - role: 'assistant', content: first.message.content, source: first.message.source, - }) - - const updated = await assemble(ctx, { - model, - messages: [...history, assistant, system(latest), ...ask('ping')], - maxTokens: 50, - }) - expect(updated.finish.kind).toBe('stop') - expect(textOf(updated).trim()).toBe('banana') - - const replaced = await assemble(ctx, { - model, - messages: [system(latest), ...ask('ping'), assistant, ...ask('ping')], - maxTokens: 50, - }) - expect(replaced.finish.kind).toBe('stop') - expect(textOf(replaced).trim()).toBe('banana') - expect(wireRequests).toHaveLength(4) - - const cached = updated.usage?.cacheReadTokens ?? 0 - expect(replaced.usage?.cacheReadTokens).toBeDefined() - const baselineCached = replaced.usage?.cacheReadTokens ?? 0 - expect(cached).toBeGreaterThanOrEqual(reusableTokens) - expect(cached).toBeGreaterThan(baselineCached) - } finally { - vi.stubGlobal('fetch', nativeFetch) - } - }, - ) - - it('streams raw chunks in protocol order', async () => { - const ctx = await harness(FLASH, { thinking: 'disabled' }) - const kinds: string[] = [] - for await (const chunk of ctx.llm.stream({ - provider: 'deepseek-official', - model: FLASH, - messages: ask('Count from 1 to 5, digits only.'), - maxTokens: 50, - })) { - kinds.push(chunk.type) + expect(assembler.finish.kind).toBe('stop') + expect(assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('')).toContain('PONG') + expect(requests).toBe(run + 1) + if (run === 0) session.append('step/start', { turn: 1, step: 1 }) } - expect(kinds[0]).toBe('block-start') - expect(kinds.at(-1)).toBe('finish') - expect(kinds.filter(kind => kind === 'finish')).toHaveLength(1) - // usage precedes finish (deferred-emit contract) - expect(kinds.indexOf('usage')).toBeLessThan(kinds.indexOf('finish')) + expect(session.snapshotEvents().filter(event => event.type === 'session-log-deepseek/delivery-accepted')).toHaveLength(enabled ? 2 : 0) + }) + + it.each(['off', 'low', 'high', 'max'])('streams text with %s effort', async (effort) => { + const ctx = await boot() + const result = await assemble(ctx.llm.stream(options({ reasoningEffort: ReasoningEffortId(effort), temperature: 0, messages: [user('Reply with exactly PONG.')] }))) + expect(result.assembler.finish).toEqual({ kind: 'stop' }) + expect(result.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('PONG') + expect(result.assembler.usage?.outputTokens).toBeGreaterThan(0) + expect(result.message.source.replayState).toBeDefined() + }) + + it('replays a JSON-persisted thinking/tool turn and an error result before continuing', async () => { + const ctx = await boot() + const history: Message[] = [user('Use lookup_value with key secret. If the tool returns an error, report its exact error text and stop.')] + const request = options({ messages: history, tools: [tool], reasoningEffort: ReasoningEffortId('high') }) + const first = await assemble(ctx.llm.stream(request)) + expect(first.assembler.finish.kind).toBe('tool-calls') + const calls = first.message.content.filter(block => block.type === 'tool-call') + expect(calls).toHaveLength(1) + const restored = JSON.parse(JSON.stringify(first.message)) as Message + history.push(restored) + for (const call of calls) history.push(createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: 'LOOKUP_DENIED_731' }], isError: true })) + const second = await assemble(ctx.llm.stream({ ...request, messages: history })) + expect(second.assembler.finish.kind).toBe('stop') + expect(second.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('LOOKUP_DENIED_731') + history.push(second.message, user('Reply with exactly DONE.')) + const third = await assemble(ctx.llm.stream({ ...request, messages: history })) + expect(third.assembler.finish.kind).toBe('stop') + }) + + it('continues persisted foreign history containing malformed tool arguments', async () => { + const ctx = await boot() + const callId = ToolCallId('historical_lookup') + const history = [ + user('Look up the secret value.'), + createAssistantMessage({ source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, content: [ + { type: 'tool-call', id: callId, name: 'lookup_value', arguments: '{"key":"the "secret""}' }, + ] }), + createToolResultMessage({ callId, content: [{ type: 'text', text: 'Invalid arguments: expected an object' }], isError: true }), + user('Do not retry the lookup. Reply with exactly HISTORY_RECOVERED_731.'), + ] + const saved = JSON.stringify(history) + const restored = JSON.parse(saved) as Message[] + const response = await assemble(ctx.llm.stream(options({ messages: restored, tools: [tool], reasoningEffort: ReasoningEffortId('off') }))) + expect(response.assembler.finish.kind).toBe('stop') + expect(response.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('HISTORY_RECOVERED_731') + expect(JSON.stringify(restored)).toBe(saved) + }) + + it('cancels an active stream without committing a successful response', async () => { + const ctx = await boot() + const controller = new AbortController() + const stream = ctx.llm.stream(options({ signal: controller.signal, messages: [user('List the integers from one to one thousand, one per line.')] })) + let cancelled = false + let finish: unknown + for await (const chunk of stream) { + if (!cancelled && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta')) { cancelled = true; controller.abort() } + if (chunk.type === 'finish') finish = chunk.reason + } + expect(cancelled).toBe(true) + expect(finish).toMatchObject({ kind: 'aborted' }) + }) + + it('continues a thinking/tool turn after degrading unusable persisted replay metadata', async () => { + const ctx = await boot() + const warnings: unknown[][] = [] + ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } }) + const history: Message[] = [user('Use lookup_value with key secret. Then reply with the exact tool result and stop.')] + const request = options({ messages: history, tools: [tool], reasoningEffort: ReasoningEffortId('high') }) + const first = await assemble(ctx.llm.stream(request)) + expect(first.assembler.finish.kind).toBe('tool-calls') + const calls = first.message.content.filter(block => block.type === 'tool-call') + expect(calls).toHaveLength(1) + const restored = JSON.parse(JSON.stringify(first.message)) as typeof first.message + restored.source.replayState = { response: { kind: 'deepseek-messages', version: 2 }, blocks: [] } + const saved = JSON.stringify(restored) + history.push(restored, ...calls.map(call => createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: 'REPLAY_RECOVERED_731' }], isError: false }))) + const second = await assemble(ctx.llm.stream({ ...request, messages: history })) + expect(second.assembler.finish.kind).toBe('stop') + expect(second.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('REPLAY_RECOVERED_731') + expect(warnings).toEqual([[expect.stringContaining('unsupported kind or version')]]) + expect(JSON.stringify(restored)).toBe(saved) }) }) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ea6ba2bf8f..f12fe10e87 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1,2358 +1,448 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' +/** HTTP lifecycle, routing and optional Cordis services under real composition. */ +import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from '@deepseek-ai/cordis' -import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' -import LlmRuntime, { ToolCallId, createUserMessage, - CONTEXT_WINDOW_EXCEEDED_CODE, - createToolResultMessage, - LlmError, - ProviderRequestId, - QUOTA_EXCEEDED_CODE, - ReasoningEffortId, - userAgent, -} from '@deepseek-ai/dsh-llm' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' -import { SessionId } from '@deepseek-ai/dsh-session' -import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import type { PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' -import type { ContextFormed } from '@deepseek-ai/dsh-llm' -import { httpErrorCode } from '../src/protocols/chat-completions/adapter.ts' -import { resolveRequestImageTarget } from '../src/common/request-pricing.ts' -import { assemble } from './assemble.ts' -import { closeMockServers, mockServer, textEvents } from './mock-server.ts' -import type { Behavior } from './mock-server.ts' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context, LoggerLevel, Service } from '@deepseek-ai/cordis' +import LocalAttachments from '@deepseek-ai/dsh-attachment-local' +import AgentRegistry, { installModelSelection } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import LlmRuntime, { createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { Message } from '@deepseek-ai/dsh-llm' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import LocalCredentials from '@deepseek-ai/dsh-credentials-local' +import FileSettings from '@deepseek-ai/dsh-settings-file' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { DeepSeekAdapter } from '../src/adapter.ts' +import { object } from '../src/replay.ts' +import { DeepSeekFileStore } from '../src/file-store.ts' +import * as Messages from '../src/index.ts' +import { adapter, assemble, chunks, MODEL, options, prepareExtensions, server, sse, textEvents, user, sourceModuleLoader } from './helpers.ts' + +const cleanup: (() => Promise)[] = [] +afterEach(async () => { + while (cleanup.length) await cleanup.pop()!() + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) +async function endpoint(...args: Parameters) { + const instance = await server(...args) + cleanup.push(() => instance.close()) + return instance +} +async function context() { + const home = await mkdtemp(join(tmpdir(), 'dsh-messages-test-')) + cleanup.push(() => rm(home, { recursive: true, force: true })) + vi.stubEnv('DSH_HOME', home) + const ctx = new Context() + cleanup.push(() => ctx.fiber.dispose()) + return { ctx, home } +} + +async function send(agent: Agent, text: string) { + agent.followup(user(text)) + await agent.whenIdle() + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) +} declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { - 'test': { kind: 'test' } & ContextFormed + 'saved-notice': { kind: 'saved-notice' } } } -const TEST_USER_ID = '00000000-0000-4000-8000-000000000001' as AnonymousUserId -let testHome: string +describe('direct Messages HTTP', () => { + it('continues through Messages with assistant blocks in saved user history', async () => { + const http = await endpoint() + const notice = createUserMessage({ source: { kind: 'saved-notice' }, content: [ + { type: 'text', text: 'Background subagent finished.' }, + { type: 'reasoning', text: 'child reasoning' }, + { type: 'tool-call', id: ToolCallId('child-call'), name: 'read', arguments: '{}' }, + { type: 'text', text: 'Its closing message: answer.' }, + ] }) + const empty = createUserMessage({ source: { kind: 'user' }, content: [{ type: 'reasoning', text: 'no closing text' }] }) + const history = [notice, empty] + const saved = JSON.stringify(history) + const llm = adapter({ baseURL: http.url }) + const first = await assemble(llm.stream(options({ messages: history }))) + const second = await assemble(llm.stream(options({ messages: [...history, first.message, user('continue')] }))) -beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), 'dsh-llm-deepseek-')) - vi.stubEnv('DSH_HOME', testHome) -}) - -afterEach(async () => { - await closeMockServers() - vi.unstubAllEnvs() - vi.useRealTimers() - rmSync(testHome, { recursive: true, force: true }) -}) - -async function harness(baseURL: string, config: object = {}) { - // Configuration carries only the reference; the key comes from the - // environment, which is the whole credential plane without a mounted seam. - vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(DeepSeekLlmApiExtensionRegistry) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL, ...config }) - return ctx -} - -/** Direct adapter over the plugin's real resolve step, with a static key. */ -function noExtensions(): Promise { - return Promise.resolve({ fields: {}, accept: () => Promise.resolve() }) -} - -/** Direct adapter over the plugin's real resolve step, with a static key. */ -function adapterOf( - config: Partial & { apiKey?: string } = {}, - attachments?: AttachmentStore, - files?: LlmDeepSeek.DeepSeekFileStore, -): DeepSeekAdapter { - const { apiKey, ...rest } = config - return new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', ...rest }), - resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), - resolveUserId: () => TEST_USER_ID, - resolveAttachments: () => attachments, - ...files === undefined ? {} : { resolveFiles: () => files }, - prepareExtensions: noExtensions, - }) -} - -async function drain(stream: AsyncIterable): Promise { - for await (const _chunk of stream) { /* drain */ } -} - -const imageRef: ImageAttachmentRef = { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), - mediaType: 'image/png', - bytes: 3, - width: 1, - height: 1, -} - -function requestImage(ref = imageRef): RequestImageAttachment { - return { - variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), - attachment: ref, - data: Uint8Array.of(1, 2, 3), - mediaType: 'image/png', - bytes: 3, - width: 1, - height: 1, - depth: 'uchar', - space: 'srgb', - hasAlpha: true, - } -} - -function attachmentStoreOf( - project: (ref: ImageAttachmentRef, policy: unknown, signal?: AbortSignal) => Promise, -): { - store: AttachmentStore - readImageRequest: ReturnType> -} { - const readImageRequest = vi.fn(project) - return { - store: { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore, - readImageRequest, - } -} - -function fileStoreOf( - implementation: (...args: Parameters) => ReturnType, -) { - const ensureUploaded = vi.fn(implementation) - const invalidate = vi.fn(() => Promise.resolve()) - return { - store: { ensureUploaded, invalidate } as unknown as LlmDeepSeek.DeepSeekFileStore, - ensureUploaded, - invalidate, - } -} - -function fileReference(fileId: string): Awaited> { - return { - record: { fileId: LlmDeepSeek.DeepSeekFileId(fileId) }, - uploaded: true, - } as Awaited> -} - -function successfulSseResponse(): Response { - return new Response(textEvents.map(event => `data: ${event}\n\n`).join(''), { - status: 200, - headers: { 'content-type': 'text/event-stream' }, - }) -} - -describe('request image target', () => { - it.each([ - [ - { id: 'default' }, - { width: 1302, height: 1302, maxBytes: 2 * 1024 * 1024 }, - ], - [ - { id: 'low', imagePixelBudget: 'low' as const }, - { width: 512, height: 512, maxBytes: 2 * 1024 * 1024 }, - ], - [ - { id: 'custom', imagePixelBudget: 320_000, imageMaxBytes: 512_000 }, - { width: 565, height: 565, maxBytes: 512_000 }, - ], - ])('resolves route-owned defaults and overrides for %s on a 4096x4096 source', (model, expected) => { - expect(resolveRequestImageTarget(model, { width: 4096, height: 4096 })).toEqual(expected) - }) - - it('caps every request image at the provider per-side limit', () => { - expect(resolveRequestImageTarget({ id: 'default' }, { width: 8192, height: 78 })) - .toEqual({ width: 4096, height: 39, maxBytes: 2 * 1024 * 1024 }) - expect(resolveRequestImageTarget({ id: 'custom', imagePixelBudget: 640_000 }, { width: 10_000, height: 100 })) - .toEqual({ width: 4096, height: 41, maxBytes: 2 * 1024 * 1024 }) - }) - - it('answers image request pricing from the current connection snapshot', () => { - const adapter = adapterOf({ - models: [{ id: 'vision', inputModalities: ['text', 'image'] }], - }) - const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([{ type: 'image', attachment: imageRef }]) - expect(priced).toHaveLength(1) - expect(priced?.[0]!.visualTokens).toBeGreaterThan(0) - const textOnly = adapter.imageRequestPricing('deepseek-official', 'unlisted')?.priceImages([{ type: 'image', attachment: imageRef }]) - expect(textOnly?.[0]!.visualTokens).toBe(0) - }) - - it('prices descriptor text through the serializer\'s access resolution', () => { - const attachments = {} as AttachmentStore - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', models: [{ id: 'vision', inputModalities: ['text', 'image'] }] }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - resolveAttachments: () => attachments, - resolveImageAccess: (store, ref) => (store === attachments && ref === imageRef - ? { readonlyPath: '/world/img.png' } - : undefined), - prepareExtensions: noExtensions, - }) - const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([{ type: 'image', attachment: imageRef }]) - expect(priced?.[0]?.text).toContain('/world/img.png') - }) -}) - -describe('DeepSeekAdapter against a mock server', () => { - it('merges prepared extension fields and accepts them once after HTTP 2xx', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const accept = vi.fn() - const prepareExtensions = vi.fn(async () => ({ - fields: { dsh_test: { version: 1 } }, - accept: async () => { accept() }, - })) - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: prepareExtensions as never, - }) - - await drain(adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [], sessionId: SessionId('s') })) - expect(server.requests[0]).toMatchObject({ dsh_test: { version: 1 } }) - expect(prepareExtensions).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 's' })) - expect(accept).toHaveBeenCalledOnce() - }) - - it('fails before fetch on extension preparation or base-field collision', async () => { - const server = await mockServer([]) - const base = { - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - } - const failed = new DeepSeekAdapter({ - ...base, - prepareExtensions: () => Promise.reject(new Error('metadata unavailable')), - }) - await expect(drain(failed.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) - .rejects.toMatchObject({ code: 'REQUEST_EXTENSION' }) - - const collision = new DeepSeekAdapter({ - ...base, - prepareExtensions: (() => Promise.resolve({ fields: { model: 'replacement' }, accept: () => Promise.resolve() })) as never, - }) - await expect(drain(collision.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) - .rejects.toMatchObject({ code: 'REQUEST_EXTENSION' }) - expect(server.requests).toHaveLength(0) - }) - - it('passes cancellation into extension preparation and aborts before fetch', async () => { - const server = await mockServer([]) - const controller = new AbortController() - const started = Promise.withResolvers() - let signalSeen: AbortSignal | undefined - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: ((request: { signal: AbortSignal }) => { - signalSeen = request.signal - started.resolve(undefined) - if (request.signal === undefined) return new Promise(() => {}) - return new Promise((_resolve, reject) => { - request.signal.addEventListener('abort', () => { - const reason: unknown = request.signal.reason - reject(reason instanceof Error ? reason : new Error('extension preparation aborted', { cause: reason })) - }, { once: true }) - }) - }) as never, - }) - - const pending = drain(adapter.stream({ - provider: 'deepseek-official', - model: 'm', - messages: [], - signal: controller.signal, - })) - await started.promise - expect(signalSeen).toBeInstanceOf(AbortSignal) - controller.abort() - await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) - expect(server.requests).toHaveLength(0) - }) - - it('passes cancellation through an outstanding fetch', async () => { - const controller = new AbortController() - const started = Promise.withResolvers() - const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { - const signal = init?.signal - return new Promise((_resolve, reject) => { - started.resolve(undefined) - signal?.addEventListener('abort', () => { - const reason: unknown = signal.reason - reject(reason instanceof Error ? reason : new Error('fetch aborted', { cause: reason })) - }, { once: true }) - }) - }) - try { - const adapter = adapterOf({ baseURL: 'https://provider.invalid' }) - const pending = drain(adapter.stream({ - provider: 'deepseek-official', - model: 'm', - messages: [], - signal: controller.signal, - })) - await started.promise - controller.abort() - await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) - } finally { - fetch.mockRestore() - } - }) - - it('does not accept extensions on non-2xx and does accept before a later stream failure', async () => { - const server = await mockServer([ - { kind: 'http-error', status: 500, body: '{}' }, - { kind: 'close-early', events: ['{"choices":[{"delta":{"content":"partial"}}]}'] }, + expect(first.assembler.finish.kind).toBe('stop') + expect(second.assembler.finish.kind).toBe('stop') + expect(http.requests).toHaveLength(2) + const wireNotice = { role: 'user', content: [ + { type: 'text', text: 'Background subagent finished.' }, + { type: 'text', text: 'Its closing message: answer.' }, + ] } + expect(http.requests[0]?.body.messages).toEqual([wireNotice]) + expect(http.requests[1]?.body.messages).toEqual([ + wireNotice, + { role: 'assistant', content: [{ type: 'text', text: 'Hello 世界' }] }, + { role: 'user', content: [{ type: 'text', text: 'continue' }] }, ]) - const accept = vi.fn() - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: () => Promise.resolve({ fields: { dsh_test: 1 }, accept: async () => { accept() } }) as never, - }) - const request = { provider: 'deepseek-official', model: 'm', messages: [] } + expect(JSON.stringify(history)).toBe(saved) + }) - await expect(drain(adapter.stream(request))).rejects.toMatchObject({ code: 'SERVER' }) + it('continues without a diagnostic callback when replay metadata is unusable', async () => { + const http = await endpoint() + const message = createAssistantMessage({ content: [{ type: 'text', text: 'Remember 731.' }], source: { + provider: 'deepseek-official', model: MODEL, replayState: { response: {}, blocks: [] }, + } }) + const response = await assemble(adapter({ baseURL: http.url }).stream(options({ messages: [user(), message, user()] }))) + expect(response.assembler.finish.kind).toBe('stop') + expect(http.requests[0]?.body.messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'Remember 731.' }] }, + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + ]) + }) + + it('uses the Messages endpoint, authentication, attribution and final usage', async () => { + const http = await endpoint() + const llm = adapter({ baseURL: http.url }) + const response = await assemble(llm.stream(options({ model: 'deepseek-flash', sessionId: SessionId('session-test'), purpose: 'compaction' })), 'deepseek-flash') + expect(response.message.content).toEqual([{ type: 'text', text: 'Hello 世界' }]) + expect(response.message.source).toMatchObject({ + model: 'deepseek-flash', replayState: { response: { model: 'deepseek-flash' } }, + }) + expect(http.requests[0]).toMatchObject({ path: '/anthropic/v1/messages', headers: { + 'x-api-key': 'test-key', 'anthropic-version': '2023-06-01', + 'user-agent': expect.stringContaining('deepseek-harness/') as string, 'x-deepseek-harness-user-id': 'test-user', + 'x-deepseek-harness-session-id': 'session-test', 'x-deepseek-harness-compact': '1', + }, body: { thinking: { type: 'enabled' }, output_config: { effort: 'high' } } }) + expect(llm.providerInfo('deepseek-official')).toEqual({ id: 'deepseek-official', name: 'DeepSeek' }) + expect((await llm.listModels('deepseek-official')).map(model => model.id)).toEqual([ + 'deepseek-flash', 'deepseek-v4-pro', + ]) + expect(await llm.resolveModel('deepseek-official', 'deepseek-flash')).toMatchObject({ + name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', + }) + expect(await llm.resolveModel('deepseek-official', MODEL)).toMatchObject({ id: MODEL }) + expect(llm.imageRequestPricing('deepseek-official', MODEL)).toBeDefined() + }) + + it.each([ + ['https://provider.example', 'https://provider.example/v1/messages'], + ['https://provider.example/v1/', 'https://provider.example/v1/messages'], + ['https://provider.example/v1beta', 'https://provider.example/v1beta/v1/messages'], + ['https://provider.example/v2', 'https://provider.example/v2/v1/messages'], + ['https://provider.example/anthropic', 'https://provider.example/anthropic/v1/messages'], + ['https://v1.provider.example', 'https://v1.provider.example/v1/messages'], + ])('resolves the Messages endpoint from %s', async (baseURL, expected) => { + const fetchImpl = vi.fn(async () => new Response(sse(textEvents), { + headers: { 'content-type': 'text/event-stream' }, + })) + vi.stubGlobal('fetch', fetchImpl) + + await chunks(adapter({ baseURL }).stream(options())) + + expect(fetchImpl.mock.calls[0]?.[0]).toBe(expected) + }) + + it.each([true, false])('maps non-2xx responses (JSON=%s)', async (json) => { + const http = await endpoint((response) => { response.statusCode = 429; response.setHeader('retry-after', '3'); response.end(json ? JSON.stringify({ error: { type: 'rate_limit_error', message: 'slow down' } }) : 'busy') }) + await expect(chunks(adapter({ baseURL: http.url }).stream(options()))).rejects.toMatchObject({ code: 'RATE_LIMIT', failure: { status: 429, providerRetryAfterMs: 3000 } }) + }) + + it('refuses redirects before credentials reach another origin or request extensions are accepted', async () => { + const destination = await endpoint() + const source = await endpoint((response) => { + response.writeHead(307, { location: `${destination.url}/v1/messages` }) + response.end() + }) + const accept = vi.fn(async () => {}) + const prepare = vi.fn(async () => ({ fields: {}, accept })) + const files = new DeepSeekFileStore() + const llm = new DeepSeekAdapter({ + options: () => Messages.resolveAdapterOptions({ baseURL: source.url }), + resolveApiKey: () => Promise.resolve('test-key'), resolveUserId: () => 'test-user' as AnonymousUserId, + resolveAttachments: () => undefined, resolveImageAccess: () => undefined, resolveFiles: () => files, + prepareExtensions: prepare, + }) + const error = await chunks(llm.stream(options())).catch((cause: unknown) => cause) + expect(source.requests).toHaveLength(1) + expect(source.requests[0]?.headers['x-api-key']).toBe('test-key') + expect(destination.requests).toEqual([]) + expect(error).toMatchObject({ code: 'TRANSPORT' }) + expect(prepare).toHaveBeenCalledOnce() expect(accept).not.toHaveBeenCalled() - await expect(drain(adapter.stream(request))).rejects.toBeDefined() - expect(accept).toHaveBeenCalledOnce() }) - it('reports a post-2xx extension acceptance failure without relabelling it as transport', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const failure = new Error('watermark append failed') - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: () => Promise.resolve({ - fields: { dsh_test: 1 }, - accept: () => Promise.reject(failure), - }) as never, + it('freezes endpoint and defaults for a prepared call while the next call sees new settings', async () => { + const first = await endpoint(), second = await endpoint() + let config = Messages.resolveAdapterOptions({ baseURL: first.url, maxTokens: 10, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) + const files = new DeepSeekFileStore() + const llm = new DeepSeekAdapter({ options: () => config, resolveApiKey: snapshot => Promise.resolve(snapshot.maxTokens === 10 ? 'first' : 'second'), resolveUserId: () => 'user' as AnonymousUserId, resolveAttachments: () => undefined, resolveImageAccess: () => undefined, resolveFiles: () => files, prepareExtensions }) + const prepared = await llm.prepareCall('deepseek-official', MODEL) + config = Messages.resolveAdapterOptions({ baseURL: second.url, maxTokens: 20 }) + expect(prepared.model.systemPromptUpdate).toBe('in-history') + expect((await llm.resolveModel('deepseek-official', MODEL)).systemPromptUpdate).toBeUndefined() + await chunks(prepared.stream(options())) + await chunks(llm.stream(options())) + expect(first.requests[0]).toMatchObject({ headers: { 'x-api-key': 'first' }, body: { max_tokens: 10 } }) + expect(second.requests[0]).toMatchObject({ headers: { 'x-api-key': 'second' }, body: { max_tokens: 20 } }) + }) + + it('aborts an open provider response when its consumer stops', async () => { + let closed!: () => void + const stopped = new Promise((resolve) => { closed = resolve }) + const http = await endpoint((response) => { + response.once('close', closed) + response.write(sse(textEvents.slice(0, 3))) }) - - await expect(drain(adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) - .rejects.toMatchObject({ code: 'REQUEST_EXTENSION', cause: failure }) - expect(server.requests).toHaveLength(1) + const stream = adapter({ baseURL: http.url }).stream(options())[Symbol.asyncIterator]() + expect((await stream.next()).value).toMatchObject({ type: 'block-start' }) + await stream.return!() + await stopped }) - it('streams a text generation end to end through the assembler', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url) - - const result = await assemble(ctx, { - model: 'deepseek-v4-pro', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) - expect(result.finish).toEqual({ kind: 'stop' }) - expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1, totalTokens: 4 }) - - // The wire request carried the auth header contents we configured. - expect(server.requests[0]).toMatchObject({ - model: 'deepseek-v4-pro', - max_tokens: 256_000, - reasoning_effort: 'high', - stream: true, - stream_options: { include_usage: true }, - }) - // App attribution and DeepSeek request identity are independent wire facts. - expect(server.headers[0]?.['user-agent']).toBe(userAgent()) - expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) - expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-session-id') - expect(server.headers[0]).not.toHaveProperty('http-referer') - expect(server.headers[0]).not.toHaveProperty('x-openrouter-title') - expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories') - expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-compact') + it('distinguishes caller cancellation from idle timeout and transport failure', async () => { + const http = await endpoint((response) =>{ response.flushHeaders() }) + await expect(chunks(adapter({ baseURL: http.url, streamIdleTimeoutMs: 30 }).stream(options()))).rejects.toMatchObject({ code: 'TIMEOUT' }) + const controller = new AbortController(); controller.abort() + await expect(chunks(adapter({ baseURL: http.url }).stream(options({ signal: controller.signal })))).rejects.toMatchObject({ code: 'ABORTED' }) + vi.stubGlobal('fetch', async () => { throw new TypeError('network down') }) + await expect(chunks(adapter().stream(options()))).rejects.toMatchObject({ code: 'TRANSPORT' }) }) - it('uploads a durable image once and sends only its Files API id to the vision model', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const signalSeen: (AbortSignal | undefined)[] = [] - const policies: unknown[] = [] - const attachmentMocks = attachmentStoreOf((ref, policy, signal) => { - signalSeen.push(signal) - policies.push(policy) - return Promise.resolve(requestImage(ref)) - }) - const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-flash', - messages: [createUserMessage({ - content: [ - { type: 'text', text: 'describe ' }, - { type: 'image', attachment: imageRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(server.requests[0]).toMatchObject({ - model: 'deepseek-flash', - messages: [{ - role: 'user', - content: [ - { type: 'text', text: 'describe ' }, - { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request preview 1x1px.`) as string }, - { type: 'file', file_id: 'file-api-1' }, - ], - }], - }) - expect(server.fileRequests).toEqual([{ - method: 'POST', - path: '/files', - filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`, - bytes: 3, - }]) - expect(signalSeen[0]).toBeInstanceOf(AbortSignal) - expect(policies).toEqual([{ width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }]) - }) - - it('keeps images inside plugin content opaque through adapter.stream', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) - const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store) - await drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-pro', messages: [ - createUserMessage({ source: { kind: 'user' }, content: [ - { type: 'plugin:message', - message: { role: 'tool', toolCallId: 'foreign', content: [{ type: 'image', attachment: { ...imageRef } }] }, - } as never, - { type: 'text', text: 'context' }, - ] }), - ] })) - expect(attachmentMocks.readImageRequest).not.toHaveBeenCalled() - expect(server.fileRequests).toHaveLength(0) - expect(server.requests).toHaveLength(1) - expect(server.requests[0]).toMatchObject({ messages: [{ role: 'user', content: 'context' }] }) - }) - - it('falls back to one all-base64 request when Files API resolution fails', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } - const attachments = attachmentStoreOf(ref => Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), - })).store - const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - const body = server.requests[0] as { messages: Array<{ content: unknown }> } - expect(JSON.stringify(body.messages[0]?.content).match(/"type":"image_url"/g)).toHaveLength(2) - expect(JSON.stringify(body)).not.toContain('file_id') - expect(files.ensureUploaded).toHaveBeenCalledTimes(1) - }) - - it('fails a base64 fallback whose retained images exceed the inline bound with the count to offload', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) - const adapter = adapterOf({ - baseURL: server.url, - maxInlineRequestImageBytes: 80, - inlineImageOffloadByteQuantum: 40, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: Array.from({ length: 21 }, () => ({ type: 'image' as const, attachment: imageRef })), - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', message: expect.stringContaining('11 more') as string }) - expect(server.requests).toHaveLength(0) - }) - it('discards partially resolved file ids and falls back with every retained image inline', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } - const attachments = attachmentStoreOf(ref => Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), - })).store - const files = fileStoreOf(() => Promise.reject(new Error('unused'))) - files.ensureUploaded - .mockResolvedValueOnce(fileReference('file-api-partial')) - .mockRejectedValueOnce(new LlmError('Files unavailable', 'TRANSPORT')) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - const body = server.requests[0] as { messages: Array<{ content: unknown }> } - expect(JSON.stringify(body.messages[0]?.content).match(/"type":"image_url"/g)).toHaveLength(2) - expect(JSON.stringify(body)).not.toContain('file-api-partial') - }) - - it('falls back after the configured Files API deadline without aborting chat', async () => { - vi.useFakeTimers() - const started = Promise.withResolvers() - const files = fileStoreOf((_version, _connection, _policy, signal) => new Promise((_resolve, reject) => { - started.resolve(undefined) - signal?.addEventListener('abort', () => { - const reason: unknown = signal.reason - reject(reason instanceof Error ? reason : new Error('files operation aborted')) - }, { once: true }) - })) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulSseResponse()) - const adapter = adapterOf({ - baseURL: 'https://deepseek.invalid', - filesApiTimeoutMs: 50, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - const pending = drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - await started.promise - await vi.advanceTimersByTimeAsync(50) - await pending - - expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(fetchSpy.mock.calls[0]?.[1]?.body).toEqual(expect.stringContaining('image_url')) - fetchSpy.mockRestore() - }) - - it('does not turn caller cancellation during file resolution into base64 fallback', async () => { - const started = Promise.withResolvers() - const files = fileStoreOf((_version, _connection, _policy, signal) => new Promise((_resolve, reject) => { - started.resolve(undefined) - signal?.addEventListener('abort', () => { reject(new Error('cancelled')) }, { once: true }) - })) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const fetchSpy = vi.spyOn(globalThis, 'fetch') - const controller = new AbortController() - const adapter = adapterOf({ - baseURL: 'https://deepseek.invalid', - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - const pending = drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - signal: controller.signal, - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - await started.promise - controller.abort() - - await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) - expect(fetchSpy).not.toHaveBeenCalled() - fetchSpy.mockRestore() - }) - - it('does not retry a generic chat failure through base64 fallback', async () => { - const server = await mockServer([{ - kind: 'http-error', - status: 503, - body: JSON.stringify({ error: { message: 'chat unavailable' } }), - }]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const files = fileStoreOf(() => Promise.resolve(fileReference('file-api-ready'))) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'SERVER', message: 'chat unavailable' }) - - expect(server.requests).toHaveLength(1) - expect(JSON.stringify(server.requests[0])).toContain('file-api-ready') - expect(JSON.stringify(server.requests[0])).not.toContain('image_url') - }) - - it('does not prepare a surface-offloaded image and sends its placeholder instead', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const old = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), bytes: 3 } - const recent = { ...imageRef, attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`), bytes: 3 } - const attachmentMocks = attachmentStoreOf((ref) => { - if (ref.attachmentId === old.attachmentId) throw new Error('old image must not be read') - return Promise.resolve(requestImage(ref)) - }) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachmentMocks.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: old, offloaded: true }, - { type: 'image', attachment: recent }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(attachmentMocks.readImageRequest).toHaveBeenCalledWith( - recent, - { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, - expect.any(AbortSignal), - ) - const body = server.requests[0] as { messages: unknown[] } - expect(body.messages[0]).toMatchObject({ - role: 'user', - content: [ - { type: 'text', text: expect.stringContaining(`image omitted to fit request image limits; ${old.attachmentId}`) as string }, - { type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string }, - { type: 'file', file_id: 'file-api-1' }, - ], - }) - }) - - it('projects nested tool-result images with route-owned request budgets', async () => { - const server = await mockServer([ - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - ]) - const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) - const adapter = adapterOf({ - baseURL: server.url, - models: [ - { - id: 'vision-low', - inputModalities: ['text', 'image'], - imagePixelBudget: 'low', - imageMaxBytes: 512_000, - }, - { - id: 'vision-custom', - inputModalities: ['text', 'image'], - imagePixelBudget: 320_000, - }, - ], - }, attachmentMocks.store) - const nested = createToolResultMessage({ - callId: ToolCallId('image-result'), - content: [{ type: 'image', attachment: imageRef }], - isError: false, - }) - - await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-low', messages: [nested] })) - await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-custom', messages: [nested] })) - - expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( - 1, - imageRef, - { width: 1, height: 1, maxBytes: 512_000 }, - expect.any(AbortSignal), - ) - expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( - 2, - imageRef, - { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, - expect.any(AbortSignal), - ) - }) - - it('reuses the exact request version between agent and compaction calls', async () => { - const server = await mockServer([ - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - const messages = [createUserMessage({ - content: [{ type: 'image' as const, attachment: imageRef }], - source: { kind: 'test' as const }, - })] - - await drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-flash-vision-exp', messages })) - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages, - purpose: 'compaction', - })) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(1) - expect(server.requests).toMatchObject([ - { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-1' }] }] }, - { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-1' }] }] }, - ]) - expect(server.headers[1]?.['x-deepseek-harness-compact']).toBe('1') - }) - - it('explains a provider rejection of a normalized image and retains the raw response as cause', async () => { - const raw = JSON.stringify({ error: { message: 'unsupported image payload for file-api-1' } }) - const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - let failure: unknown - try { - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - } catch (error: unknown) { - failure = error - } - expect(failure).toMatchObject({ - code: 'INVALID_REQUEST', - message: expect.stringContaining( - `normalized image "${imageRef.attachmentId}" at message 1, image 1`, - ) as string, - cause: { message: raw }, - }) - expect((failure as Error).message).toContain('image/png, 8-bit sRGBA, 1x1') - expect((failure as Error).message).toContain('unsupported image payload for file-api-1') - expect((failure as Error).message).not.toBe(raw) - }) - - it('identifies the sole image when a normalized rejection omits its file id', async () => { - const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) - const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ - message: expect.stringContaining(`normalized image "${imageRef.attachmentId}"`) as string, - }) - }) - - it('lists every candidate when a normalized multi-image rejection names no file id', async () => { - const secondRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), - } - const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) - const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) - const attachments = attachmentStoreOf((ref) => { - const first = ref.attachmentId === imageRef.attachmentId - return Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(first ? 'b' : 'd').repeat(64)}`), - attachment: first ? { ...ref, name: 'diagram.png' } : ref, - hasAlpha: false, - }) - }).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ - code: 'INVALID_REQUEST', - message: expect.stringContaining('Candidate images: "diagram.png"') as string, - cause: { message: raw }, - }) - }) - - it.each([ - 'file-api-1 expired', - 'file_id file-api-10 invalid; file_id file-api-1 expired', - 'file_id file-api-1 expired', - 'file_not_found', - 'file_id file-api-1 deleted', - 'invalid file_id file-api-1', - ])('reuploads once when chat rejects a Files API reference as %s', async (providerMessage) => { - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ error: { message: providerMessage } }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachmentMocks.store) - const options = { - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image' as const, attachment: imageRef }], - source: { kind: 'test' as const }, - })], - } - - await drain(adapter.stream(options)) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(2) - expect(server.requests).toMatchObject([ - { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-1' }] }] }, - { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-2' }] }] }, - ]) - expect(attachmentMocks.readImageRequest).toHaveBeenCalledTimes(1) - }) - - it('uses inline fallback when stale-id recovery cannot resolve a replacement file', async () => { - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ error: { message: 'file_id file-api-stale expired' } }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const files = fileStoreOf(() => Promise.reject(new Error('unused'))) - files.ensureUploaded - .mockResolvedValueOnce(fileReference('file-api-stale')) - .mockRejectedValueOnce(new LlmError('Files unavailable', 'SERVER')) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(files.invalidate).toHaveBeenCalledTimes(1) - expect(server.requests).toHaveLength(2) - expect(JSON.stringify(server.requests[0])).toContain('file-api-stale') - expect(JSON.stringify(server.requests[1])).toContain('image_url') - }) - - it('invalidates only the identified mapping when a multi-image request names one stale file id', async () => { - const secondRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), - } - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ error: { message: 'file_id file-api-2 expired' } }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), - })).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(3) - const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> - expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([{ type: 'file', file_id: 'file-api-1' }, { type: 'file', file_id: 'file-api-2' }]) - expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([{ type: 'file', file_id: 'file-api-1' }, { type: 'file', file_id: 'file-api-3' }]) - }) - - it('invalidates every listed missing file id and preserves unlisted mappings', async () => { - const secondRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), - } - const thirdRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), - } - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ - error: { - message: 'path.to.object[index]: the following file_ids do not exist or are not created under your account: ' - + 'file-api-1, file-api-3, file-api-unknown', - }, - }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf((ref) => { - let digest = 'f' - if (ref.attachmentId === imageRef.attachmentId) digest = 'b' - else if (ref.attachmentId === secondRef.attachmentId) digest = 'd' - return Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${digest.repeat(64)}`), - }) - }).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - { type: 'image', attachment: thirdRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(5) - const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> - expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([ - { type: 'file', file_id: 'file-api-1' }, - { type: 'file', file_id: 'file-api-2' }, - { type: 'file', file_id: 'file-api-3' }, - ]) - expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([ - { type: 'file', file_id: 'file-api-4' }, - { type: 'file', file_id: 'file-api-2' }, - { type: 'file', file_id: 'file-api-5' }, - ]) - }) - - it('invalidates every used mapping when a stale-file response does not identify one file id', async () => { - const secondRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), - } - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ error: { message: 'file reference expired' } }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), - })).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(4) - const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> - expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([{ type: 'file', file_id: 'file-api-3' }, { type: 'file', file_id: 'file-api-4' }]) - }) - - it('returns the second stale-file rejection without a third chat attempt', async () => { - const stale = JSON.stringify({ error: { message: 'file_id file-api-1 expired' } }) - const server = await mockServer([ - { kind: 'http-error', status: 400, body: stale }, - { kind: 'http-error', status: 400, body: stale }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'INVALID_REQUEST', message: 'file_id file-api-1 expired' }) - expect(server.requests).toHaveLength(2) - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(2) - }) - - it.each(['deepseek-v4-flash', 'unlisted-pass-through'])( - 'rejects image input for text-only model %s before credentials, attachments, or fetch', - async (model) => { - const server = await mockServer([]) - const resolveApiKey = vi.fn(() => Promise.resolve('k')) - const resolveAttachments = vi.fn(() => ({}) as AttachmentStore) - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey, - resolveUserId: () => TEST_USER_ID, - resolveAttachments, - prepareExtensions: noExtensions, - }) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model, - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(resolveApiKey).not.toHaveBeenCalled() - expect(resolveAttachments).not.toHaveBeenCalled() - expect(server.requests).toHaveLength(0) - }, - ) - - it('rejects vision input without an attachment provider before credentials or fetch', async () => { - const server = await mockServer([]) - const resolveApiKey = vi.fn(() => Promise.resolve('k')) - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ - protocol: 'chat-completions', - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }), - resolveApiKey, - resolveUserId: () => TEST_USER_ID, - prepareExtensions: noExtensions, - }) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(resolveApiKey).not.toHaveBeenCalled() - expect(server.requests).toHaveLength(0) - }) - - it('streams raw chunks through ctx.llm.stream', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }]) - const ctx = await harness(server.url) - - const kinds: string[] = [] - for await (const chunk of ctx.llm.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) { - kinds.push(chunk.type) - } - expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) - }) - - it('forwards the harness user and session ids for host-side trajectory routing', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url) - - await assemble(ctx, { - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - sessionId: SessionId('child-session'), - }) - - expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session') - expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) - }) - - it('marks the auxiliary compaction call on the wire', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url) - - await assemble(ctx, { - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - purpose: 'compaction', - }) - - expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1') - }) - - it('switches dynamically from the configured low default through off to max', async () => { - const server = await mockServer([ - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - ]) - const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'low' }) - - await assemble(ctx,{ - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - await assemble(ctx,{ - model: 'deepseek-v4-flash', - reasoningEffort: ReasoningEffortId('off'), - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi again' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - await assemble(ctx,{ - model: 'deepseek-v4-flash', - reasoningEffort: ReasoningEffortId('max'), - messages: [createUserMessage({ - content: [{ type: 'text', text: 'one more time' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - expect(server.requests[0]).toMatchObject({ - thinking: { type: 'enabled' }, - reasoning_effort: 'low', - }) - expect(server.requests[1]).toMatchObject({ - thinking: { type: 'disabled' }, - }) - expect(server.requests[1]).not.toHaveProperty('reasoning_effort') - expect(server.requests[2]).toMatchObject({ - thinking: { type: 'enabled' }, - reasoning_effort: 'max', - }) - }) - - it('uses the configured maxTokens default and preserves an explicit request cap', async () => { - const server = await mockServer([ - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - ]) - const ctx = await harness(server.url, { maxTokens: 32_000 }) - - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], maxTokens: 8_192 }) - - expect(server.requests[0]).toMatchObject({ max_tokens: 32_000 }) - expect(server.requests[1]).toMatchObject({ max_tokens: 8_192 }) - }) - - it('publishes only off and omits the wire effort when thinking is disabled', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url, { thinking: 'disabled' }) - - await assemble(ctx,{ - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - expect(server.requests[0]).toMatchObject({ - thinking: { type: 'disabled' }, - }) - expect(server.requests[0]).not.toHaveProperty('reasoning_effort') - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash')) - .resolves.toMatchObject({ - reasoning: { - efforts: [{ - id: ReasoningEffortId('off'), - name: 'Off', - description: 'Use for simple tasks that do not need reasoning.', - }], - defaultEffort: ReasoningEffortId('off'), - }, - }) - }) - - it('reports a per-request effort failure before I/O when thinking is disabled', async () => { - const server = await mockServer([]) - const ctx = await harness(server.url, { thinking: 'disabled' }) - - const result = await assemble(ctx, { - model: 'deepseek-v4-flash', - reasoningEffort: ReasoningEffortId('high'), - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - expect(result.finish).toMatchObject({ - kind: 'error', - failure: { code: 'UNSUPPORTED_REASONING_EFFORT' }, - }) - expect(server.requests).toHaveLength(0) - }) - - it.each(['high', 'max'])( - 'rejects direct adapter effort %s before I/O when thinking is disabled', - async (effort) => { - const server = await mockServer([]) - const adapter = adapterOf({ apiKey: 'test-key', baseURL: server.url, thinking: 'disabled' }) - - const stream = adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - reasoningEffort: ReasoningEffortId(effort), - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - await expect(async () => { - for await (const _chunk of stream) { /* drain */ } - }).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) - expect(server.requests).toHaveLength(0) - }, - ) - - it.each([ - [401, 'AUTH'], - [403, 'AUTH'], - [429, 'RATE_LIMIT'], - [400, 'INVALID_REQUEST'], - [500, 'SERVER'], - [503, 'SERVER'], - ])('maps HTTP %d to failure code %s with the body message', async (status, code) => { - const behavior: Behavior = { - kind: 'http-error', - status, - body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), - } - const server = await mockServer([behavior]) - const ctx = await harness(server.url) - const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toEqual({ - kind: 'error', - failure: { message: `failed with ${status}`, code, status }, - }) - }) - - it('uses the HTTP status as the cause when an error response has no body', async () => { - const server = await mockServer([{ kind: 'http-error', status: 500, body: '' }]) - const adapter = adapterOf({ baseURL: server.url }) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - messages: [], - }))).rejects.toMatchObject({ - code: 'SERVER', - cause: { message: 'DeepSeek HTTP 500' }, - }) - }) - - it('classifies an HTTP context-window failure with the canonical code', async () => { - const server = await mockServer([{ - kind: 'http-error', - status: 400, - body: JSON.stringify({ - error: { - message: 'This model maximum context length is 128000 tokens; your input exceeds that limit.', - type: 'invalid_request_error', - code: 'context_length_exceeded', - }, - }), - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toMatchObject({ - kind: 'error', - failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE }, - }) - }) - - it('retains status, Retry-After seconds, and provider request id as structured facts', async () => { - const server = await mockServer([{ - kind: 'http-error', - status: 429, - body: JSON.stringify({ error: { message: 'slow down' } }), - headers: { 'retry-after': '2', 'x-request-id': 'req-429' }, - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toEqual({ - kind: 'error', - failure: { - message: 'slow down', - code: 'RATE_LIMIT', - status: 429, - providerRetryAfterMs: 2_000, - requestId: ProviderRequestId('req-429'), - }, - }) - }) - - it('parses a future Retry-After HTTP date and the DeepSeek request-id fallback', async () => { - const now = 1_800_000_000_000 - const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now) - try { - const server = await mockServer([{ - kind: 'http-error', - status: 503, - body: JSON.stringify({ error: { message: 'come back later' } }), - headers: { - 'retry-after': new Date(now + 3_000).toUTCString(), - 'x-deepseek-request-id': 'deepseek-503', - }, - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toEqual({ - kind: 'error', - failure: { - message: 'come back later', - code: 'SERVER', - status: 503, - providerRetryAfterMs: 3_000, - requestId: ProviderRequestId('deepseek-503'), - }, - }) - } finally { - dateNow.mockRestore() - } - }) - - it('omits zero, non-finite, invalid, and past Retry-After values', async () => { - const values = [ - '0', - '9'.repeat(400), - 'not-a-date', - new Date(0).toUTCString(), - ] - for (const value of values) { - const server = await mockServer([{ - kind: 'http-error', - status: 429, - body: JSON.stringify({ error: { message: 'retry later' } }), - headers: { 'retry-after': value }, - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toEqual({ - kind: 'error', - failure: { message: 'retry later', code: 'RATE_LIMIT', status: 429 }, - }) - } - }) - - it('classifies only context-capacity HTTP 400 details as context overflow', () => { - expect(httpErrorCode(400, { message: 'request too large for model context' })) - .toBe(CONTEXT_WINDOW_EXCEEDED_CODE) - expect(httpErrorCode(400, { message: 'invalid input: temperature exceeds maximum allowed value' })) - .toBe('INVALID_REQUEST') - expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('INVALID_REQUEST') - }) - - it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => { - expect(httpErrorCode(429, { code: 'insufficient_quota', message: 'account credits exhausted' })) - .toBe(QUOTA_EXCEEDED_CODE) - expect(httpErrorCode(429, { message: 'request rate limit exceeded' })).toBe('RATE_LIMIT') - }) - - it('keeps the status-line message for JSON error bodies without a message', async () => { - const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }]) - const ctx = await harness(server.url) - const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish.kind).toBe('error') - if (result.finish.kind !== 'error') throw new Error('expected an error finish') - expect(result.finish.failure.code).toBe('SERVER') - expect(result.finish.failure.message).toMatch(/HTTP 500/) - }) - - it('keeps the status-line message for non-JSON error bodies', async () => { - const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }]) - const ctx = await harness(server.url) - const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish.kind).toBe('error') - if (result.finish.kind !== 'error') throw new Error('expected an error finish') - expect(result.finish.failure.code).toBe('SERVER') - expect(result.finish.failure.message).toMatch(/HTTP 502/) - }) - - it('maps unusual statuses to HTTP_', () => { - expect(httpErrorCode(418)).toBe('HTTP_418') - }) - - it('reports a transport failure with the endpoint in the message', async () => { - // Port 1 is reserved/unbound, so the service normalizes the fetch failure. - const ctx = await harness('http://127.0.0.1:1') - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toMatchObject({ - kind: 'error', - failure: { - code: 'TRANSPORT', - message: 'DeepSeek API request to http://127.0.0.1:1 failed', - }, - }) - }) - - it('classifies an aborted request as an aborted finish', async () => { - const controller = new AbortController() - controller.abort() - const ctx = await harness('http://127.0.0.1:1') - const result = await assemble(ctx, { - model: 'deepseek-v4-flash', - messages: [], - signal: controller.signal, - }) - expect(result.finish).toMatchObject({ kind: 'aborted', failure: { code: 'ABORTED' } }) - }) - - it('throws EMPTY_RESPONSE when the response has no body', async () => { - const adapter = adapterOf({ baseURL: 'http://127.0.0.1:1' }) - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(null, { status: 200 }), - ) - try { - const iterate = async (): Promise => { - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - } - await expect(iterate()).rejects.toThrow(/no response body/) - } finally { - fetchSpy.mockRestore() - } - }) - - it('classifies an abrupt body close as TRANSPORT', async () => { - const server = await mockServer([{ - kind: 'close-early', - events: ['{"choices":[{"delta":{"content":"par"}}]}'], - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish.kind).toBe('error') - if (result.finish.kind !== 'error') throw new Error('expected an error finish') - expect(result.finish.failure.code).toBe('TRANSPORT') - expect(result.finish.failure.message).toMatch(/^DeepSeek API stream from .* failed$/) - }) - - it('aborts mid-stream via the request signal', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 50 }]) - const ctx = await harness(server.url) - const controller = new AbortController() - - const pending = (async () => { - const chunks = [] - for await (const chunk of ctx.llm.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - messages: [], - signal: controller.signal, - })) { - chunks.push(chunk) - } - return chunks - })() - - setTimeout(() => { controller.abort() }, 30) - const chunks = await pending - expect(chunks).toHaveLength(1) - expect(chunks[0]?.type).toBe('finish') - if (chunks[0]?.type !== 'finish') throw new Error('expected a finish chunk') - expect(chunks[0].reason.kind).toBe('aborted') - if (chunks[0].reason.kind !== 'aborted') throw new Error('expected an aborted finish') - expect(chunks[0].reason.failure.code).toBe('ABORTED') - }) - - it('maps connection failures to TRANSPORT without losing the cause', async () => { - const cause = new TypeError('connection refused') - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause) - const adapter = adapterOf({ baseURL: 'https://example.invalid' }) - try { - const drain = async (): Promise => { - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - } - await expect(drain()).rejects.toMatchObject({ code: 'TRANSPORT', cause }) - } finally { - fetchSpy.mockRestore() - } - }) - - it('renders a non-Error transport rejection without losing its cause', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { - const failed = Promise.withResolvers() - failed.reject('offline') - return failed.promise - }) - const adapter = adapterOf({ baseURL: 'https://example.invalid' }) - try { - const drain = async (): Promise => { - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - } - await expect(drain()).rejects.toMatchObject({ - message: 'DeepSeek API request to https://example.invalid failed', - code: 'TRANSPORT', - cause: 'offline', - }) - } finally { - fetchSpy.mockRestore() - } - }) - - it('aborts the underlying body when the stream stays idle past its watchdog', async () => { - vi.useFakeTimers() - let stopped = false - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { - const signal = init?.signal - const body = new ReadableStream({ - start(controller) { - signal?.addEventListener('abort', () => { - stopped = true - controller.error(signal.reason) - }, { once: true }) - }, - }) - return Promise.resolve(new Response(body, { status: 200 })) - }) - const adapter = adapterOf({ - baseURL: 'https://example.invalid', - streamIdleTimeoutMs: 100, - }) - try { - const drain = (async () => { - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - })() - const rejected = expect(drain).rejects.toMatchObject({ code: 'TIMEOUT' }) - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(100) - await rejected - expect(stopped).toBe(true) - } finally { - fetchSpy.mockRestore() - } - }) - - it('keeps an idle provider read alive through SSE comments', async () => { - vi.useFakeTimers() - const encoder = new TextEncoder() - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { - const body = new ReadableStream({ - start(controller) { - setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 75) - setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 150) - setTimeout(() => { - controller.enqueue(encoder.encode(textEvents.map(event => `data: ${event}\n\n`).join(''))) - controller.close() - }, 225) - }, - }) - return Promise.resolve(new Response(body, { status: 200 })) - }) - const adapter = adapterOf({ - baseURL: 'https://example.invalid', - streamIdleTimeoutMs: 100, - }) - try { - const chunks: string[] = [] - const drain = (async () => { - for await (const chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { - chunks.push(chunk.type) - } - })() - await vi.advanceTimersByTimeAsync(75) - await vi.advanceTimersByTimeAsync(75) - await vi.advanceTimersByTimeAsync(75) - await expect(drain).resolves.toBeUndefined() - expect(chunks).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) - } finally { - fetchSpy.mockRestore() - } + it('rejects a successful response with no readable body', async () => { + vi.stubGlobal('fetch', async () => new Response(null, { status: 200 })) + await expect(chunks(adapter().stream(options()))).rejects.toMatchObject({ code: 'EMPTY_RESPONSE' }) }) }) -describe('plugin registration and config', () => { - it('defaults to Messages and resolves the selected protocol endpoint without rewriting overrides', () => { - expect(resolveAdapterOptions({})).toMatchObject({ protocol: 'messages', baseURL: 'https://api.deepseek.com/anthropic' }) - expect(resolveAdapterOptions({ protocol: 'chat-completions' })).toMatchObject({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com' }) - expect(resolveAdapterOptions({ protocol: 'messages' })).toMatchObject({ protocol: 'messages', baseURL: 'https://api.deepseek.com/anthropic' }) - for (const baseURL of ['https://gateway.example/custom/v1', 'https://gateway.example/v1/messages']) { - expect(resolveAdapterOptions({ protocol: 'messages', baseURL }).baseURL).toBe(baseURL) - } - expect(() => resolveAdapterOptions({ protocol: 'responses' } as unknown as LlmDeepSeek.Config)).toThrow(/protocol/) - }) - - it('keeps wire helpers off the package root', () => { - for (const helper of [ - 'httpErrorCode', - 'serializeMessages', - 'serializeRequest', - 'DONE', - 'parseSse', - 'mapFinishReason', - 'mapUsage', - 'translate', - ]) expect(LlmDeepSeek).not.toHaveProperty(helper) - }) - - it('registers the deepseek provider and unregisters on dispose (HMR safety)', async () => { - const server = await mockServer([]) - const ctx = new Context() +describe('Cordis provider composition', () => { + it('resolves an attachment service loaded after the adapter and maps its read-only path', async () => { + const http = await endpoint() + const { ctx, home } = await context() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') await ctx.plugin(LlmRuntime) - const fiber = await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: server.url, + await ctx.plugin(Messages, { baseURL: http.url }) + const model = 'deepseek-flash' + const price = () => ctx.llm.imageRequestPricing('deepseek-official', model)! + const dummy = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), width: 1, height: 1, bytes: 3, mediaType: 'image/png' as const } + expect(price().priceImages([{ type: 'image', attachment: dummy }])[0]?.text).toBeDefined() + await ctx.plugin(LocalAttachments, { dshHome: home }) + const attachment = await ctx.attachments.saveImage({ data: await readFile(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) + expect(price().priceImages([{ type: 'image', attachment }])[0]?.text).not.toContain('/mounted/image.png') + class MappedFiles extends Service { + constructor(context: Context) { super(context, 'fs') } + processPathFromHostPath(_path: string) { return '/mounted/image.png' } + } + await ctx.plugin(MappedFiles) + const message = user() + await chunks(ctx.llm.stream(options({ model, messages: [{ ...message, content: [...message.content, { type: 'image', attachment }] }] }))) + expect(JSON.stringify(http.requests[0]?.body)).toContain('/mounted/image.png') + expect(price().priceImages([{ type: 'image', attachment }])[0]?.text).toContain('/mounted/image.png') + }) + + async function boot(...args: Parameters) { + const http = await endpoint(...args) + const { ctx, home } = await context() + vi.stubEnv('DEEPSEEK_API_KEY', '') + await writeFile(join(home, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: stored-key\n', { mode: 0o600 }) + await writeFile(join(home, 'settings.yaml'), '{}\n') + const template = await readFile(new URL('fixtures/cordis.yml', import.meta.url), 'utf8') + await writeFile(join(home, 'cordis.yml'), template.replaceAll('{{endpoint}}', JSON.stringify(http.url)).replaceAll('{{settings}}', JSON.stringify(join(home, 'settings.yaml'))).replaceAll('{{credentials}}', JSON.stringify(join(home, '.credentials.yaml')))) + ctx.baseUrl = pathToFileURL(home).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-llm-deepseek', Messages], + ['@deepseek-ai/dsh-credentials-local', LocalCredentials], ['@deepseek-ai/dsh-settings-file', FileSettings], + ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], + ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], + ]) + // The importer supplies source modules while Loader still owns configuration and effects. + for (const name of modules.keys()) { + const directory = join(home, 'node_modules', name) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'package.json'), JSON.stringify({ name, version: '0.1.3-alpha.1', type: 'module' })) + } + ctx.loader.internal = sourceModuleLoader(async (name) => { + if (!modules.has(name)) throw new Error(`unexpected module ${name}`) + return modules.get(name) }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - expect(ctx.llm.listConfigurableProviders()).toEqual([{ - provider: 'deepseek-official', - displayName: 'DeepSeek', - settingsNs: 'llm-deepseek', - settingsPath: [], - }]) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(home, 'cordis.yml')).href } }) + await ctx.loader.await() + return { ctx, http } + } + + it.each([ + { model: MODEL, inHistory: false }, + { model: MODEL, inHistory: true }, + { model: 'deepseek-flash', inHistory: true }, + ])('updates, clears and restores prompts across continued and resumed sessions, model=$model in-history=$inHistory', async ({ model, inHistory }) => { + const { ctx, http } = await boot() + if (inHistory && model === MODEL) await ctx.settings.update(Messages.name, { models: [{ id: model, systemPromptUpdate: 'in-history' }] }) + let prompt = 'first prompt' + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ + ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], + })) + const agentOptions = { provider: 'deepseek-official', model } + const agent = await ctx.agentLoop.create(SessionId('prompt-update'), agentOptions) + await send(agent, 'first') + prompt = 'second prompt' + await send(agent, 'second') + const count = agent.session.snapshotEvents().filter(event => event.type === 'system/message').length + await send(agent, 'unchanged') + expect(agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(count) + prompt = '' + await send(agent, 'clear') + const { agent: resumed } = await ctx.agents.create({ sessionId: SessionId('prompt-resume'), agentOptions, + seed: [...agent.session.snapshotEvents()] }) + await send(resumed, 'resume cleared') + prompt = 'restored prompt' + await send(resumed, 'restore') + expect(http.requests.map(request => request.body.system)).toEqual(inHistory + ? ['first prompt', 'first prompt', 'first prompt', undefined, undefined, undefined] + : ['first prompt', 'second prompt', 'second prompt', undefined, undefined, 'restored prompt']) + for (const [index, request] of http.requests.entries()) { + const messages = request.body.messages as { role: string; content: unknown[] }[] + expect(messages.filter(message => message.role === 'assistant')).toHaveLength(index) + expect(messages.filter(message => message.role === 'system').map(message => message.content)).toEqual( + !inHistory ? [] : index === 1 || index === 2 ? [[{ type: 'text', text: 'second prompt' }]] + : index === 5 ? [[{ type: 'text', text: 'restored prompt' }]] : [], + ) + expect(JSON.stringify(messages.filter(message => message.role !== 'system'))).not.toMatch(/first prompt|second prompt|restored prompt/) + } + expect(resumed.session.requestContext()?.systemPromptUpdate).toBe(inHistory ? 'in-history' : undefined) + }) + + it.each([false, true])('continues and resumes sessions after a model capability change, in-history=%s', async (inHistory) => { + const { ctx, http } = await boot() + await ctx.settings.update('llm-deepseek', { baseURL: http.url, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) + let prompt = 'old prompt' + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ + ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], + })) + const selection: ModelSelectionRef = { current: { provider: 'deepseek-official', model: MODEL }, assembled: undefined } + const agent = await ctx.agentLoop.create(SessionId('capability-switch'), selection.current) + installModelSelection(agent.ctx, selection) + await send(agent, 'first') + prompt = 'current prompt' + await send(agent, 'second') + expect((http.requests[1]?.body.messages as { role: string }[]).filter(message => message.role === 'system')).toHaveLength(1) + const seed = [...agent.session.snapshotEvents()] + const saved = JSON.stringify(seed) + await ctx.settings.update(Messages.name, { models: [{ id: MODEL, ...inHistory ? { systemPromptUpdate: 'in-history' } : {} }] }) + selection.current = { provider: 'deepseek-official', model: MODEL } + await send(agent, 'switch') + const { agent: resumed } = await ctx.agents.create({ sessionId: SessionId('switch-resume'), agentOptions: selection.current, seed }) + await send(resumed, 'resume') + for (const request of http.requests.slice(2)) { + expect(request.path).toBe('/anthropic/v1/messages') + expect(request.body.system).toBe(inHistory ? 'old prompt' : 'current prompt') + const messages = request.body.messages as { role: string }[] + expect(messages.filter(message => message.role === 'assistant')).toHaveLength(2) + expect(messages.filter(message => message.role === 'system')).toHaveLength(inHistory ? 1 : 0) + expect(JSON.stringify(messages.filter(message => message.role !== 'system'))).not.toMatch(/old prompt|current prompt/) + } + expect(JSON.stringify(seed)).toBe(saved) + expect(agent.session.deriveMessages().filter(message => message.role === 'system')).toHaveLength(inHistory ? 2 : 1) + expect(resumed.session.deriveMessages().filter(message => message.role === 'system')).toHaveLength(inHistory ? 2 : 1) + }) + + it('maps multiple system snapshots on direct compaction calls to the latest prompt', async () => { + const { ctx, http } = await boot() + const history = [createSystemMessage('old'), user(), + createAssistantMessage({ content: [{ type: 'text', text: 'OK' }], source: { provider: 'deepseek-official', model: MODEL } }), + createSystemMessage('current'), user('summarize')] + const saved = JSON.stringify(history) + const response = await assemble(ctx.llm.stream(options({ messages: history, purpose: 'compaction' }))) + expect(response.assembler.finish.kind).toBe('stop') + expect(http.requests[0]?.body.system).toBe('current') + expect((http.requests[0]?.body.messages as { role: string }[]).map(message => message.role)).toEqual(['user', 'assistant', 'user']) + expect(JSON.stringify(history)).toBe(saved) + }) + + it('continues a recorded tool turn with a warning when its native replay version is unknown', async () => { + const { ctx, http } = await boot() + const warnings: unknown[][] = [] + ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } }) + const fixture = await readFile(new URL('../../../../snapshots/session/deepseek-messages-degraded-replay/session.v2.jsonl', import.meta.url), 'utf8') + const records = fixture.trim().split('\n').map(line => JSON.parse(line) as { type: string; data: { message?: Message } }) + const assistant = records.find(record => record.type === 'assistant/message')!.data.message! + if (assistant.source.kind === 'model') assistant.source.provider = 'deepseek-official' + // The released v2 row still wraps its tool result inside a user message. + const released = object(records.find(record => record.type === 'tool/result')!.data.message) + if (!Array.isArray(released.content)) throw new Error('fixture lacks released tool content') + const releasedBlock = object(released.content[0]) + if (typeof releasedBlock.toolCallId !== 'string' || !Array.isArray(releasedBlock.content)) { + throw new Error('fixture lacks released tool result') + } + const content = releasedBlock.content.map((value) => { + const block = object(value) + if (block.type !== 'text' || typeof block.text !== 'string') throw new Error('fixture tool result must contain text') + return { type: 'text' as const, text: block.text } + }) + const result = createToolResultMessage({ + callId: ToolCallId(releasedBlock.toolCallId), content, isError: releasedBlock.isError === true, + }) + const saved = JSON.stringify([assistant, result]) + const response = await assemble(ctx.llm.stream(options({ messages: [user(), assistant, result] }))) + expect(response.assembler.finish.kind).toBe('stop') + expect(warnings).toEqual([[`llm-deepseek: unusable Messages replay state on assistant history for route "deepseek-official/${MODEL}"; sending provider-neutral content (DeepSeek Messages replay: unsupported kind or version)`]]) + expect(http.requests).toHaveLength(1) + expect(http.requests[0]?.body.messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [ + { type: 'thinking', thinking: 'The user wants me to run a simple bash command and then reply with "DONE".' }, + { type: 'tool_use', id: 'call_00_fkbBRJsUrGKd1pWVc4Gn8233', name: 'bash', input: { command: 'echo TERMINAL_OK', description: 'Echo TERMINAL_OK to verify terminal access' } }, + ] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_00_fkbBRJsUrGKd1pWVc4Gn8233', content: [{ type: 'text', text: 'TERMINAL_OK\n' }], is_error: false }] }, + ]) + expect(JSON.stringify([assistant, result])).toBe(saved) + }) + + it('loads one provider from YAML, rotates settings and credentials, then removes disposed registrations', async () => { + const { ctx, http } = await boot() + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek-official']) + expect((await assemble(ctx.llm.stream(options()))).assembler.finish.kind).toBe('stop') + expect(http.requests[0]?.headers['x-api-key']).toBe('stored-key') + const second = await endpoint() + await ctx.settings.update(Messages.name, { baseURL: second.url, maxTokens: 51, retryPolicy: { mode: 'always' } }) + await ctx.credentials.set(credentialRef('DEEPSEEK_API_KEY'), 'rotated') + await chunks(ctx.llm.stream(options())) + expect(second.requests[0]).toMatchObject({ headers: { 'x-api-key': 'rotated' }, body: { max_tokens: 51 } }) + await ctx.settings.update(Messages.name, { models: [{ id: 'duplicate' }, { id: 'duplicate' }], baseURL: http.url }) + await chunks(ctx.llm.stream(options())) + expect(second.requests).toHaveLength(2) + expect(http.requests).toHaveLength(1) + await ctx.settings.update(Messages.name, { models: [{ id: MODEL }], baseURL: http.url }) + await chunks(ctx.llm.stream(options())) + expect(http.requests).toHaveLength(2) + const llm = ctx.llm + await ctx.fiber.dispose() + expect(llm.listProviders()).toEqual([]) + expect(llm.listConfigurableProviders()).toEqual([]) + }) + + it('uses environment credentials and reports missing or malformed keys without network access', async () => { + const http = await endpoint() + const { ctx } = await context() + vi.stubEnv('DEEPSEEK_BASE_URL', http.url) + vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') + await ctx.plugin(LlmRuntime) + const fiber = ctx.plugin(Messages) + await fiber + await chunks(ctx.llm.stream(options())) + expect(http.requests[0]?.headers['x-api-key']).toBe('env-key') + vi.stubEnv('DEEPSEEK_API_KEY', '') + expect((await assemble(ctx.llm.stream(options()))).assembler.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) + vi.stubEnv('DEEPSEEK_API_KEY', 'bad\nkey') + expect((await assemble(ctx.llm.stream(options()))).assembler.finish).toMatchObject({ kind: 'error', failure: { code: 'INVALID_CREDENTIAL' } }) await fiber.dispose() expect(ctx.llm.listProviders()).toEqual([]) - expect(ctx.llm.listConfigurableProviders()).toEqual([]) - }) - - it('registers retryPolicy from the provider config', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - retryPolicy: { - mode: 'always', - backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, - }, - }) - - expect(ctx.llm.providerRetryPolicy('deepseek-official')).toEqual({ - mode: 'always', - initialDelayMs: 25, - maxDelayMs: 100, - jitterRatio: 0.2, - }) - }) - - it('owns the deepseek provider and advertises the default models', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, - { - provider: 'deepseek-official', - id: 'deepseek-v4-pro', - name: 'DeepSeek-V4-Pro', - description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', - inputModalities: ['text'], - }, - ]) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')) - .resolves.toMatchObject({ - provider: 'deepseek-official', - id: 'deepseek-flash', - name: 'DeepSeek-V41-Flash', - inputModalities: ['text', 'image'], - systemPromptUpdate: 'in-history', - context: { contextWindow: 1_000_000 }, - defaultMaxTokens: 256_000, - reasoning: { - efforts: [ - { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, - { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, - { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, - { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, - ], - defaultEffort: ReasoningEffortId('high'), - }, - }) - }) - - it('keeps deepseek-v4-pro available with its V4 capabilities', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - const info = await ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-pro') - expect(info).toMatchObject({ - id: 'deepseek-v4-pro', - inputModalities: ['text'], - context: { contextWindow: 1_000_000 }, - defaultMaxTokens: 256_000, - }) - expect(info?.systemPromptUpdate).toBeUndefined() - }) - - it.each(['off', 'low', 'max'] as const)('uses the configured %s reasoning default', async (effort) => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - reasoningEffort: effort, - }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) - .resolves.toMatchObject({ - reasoning: { - efforts: [ - { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, - { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, - { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, - { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, - ], - defaultEffort: ReasoningEffortId(effort), - }, - }) - }) - - it('accepts off as the default when thinking is deployment-disabled', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - thinking: 'disabled', - reasoningEffort: 'off', - }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) - .resolves.toMatchObject({ - reasoning: { - efforts: [{ - id: ReasoningEffortId('off'), - name: 'Off', - description: 'Use for simple tasks that do not need reasoning.', - }], - defaultEffort: ReasoningEffortId('off'), - }, - }) - }) - - it.each(['low', 'high', 'max'] as const)( - 'rejects configured reasoning effort %s when thinking is disabled', - async (reasoningEffort) => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - thinking: 'disabled', - reasoningEffort, - })).rejects.toThrow(/only reasoningEffort "off"/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it.each(['low', 'high', 'max'] as const)( - 'rejects disabled-thinking effort %s at the resolver boundary', - (reasoningEffort) => { - expect(() => resolveAdapterOptions({ thinking: 'disabled', reasoningEffort })) - .toThrow(/only reasoningEffort "off"/) - }, - ) - - it('accepts disabled thinking with off at the resolver boundary', async () => { - const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' }) - await expect(adapter.resolveModel('deepseek-official', 'pass-through')).resolves.toMatchObject({ - reasoning: { - efforts: [{ - id: ReasoningEffortId('off'), - name: 'Off', - description: 'Use for simple tasks that do not need reasoning.', - }], - defaultEffort: ReasoningEffortId('off'), - }, - }) - }) - - it('uses the default model catalog when apply is called directly', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, - { - provider: 'deepseek-official', - id: 'deepseek-v4-pro', - name: 'DeepSeek-V4-Pro', - description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', - inputModalities: ['text'], - }, - ]) - }) - - it('defaults an adapter-supplied catalog entry to text input', async () => { - const connection = resolveAdapterOptions({ models: [] }) - const adapter = new DeepSeekAdapter({ - options: () => ({ ...connection, models: [{ id: 'adapter-model' }] }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: noExtensions, - }) - await expect(adapter.listModels('deepseek-official')).resolves.toEqual([{ - provider: 'deepseek-official', - id: 'adapter-model', - name: 'adapter-model', - inputModalities: ['text'], - }]) - }) - - it('advertises configured models without restricting arbitrary request ids', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - models: [ - { id: 'private-fast', contextWindow: 32_000 }, - { - id: 'private-reasoner', - name: 'Private Reasoner', - description: 'Higher reasoning budget', - contextWindow: 64_000, - inputModalities: ['text', 'image'], - }, - ], - }) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] }, - { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text', 'image'] }, - ]) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-fast')) - .resolves.toMatchObject({ context: { contextWindow: 32_000 } }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-reasoner')) - .resolves.toMatchObject({ - name: 'Private Reasoner', - description: 'Higher reasoning budget', - inputModalities: ['text', 'image'], - }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'arbitrary-unlisted')) - .resolves.toMatchObject({ - context: { contextWindow: 1_000_000 }, - defaultMaxTokens: 256_000, - }) - }) - - it('uses exact model capacity before the adapter-wide default', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - defaultContextWindow: 256_000, - models: [ - { id: 'inherits-default' }, - { id: 'exact-override', contextWindow: 64_000 }, - ], - }) - - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'inherits-default')) - .resolves.toMatchObject({ context: { contextWindow: 256_000 } }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'exact-override')) - .resolves.toMatchObject({ context: { contextWindow: 64_000 } }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) - .resolves.toMatchObject({ context: { contextWindow: 256_000 } }) - }) - - it('allows an explicit empty model catalog', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - models: [], - }) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([]) - }) - - const invalidModels: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [ - [[{ id: '' }], /ids must be non-empty/], - [[{ id: 'm', name: '' }], /empty name/], - [[{ id: 'm', contextWindow: 0 }], /contextWindow/], - [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/], - [[{ id: 'm', inputModalities: [] }], /inputModalities/], - [[{ id: 'm', inputModalities: ['text', 'text'] }], /inputModalities must not contain duplicates/], - [[{ - id: 'm', - inputModalities: ['audio'] as unknown as NonNullable, - }], /expected "text" \| "image"/], - [[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/], - ] - - it.each(invalidModels)('rejects invalid advisory model config', async (models, message) => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - models: [...models], - })).rejects.toThrow(message) - expect(ctx.llm.listProviders()).toEqual([]) - }) - - const invalidProgrammaticModalities: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [ - [[{ id: 'm', inputModalities: [] }], /inputModalities must not be empty/], - [[{ - id: 'm', - inputModalities: ['audio'] as unknown as NonNullable, - }], /inputModalities must contain only "text" and "image"/], - ] - - it.each(invalidProgrammaticModalities)('rejects programmatic modality config that bypasses the schema', (models, message) => { - expect(() => resolveAdapterOptions({ models: [...models] })).toThrow(message) - }) - - it('rejects the removed imageDetail model setting through schema and direct construction', async () => { - const legacyModel = { id: 'vision', inputModalities: ['image'], imageDetail: 'low' } as unknown as - LlmDeepSeek.DeepSeekCatalogModel - expect(() => resolveAdapterOptions({ models: [legacyModel] })).toThrow(/imageDetail is no longer supported/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - models: [legacyModel], - })).rejects.toThrow(/imageDetail is no longer supported/) - expect(ctx.llm.listProviders()).toEqual([]) - }) - - it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => { - expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] })) - .toThrow(/maxTokens must be a positive integer/) - }) - - it('surfaces a catalog model\'s in-history system prompt update mode and rejects any other mode', async () => { - const adapter = adapterOf({ models: [ - { id: 'capable', systemPromptUpdate: 'in-history' }, - { id: 'plain' }, - ] }) - await expect(adapter.resolveModel('deepseek-official', 'capable')) - .resolves.toMatchObject({ systemPromptUpdate: 'in-history' }) - await expect(adapter.resolveModel('deepseek-official', 'plain')) - .resolves.not.toHaveProperty('systemPromptUpdate') - await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) - .resolves.not.toHaveProperty('systemPromptUpdate') - expect(() => resolveAdapterOptions({ - models: [{ id: 'bogus', systemPromptUpdate: 'leading' as unknown as 'in-history' }], - })).toThrow(/systemPromptUpdate must be "in-history" when present/) - }) - - it('rejects image request limits on a text-only catalog model', () => { - expect(() => resolveAdapterOptions({ - models: [{ id: 'text-only', inputModalities: ['text'], imagePixelBudget: 1 }], - })).toThrow(/text-only catalog model .* cannot declare image request limits/) - }) - - it.each([ - ['imagePixelBudget', 0, /imagePixelBudget must be "low" or a positive safe integer/], - ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be "low" or a positive safe integer/], - ['imagePixelBudget', 'auto', /imagePixelBudget must be "low" or a positive safe integer/], - ['imageMaxBytes', 0, /imageMaxBytes must be a positive safe integer/], - ['imageMaxBytes', 1.5, /imageMaxBytes must be a positive safe integer/], - ] as const)('rejects per-model %s=%s', (field, value, message) => { - expect(() => resolveAdapterOptions({ - models: [{ id: 'vision', inputModalities: ['image'], [field]: value }], - })).toThrow(message) - }) - - it('prefers a model\'s own output cap over the profile default', async () => { - // The profile default stays what an unlisted or uncapped model resolves - // to, so adding a per-model cap changes one model rather than the route. - const adapter = adapterOf({ maxTokens: 4096, models: [ - { id: 'capped', maxTokens: 512 }, - { id: 'uncapped' }, - ] }) - await expect(adapter.resolveModel('deepseek-official', 'capped')) - .resolves.toMatchObject({ defaultMaxTokens: 512 }) - await expect(adapter.resolveModel('deepseek-official', 'uncapped')) - .resolves.toMatchObject({ defaultMaxTokens: 4096 }) - await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) - .resolves.toMatchObject({ defaultMaxTokens: 4096 }) - }) - - it('rejects invalid context capacity when apply is called directly', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - expect(() => { - LlmDeepSeek.apply(ctx, { - baseURL: 'http://127.0.0.1:1', - models: [{ id: 'invalid-context', contextWindow: 0 }], - }) - }).toThrow(/contextWindow must be a positive integer/) - expect(ctx.llm.listProviders()).toEqual([]) - }) - - it.each([0, 1.5])( - 'rejects invalid adapter-wide default context capacity %s', - async (defaultContextWindow) => { - expect(() => resolveAdapterOptions({ defaultContextWindow })) - .toThrow(/defaultContextWindow must be a positive integer/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - defaultContextWindow, - })).rejects.toThrow(/defaultContextWindow/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( - 'rejects invalid adapter-wide maxTokens %s', - async (maxTokens) => { - expect(() => resolveAdapterOptions({ maxTokens })) - .toThrow(/maxTokens must be a positive safe integer/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - maxTokens, - })).rejects.toThrow(/maxTokens/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it('rejects offload quanta larger than their request bounds', () => { - expect(() => resolveAdapterOptions({ - maxRequestFilesBytes: 10, - imageOffloadByteQuantum: 11, - })).toThrow(/imageOffloadByteQuantum must not exceed maxRequestFilesBytes/) - expect(() => resolveAdapterOptions({ - maxInlineRequestImageBytes: 10, - inlineImageOffloadByteQuantum: 11, - })).toThrow(/inlineImageOffloadByteQuantum must not exceed maxInlineRequestImageBytes/) - expect(() => resolveAdapterOptions({ - maxImagesPerRequest: 10, - imageOffloadCountQuantum: 11, - })).toThrow(/imageOffloadCountQuantum must not exceed maxImagesPerRequest/) - }) - - it.each([ - ['maxImagesPerRequest', 0, /maxImagesPerRequest must be a positive safe integer/], - ['maxImagesPerRequest', 1.5, /maxImagesPerRequest must be a positive safe integer/], - ['imageOffloadByteQuantum', 0, /imageOffloadByteQuantum must be a positive safe integer/], - ['imageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /imageOffloadByteQuantum must be a positive safe integer/], - ['inlineImageOffloadByteQuantum', 0, /inlineImageOffloadByteQuantum must be a positive safe integer/], - ['inlineImageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /inlineImageOffloadByteQuantum must be a positive safe integer/], - ['imageOffloadCountQuantum', 0, /imageOffloadCountQuantum must be a positive safe integer/], - ['imageOffloadCountQuantum', 1.5, /imageOffloadCountQuantum must be a positive safe integer/], - ['fileExpiresAfterSeconds', 3_599, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], - ['fileExpiresAfterSeconds', 2_592_001, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], - ['fileRefreshMarginSeconds', -1, /fileRefreshMarginSeconds must be a non-negative integer/], - ['fileRefreshMarginSeconds', 604_800, /fileRefreshMarginSeconds must be a non-negative integer/], - ['fileQuotaCleanupBatch', 0, /fileQuotaCleanupBatch must be an integer from 1 through 1000/], - ['fileQuotaCleanupBatch', 1_001, /fileQuotaCleanupBatch must be an integer from 1 through 1000/], - ] as const)('rejects %s=%s', (field, value, message) => { - expect(() => resolveAdapterOptions({ [field]: value })).toThrow(message) - }) - - it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( - 'rejects invalid request file bound %s', - async (maxRequestFilesBytes) => { - expect(() => resolveAdapterOptions({ maxRequestFilesBytes })) - .toThrow(/maxRequestFilesBytes must be a positive safe integer/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - maxRequestFilesBytes, - })).rejects.toThrow(/maxRequestFilesBytes/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( - 'rejects invalid inline request image bound %s', - async (maxInlineRequestImageBytes) => { - expect(() => resolveAdapterOptions({ maxInlineRequestImageBytes })) - .toThrow(/maxInlineRequestImageBytes must be a positive safe integer/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - maxInlineRequestImageBytes, - })).rejects.toThrow(/maxInlineRequestImageBytes/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') - vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - }) - - it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', '') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - // First-boot onboarding: the route registers so models stay discoverable; - // only the request itself needs a key. - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) - const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) - expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) - // The guidance leads with the managed credential store. - const second = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) - expect(second.finish.kind).toBe('error') - if (second.finish.kind !== 'error') throw new Error('expected an error finish') - // The guidance names both places a credential can come from, and nothing - // else: configuration carries the reference, never a literal key. - expect(second.finish.failure.message) - .toMatch(/store DEEPSEEK_API_KEY through the credentials service.*export DEEPSEEK_API_KEY/s) - }) - - it('reads the ambient variable when no credentials seam is mounted', async () => { - // The plain cordis.yml composition: no credential provider, the key in - // the launching environment. - vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: server.url }) - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') - }) - - it('treats an empty ambient variable as no key when no credentials seam is mounted', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', '') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) - }) - - it('prefers explicit config over env for key and base URL', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') - vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1') - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url) // harness passes explicit config - await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(server.requests).toHaveLength(1) // hit the explicit URL, not env - }) - - it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - vi.stubEnv('DEEPSEEK_BASE_URL', server.url) - vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(server.requests).toHaveLength(1) - }) - - - it('takes DEEPSEEK_BASE_URL from any environment layer, with explicit config still on top', () => { - const trusted = createLaunchEnvironmentSnapshot([ - { source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } }, - ]) - expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example') - // The product trusts the project it is launched in, so a checkout can - // point its own agent at the gateway that checkout is meant to use. - const project = createLaunchEnvironmentSnapshot([ - { source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://project.example' } }, - ]) - expect(resolveAdapterOptions({}, project).baseURL).toBe('https://project.example') - // An explicitly configured endpoint outranks every environment layer, so a - // stale shell value cannot rewrite a deployment's own gateway. - const shell = createLaunchEnvironmentSnapshot([ - { source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } }, - ]) - expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal') - }) - it('uses the public Chat base URL when selected without an endpoint override', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'k') - vi.stubEnv('DEEPSEEK_BASE_URL', undefined) - const ctx = new Context() - await ctx.plugin(LlmRuntime) - // Registration succeeds; no call is made (would hit api.deepseek.com). - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - }) - - it('adapter is constructible directly for embedding over the shared resolver', async () => { - const adapter = adapterOf() - expect(adapter).toBeInstanceOf(DeepSeekAdapter) - // Direct embedding shares the plugin's one resolve step, so it advertises - // the same default catalog instead of a divergent empty one. - await expect(adapter.listModels('deepseek-official')).resolves.toHaveLength(2) - }) - - it('resolves connection facts and the credential exactly once per stream call', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const options = vi.fn(() => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url })) - const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key')) - const resolveUserId = vi.fn(() => TEST_USER_ID) - const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId, prepareExtensions: noExtensions }) - - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - - expect(options).toHaveBeenCalledTimes(1) - expect(resolveApiKey).toHaveBeenCalledTimes(1) - expect(resolveUserId).toHaveBeenCalledTimes(1) - expect(server.headers[0]?.authorization).toBe('Bearer per-request-key') - }) - - it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => { - expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: Number.POSITIVE_INFINITY })) - .toThrow(/streamIdleTimeoutMs.*positive finite/) - expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) - .toThrow(/streamIdleTimeoutMs.*no greater/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - streamIdleTimeoutMs: 0, - })).rejects.toThrow(/streamIdleTimeoutMs/) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, - })).rejects.toThrow(/streamIdleTimeoutMs/) - }) - - it('validates Files API timeout bounds independently of the stream idle deadline', async () => { - expect(() => resolveAdapterOptions({ filesApiTimeoutMs: Number.POSITIVE_INFINITY })) - .toThrow(/filesApiTimeoutMs.*positive finite/) - expect(() => resolveAdapterOptions({ filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) - .toThrow(/filesApiTimeoutMs.*no greater/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - filesApiTimeoutMs: 0, - })).rejects.toThrow(/filesApiTimeoutMs/) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1, - })).rejects.toThrow(/filesApiTimeoutMs/) - expect(resolveAdapterOptions({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 })) - .toMatchObject({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 }) - }) - - it('rejects invalid nested retryPolicy before registering the provider', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - retryPolicy: { mode: 'normal', maxRetries: -1 }, - })).rejects.toThrow(/retryPolicy/) - expect(ctx.llm.listProviders()).toEqual([]) }) }) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 7288bb9bfb..a1f55b5113 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -131,7 +131,7 @@ async function boot(dir: string, config: object): Promise { const settingsFiber = ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false }) await settingsFiber await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', ...config }) + await ctx.plugin(LlmDeepSeek, { ...config }) return { ctx, settingsFiber } } @@ -149,7 +149,7 @@ describe('request-level dynamic configuration', () => { const { ctx } = await boot(dir, { baseURL: serverA.url }) await prompt(ctx) - expect(serverA.headers[0]?.authorization).toBe('Bearer first-key') + expect(serverA.headers[0]?.['x-api-key']).toBe('first-key') await ctx.settings.update(NS, { baseURL: serverB.url }) await ctx.credentials.set(KEY_REF, 'second-key') @@ -157,7 +157,7 @@ describe('request-level dynamic configuration', () => { await prompt(ctx) // No restart, no re-registration: the next request resolved both facts. expect(serverA.requests).toHaveLength(1) - expect(serverB.headers[0]?.authorization).toBe('Bearer second-key') + expect(serverB.headers[0]?.['x-api-key']).toBe('second-key') }) it('starts keyless and serves the next request once the key arrives', async () => { @@ -171,7 +171,7 @@ describe('request-level dynamic configuration', () => { await expect(access(join(dir, '.anonymous-user-id'))).rejects.toMatchObject({ code: 'ENOENT' }) await ctx.credentials.set(KEY_REF, 'sk-arrived') await prompt(ctx) - expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') + expect(server.headers[0]?.['x-api-key']).toBe('sk-arrived') await expect(access(join(dir, '.anonymous-user-id'))).resolves.toBeUndefined() }) @@ -246,10 +246,10 @@ describe('request-level dynamic configuration', () => { const first = (server.requests[0] as { messages: Array<{ content: unknown }> }).messages[0]?.content const second = (server.requests[1] as { messages: Array<{ content: unknown }> }).messages[0]?.content expect(server.requests).toHaveLength(2) - expect(JSON.stringify(first).match(/"type":"file"/g)).toHaveLength(2) + expect(JSON.stringify(first).match(/"type":"image"/g)).toHaveLength(2) expect(JSON.stringify(second)).toContain('[image omitted to fit request image limits') expect(JSON.stringify(second)).toContain(MODEL_IMAGE_PATH) - expect(JSON.stringify(second).match(/"type":"file"/g)).toHaveLength(1) + expect(JSON.stringify(second).match(/"type":"image"/g)).toHaveLength(1) }) it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => { const dir = await home() @@ -309,7 +309,7 @@ describe('request-level dynamic configuration', () => { // regression this pins — not its key either. expect(rejected.requests).toHaveLength(0) expect(good.requests).toHaveLength(1) - expect(good.headers[0]?.authorization).toBe('Bearer good-key') + expect(good.headers[0]?.['x-api-key']).toBe('good-key') }) it('falls back to the composition entry when settings detach', async () => { @@ -327,6 +327,6 @@ describe('request-level dynamic configuration', () => { await settingsFiber.dispose() await prompt(ctx) expect(serverA.requests).toHaveLength(1) - expect(serverA.headers[0]?.authorization).toBe('Bearer steady-key') + expect(serverA.headers[0]?.['x-api-key']).toBe('steady-key') }) }) diff --git a/packages/llm/llm-deepseek/tests/egress.spec.ts b/packages/llm/llm-deepseek/tests/egress.spec.ts index 61629ef425..3a74a50cb8 100644 --- a/packages/llm/llm-deepseek/tests/egress.spec.ts +++ b/packages/llm/llm-deepseek/tests/egress.spec.ts @@ -51,19 +51,19 @@ afterAll(() => { rmSync(home, { recursive: true, force: true }) }) -/** Drive the shipping adapter's chat-completions request at an unresolvable endpoint. */ +/** Drive the shipping adapter's Messages request at an unresolvable endpoint. */ async function streamOnce(): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(DeepSeekLlmApiExtensionRegistry) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://deepseek-probe.invalid/v1', models: [{ id: 'm' }] }) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://deepseek-probe.invalid/v1', models: [{ id: 'm' }] }) for await (const _chunk of ctx.llm.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { // The endpoint never answers; the proxy record is the assertion. } } describe('llm-deepseek egress', () => { - it('sends the chat-completions request through the proxy', async () => { + it('sends the Messages request through the proxy', async () => { const observed = await observe(streamOnce) expect(observed.join('|')).toContain('deepseek-probe.invalid') }) diff --git a/packages/llm/llm-deepseek/tests/messages/expected/degraded-replay.json b/packages/llm/llm-deepseek/tests/expected/degraded-replay.json similarity index 100% rename from packages/llm/llm-deepseek/tests/messages/expected/degraded-replay.json rename to packages/llm/llm-deepseek/tests/expected/degraded-replay.json diff --git a/packages/llm/llm-deepseek/tests/messages/extensions.spec.ts b/packages/llm/llm-deepseek/tests/extensions.spec.ts similarity index 99% rename from packages/llm/llm-deepseek/tests/messages/extensions.spec.ts rename to packages/llm/llm-deepseek/tests/extensions.spec.ts index 29c2ce10c0..66256ce5df 100644 --- a/packages/llm/llm-deepseek/tests/messages/extensions.spec.ts +++ b/packages/llm/llm-deepseek/tests/extensions.spec.ts @@ -8,7 +8,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import type { DeepSeekLlmApiExtensionRequest } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import { SessionId } from '@deepseek-ai/dsh-session' -import * as DeepSeek from '../../src/index.ts' +import * as DeepSeek from '../src/index.ts' import { assemble, options, sse, textEvents } from './helpers.ts' declare module '@deepseek-ai/dsh-deepseek-llm-api-extensions' { diff --git a/packages/llm/llm-deepseek/tests/file-store.spec.ts b/packages/llm/llm-deepseek/tests/file-store.spec.ts index 10af67203a..a671607788 100644 --- a/packages/llm/llm-deepseek/tests/file-store.spec.ts +++ b/packages/llm/llm-deepseek/tests/file-store.spec.ts @@ -4,9 +4,9 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import { DeepSeekFileStore, MAX_IMAGE_BYTES } from '../src/common/file-store.ts' -import { DeepSeekFileId } from '../src/common/file-id.ts' -import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/common/upload-index.ts' +import { DeepSeekFileStore, MAX_IMAGE_BYTES } from '../src/file-store.ts' +import { DeepSeekFileId } from '../src/file-id.ts' +import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts' const REF: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), @@ -27,7 +27,7 @@ const VERSION: RequestImageAttachment = { space: 'srgb', hasAlpha: true, } -const CONNECTION = { protocol: 'chat-completions' as const, baseURL: 'https://api.deepseek.com', apiKey: 'key' } +const CONNECTION = { baseURL: 'https://api.deepseek.com', apiKey: 'key' } const POLICY = { expiresAfterSeconds: 604_800, refreshMarginSeconds: 3_600, quotaCleanupBatch: 100 } const NOW = 1_700_000_000_000 @@ -50,17 +50,16 @@ function uploadFetch(now: () => number = () => NOW) { const createdAt = now() / 1_000 return new Response(JSON.stringify({ id: `file-api-${uploads}`, - object: 'file', - bytes: 3, - created_at: createdAt, + type: 'file', + size_bytes: 3, + created_at: new Date(createdAt * 1_000).toISOString(), filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`, - purpose: 'user_data', - expires_at: createdAt + POLICY.expiresAfterSeconds, + mime_type: 'image/png', }), { status: 200 }) } if (init?.method === 'DELETE') { const id = requestUrl(_url).split('/').at(-1) - return new Response(JSON.stringify({ id, object: 'file', deleted: true }), { status: 200 }) + return new Response(JSON.stringify({ id, type: 'file_deleted' }), { status: 200 }) } throw new Error('unexpected Files API request') }) as typeof fetch @@ -68,42 +67,34 @@ function uploadFetch(now: () => number = () => NOW) { } describe('DeepSeekFileStore', () => { - it('separates native Files reuse, invalidation, and expiry from the chat namespace', async () => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-native-file-store-')) + it('reuses equivalent API roots and isolates other endpoints across invalidation and expiry', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-scope-')) roots.push(dir) let now = NOW - let uploads = 0 - const fetchImpl: typeof fetch = async (input, init) => { - if (init?.method !== 'POST') throw new Error('only uploads expected') - uploads++ - const common = { id: `file-${uploads}`, filename: 'dsh-image.png' } - return new Response(JSON.stringify(requestUrl(input).includes('/v1/files') - ? { ...common, type: 'file', size_bytes: VERSION.bytes, created_at: new Date(now).toISOString(), mime_type: VERSION.mediaType } - : { ...common, object: 'file', bytes: VERSION.bytes, created_at: now / 1_000, expires_at: now / 1_000 + POLICY.expiresAfterSeconds, purpose: 'user_data' })) - } + const remote = uploadFetch(() => now) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) - const store = new DeepSeekFileStore({ index, fetch: fetchImpl, now: () => now }) - const native = { ...CONNECTION, protocol: 'messages' as const } - const chat = await store.ensureUploaded(VERSION, CONNECTION, POLICY) - const first = await store.ensureUploaded(VERSION, native, POLICY) + const store = new DeepSeekFileStore({ index, fetch: remote.fetchImpl, now: () => now }) + const alternate = { ...CONNECTION, baseURL: 'https://other.example' } + const other = await store.ensureUploaded(VERSION, alternate, POLICY) + const first = await store.ensureUploaded(VERSION, CONNECTION, POLICY) expect(first.record.scope).toBe(deepSeekFileScope(`${CONNECTION.baseURL}/v1`, CONNECTION.apiKey)) - expect(first.record.scope).not.toBe(chat.record.scope) - expect((await store.ensureUploaded(VERSION, { ...native, baseURL: `${native.baseURL}/v1/` }, POLICY)).record).toEqual(first.record) - const reopened = new DeepSeekFileStore({ index, fetch: fetchImpl, now: () => now }) - expect((await reopened.ensureUploaded(VERSION, native, POLICY)).record).toEqual(first.record) - await reopened.invalidate(VERSION, chat.record.fileId, native) - expect((await reopened.ensureUploaded(VERSION, native, POLICY)).record).toEqual(first.record) - expect(uploads).toBe(2) - await reopened.invalidate(VERSION, first.record.fileId, native) - const replacement = await reopened.ensureUploaded(VERSION, native, POLICY) + expect(first.record.scope).not.toBe(other.record.scope) + expect((await store.ensureUploaded(VERSION, { ...CONNECTION, baseURL: `${CONNECTION.baseURL}/v1/` }, POLICY)).record).toEqual(first.record) + const reopened = new DeepSeekFileStore({ index, fetch: remote.fetchImpl, now: () => now }) + expect((await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)).record).toEqual(first.record) + await reopened.invalidate(VERSION, other.record.fileId, CONNECTION) + expect((await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)).record).toEqual(first.record) + expect(remote.uploads()).toBe(2) + await reopened.invalidate(VERSION, first.record.fileId, CONNECTION) + const replacement = await reopened.ensureUploaded(VERSION, CONNECTION, POLICY) expect(replacement.record.fileId).not.toBe(first.record.fileId) - expect((await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)).record).toEqual(chat.record) - expect(uploads).toBe(3) + expect((await reopened.ensureUploaded(VERSION, alternate, POLICY)).record).toEqual(other.record) + expect(remote.uploads()).toBe(3) now = replacement.record.expiresAt - POLICY.refreshMarginSeconds * 1_000 - const refreshed = await reopened.ensureUploaded(VERSION, native, POLICY) + const refreshed = await reopened.ensureUploaded(VERSION, CONNECTION, POLICY) expect(refreshed.record.fileId).not.toBe(replacement.record.fileId) expect(refreshed.record.expiresAt).toBe(now + POLICY.expiresAfterSeconds * 1_000) - expect(uploads).toBe(4) + expect(remote.uploads()).toBe(4) }) it('reclaims the oldest owned native file across descending pages before retrying an upload', async () => { @@ -135,7 +126,7 @@ describe('DeepSeekFileStore', () => { : { data: [file('oldest-owned', 3_000), file('foreign', 4_000, 'user-file.png')], has_more: false })) } const store = new DeepSeekFileStore({ index: new DeepSeekUploadIndex(join(dir, 'index.json')), fetch: fetchImpl, now: () => NOW }) - await expect(store.ensureUploaded(VERSION, { ...CONNECTION, protocol: 'messages' }, { ...POLICY, quotaCleanupBatch: 1 })).resolves.toMatchObject({ record: { fileId: 'file-uploaded' } }) + await expect(store.ensureUploaded(VERSION, CONNECTION, { ...POLICY, quotaCleanupBatch: 1 })).resolves.toMatchObject({ record: { fileId: 'file-uploaded' } }) expect(cursors).toEqual([null, 'middle']) expect(deleted).toEqual(['oldest-owned']) expect(uploads).toBe(2) @@ -192,12 +183,11 @@ describe('DeepSeekFileStore', () => { expect(uploadSignal?.aborted).toBe(false) complete?.(new Response(JSON.stringify({ id: 'file-api-shared', - object: 'file', - bytes: 3, - created_at: NOW / 1_000, + type: 'file', + size_bytes: 3, + created_at: new Date(NOW).toISOString(), filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`, - purpose: 'user_data', - expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds, + mime_type: 'image/png', }), { status: 200 })) await expect(completed).resolves.toMatchObject({ record: { fileId: 'file-api-shared' } }) }) @@ -273,9 +263,8 @@ describe('DeepSeekFileStore', () => { }) } return Promise.resolve(new Response(JSON.stringify({ - id: 'file-api-retry', object: 'file', bytes: 3, created_at: NOW / 1_000, - filename: 'dsh-retry.png', purpose: 'user_data', - expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds, + id: 'file-api-retry', type: 'file', size_bytes: 3, created_at: new Date(NOW).toISOString(), + filename: 'dsh-retry.png', mime_type: 'image/png', }), { status: 200 })) }) as typeof fetch const store = new DeepSeekFileStore({ @@ -295,11 +284,11 @@ describe('DeepSeekFileStore', () => { await expect(retried).resolves.toMatchObject({ record: { fileId: 'file-api-retry' } }) }) - it.each(['chat-completions', 'messages'] as const)('rejects a request version above the %s per-image limit before transport', async (protocol) => { + it('rejects a request version above the per-image limit before transport', async () => { const fetchImpl = vi.fn() as typeof fetch const store = new DeepSeekFileStore({ now: () => NOW, fetch: fetchImpl }) const oversized = { ...VERSION, bytes: MAX_IMAGE_BYTES + 1 } - await expect(store.ensureUploaded(oversized, { ...CONNECTION, protocol }, POLICY)) + await expect(store.ensureUploaded(oversized, CONNECTION, POLICY)) .rejects.toMatchObject({ code: 'INVALID_REQUEST', message: 'DeepSeek image exceeds the 32 MiB per-image limit.' }) expect(fetchImpl).not.toHaveBeenCalled() }) @@ -329,9 +318,8 @@ describe('DeepSeekFileStore', () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) roots.push(dir) const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ - id: 'file-api-wrong-size', object: 'file', bytes: 2, created_at: NOW / 1_000, - filename: 'dsh-wrong.png', purpose: 'user_data', - expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds, + id: 'file-api-wrong-size', type: 'file', size_bytes: 2, created_at: new Date(NOW).toISOString(), + filename: 'dsh-wrong.png', mime_type: 'image/png', }), { status: 200 }))) as typeof fetch const store = new DeepSeekFileStore({ index: new DeepSeekUploadIndex(join(dir, 'index.json')), @@ -377,7 +365,7 @@ describe('DeepSeekFileStore', () => { }) }) - it('reuses local expires_at above the refresh margin and uploads again at the margin', async () => { + it('reuses the local expiry above the refresh margin and uploads again at the margin', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) @@ -418,7 +406,7 @@ describe('DeepSeekFileStore', () => { vi.spyOn(index, 'commit').mockResolvedValue({ accepted: false, record: { - scope: deepSeekFileScope(CONNECTION.baseURL, CONNECTION.apiKey), + scope: deepSeekFileScope(`${CONNECTION.baseURL}/v1`, CONNECTION.apiKey), attachmentId: VERSION.attachment.attachmentId, variantId: VERSION.variantId, fileId: DeepSeekFileId('file-api-winner'), @@ -452,22 +440,20 @@ describe('DeepSeekFileStore', () => { error: { message: 'stored file quota exceeded', code: 'file_quota' }, }), { status: 400 })) return Promise.resolve(new Response(JSON.stringify({ - id: 'file-api-recovered', object: 'file', bytes: 3, created_at: NOW / 1_000, - filename: 'dsh-recovered.png', purpose: 'user_data', - expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds, + id: 'file-api-recovered', type: 'file', size_bytes: 3, created_at: new Date(NOW).toISOString(), + filename: 'dsh-recovered.png', mime_type: 'image/png', }), { status: 200 })) } if (init?.method === 'DELETE') { return Promise.resolve(new Response(JSON.stringify({ - id: 'file-api-old', object: 'file', deleted: true, + id: 'file-api-old', type: 'file_deleted', }), { status: 200 })) } - expect(new URL(requestUrl(input)).pathname).toBe('/files') + expect(new URL(requestUrl(input)).pathname).toBe('/v1/files') return Promise.resolve(new Response(JSON.stringify({ - object: 'list', data: [{ - id: 'file-api-old', object: 'file', bytes: 3, created_at: NOW / 1_000, - filename: 'dsh-old.png', purpose: 'user_data', + id: 'file-api-old', type: 'file', size_bytes: 3, created_at: new Date(NOW).toISOString(), + filename: 'dsh-old.png', mime_type: 'image/png', }], first_id: 'file-api-old', last_id: 'file-api-old', has_more: false, }), { status: 200 })) @@ -492,10 +478,9 @@ describe('DeepSeekFileStore', () => { error: { message: 'file count quota exceeded', code: 'file_quota' }, }), { status: 400 })) return Promise.resolve(new Response(JSON.stringify({ - object: 'list', data: [{ - id: 'file-api-foreign', object: 'file', bytes: 3, created_at: NOW / 1_000, - filename: 'foreign.png', purpose: 'user_data', + id: 'file-api-foreign', type: 'file', size_bytes: 3, created_at: new Date(NOW).toISOString(), + filename: 'foreign.png', mime_type: 'image/png', }], has_more: false, }), { status: 200 })) @@ -518,20 +503,19 @@ describe('DeepSeekFileStore', () => { if (init?.method === 'DELETE') { const id = target.pathname.split('/').at(-1) ?? '' deleted.add(id) - return new Response(JSON.stringify({ id, object: 'file', deleted: true }), { status: 200 }) + return new Response(JSON.stringify({ id, type: 'file_deleted' }), { status: 200 }) } - const after = target.searchParams.get('after') + const after = target.searchParams.get('after_id') if (after !== null && deleted.has(after)) throw new Error('deleted cursor cannot be reused') const id = after === null ? 'file-api-oldest' : 'file-api-next' return new Response(JSON.stringify({ - object: 'list', data: [{ id, - object: 'file', - bytes: 3, - created_at: NOW / 1_000, + type: 'file', + size_bytes: 3, + created_at: new Date(NOW).toISOString(), filename: `dsh-${id}.png`, - purpose: 'user_data', + mime_type: 'image/png', }], first_id: id, last_id: id, @@ -556,12 +540,12 @@ describe('DeepSeekFileStore', () => { const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => { if (init?.method === 'DELETE') { const id = requestUrl(input).split('/').at(-1) - return Promise.resolve(new Response(JSON.stringify({ id, object: 'file', deleted: true }), { status: 200 })) + return Promise.resolve(new Response(JSON.stringify({ id, type: 'file_deleted' }), { status: 200 })) } page += 1 const lastId = mode === 'missing' ? undefined : 'file-api-same' return Promise.resolve(new Response(JSON.stringify({ - object: 'list', data: [], has_more: true, + data: [], has_more: true, ...lastId === undefined ? {} : { last_id: lastId }, }), { status: 200 })) }) as typeof fetch diff --git a/packages/llm/llm-deepseek/tests/files-api.spec.ts b/packages/llm/llm-deepseek/tests/files-api.spec.ts index a23664c85c..daed0ebedf 100644 --- a/packages/llm/llm-deepseek/tests/files-api.spec.ts +++ b/packages/llm/llm-deepseek/tests/files-api.spec.ts @@ -2,13 +2,13 @@ import { once } from 'node:events' import { createServer } from 'node:http' import { describe, expect, it, vi } from 'vitest' import { LlmError, userAgent } from '@deepseek-ai/dsh-llm' -import { DeepSeekFileId } from '../src/common/file-id.ts' +import { DeepSeekFileId } from '../src/file-id.ts' import { DeepSeekFilesClient, DeepSeekFilesError, isFilesQuotaError, MAX_FILE_UPLOAD_BYTES, -} from '../src/common/files-api.ts' +} from '../src/files-api.ts' function requestUrl(input: string | URL | Request): string { if (typeof input === 'string') return input @@ -17,28 +17,19 @@ function requestUrl(input: string | URL | Request): string { function file(overrides: Record = {}) { return { - id: 'file-api-one', - object: 'file', - bytes: 3, - created_at: 1_700_000_000, - filename: 'image.png', - purpose: 'user_data', - expires_at: 1_700_604_800, + id: 'file-api-one', type: 'file', size_bytes: 3, + created_at: '2023-11-14T22:13:20.000Z', filename: 'image.png', mime_type: 'image/png', ...overrides, } } describe('DeepSeekFilesClient', () => { - function messagesFile(overrides: Record = {}) { - return { id: 'file-api-one', type: 'file', size_bytes: 3, created_at: '2026-09-12T13:02:46.677853363+00:00', filename: 'image.png', mime_type: 'image/png', ...overrides } - } - - describe.each(['messages', 'chat-completions'] as const)('%s successful response JSON', (protocol) => { + describe('successful response JSON', () => { it.each((['upload', 'list', 'retrieve', 'delete'] as const).flatMap(operation => ['', '{"id":'].map(body => ({ operation, body })), ))('rejects malformed JSON with operation and HTTP status: %j', async ({ operation, body }) => { const fetchImpl = vi.fn(async () => new Response(body, { status: 200 })) - const client = new DeepSeekFilesClient({ protocol, baseURL: 'https://files.example', apiKey: 'test-key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://files.example', apiKey: 'test-key', fetch: fetchImpl }) const operations = { upload: () => client.upload({ data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600 }), list: () => client.list(), @@ -59,7 +50,7 @@ describe('DeepSeekFilesClient', () => { it.each([new TypeError('body transport failed'), new DOMException('body aborted', 'AbortError')])( 'preserves a non-JSON body-read failure: %s', async (failure) => { - const client = new DeepSeekFilesClient({ protocol, baseURL: 'https://files.example', apiKey: 'test-key', + const client = new DeepSeekFilesClient({ baseURL: 'https://files.example', apiKey: 'test-key', fetch: async () => new Response(new ReadableStream({ start(controller) { controller.error(failure) } })), }) await expect(client.retrieve(DeepSeekFileId('file-api-one'))).rejects.toBe(failure) @@ -79,20 +70,20 @@ describe('DeepSeekFilesClient', () => { expect(form.get('purpose')).toBeNull() expect(form.get('expires_after[anchor]')).toBe('created_at') expect(form.get('expires_after[seconds]')).toBe('3600') - return new Response(JSON.stringify(messagesFile())) + return new Response(JSON.stringify(file())) }) as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example/custom/route///', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example/custom/route///', apiKey: 'key', fetch: fetchImpl }) const uploaded = await client.upload({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600 }) - const createdAt = Math.floor(Date.parse('2026-09-12T13:02:46.677Z') / 1_000) - expect(uploaded).toEqual({ id: 'file-api-one', bytes: 3, createdAt, filename: 'image.png', purpose: 'user_data', expiresAt: createdAt + 3_600 }) + const createdAt = Math.floor(Date.parse('2023-11-14T22:13:20.000Z') / 1_000) + expect(uploaded).toEqual({ id: 'file-api-one', bytes: 3, createdAt, filename: 'image.png', expiresAt: createdAt + 3_600 }) }) it.each([ ['https://provider.example/anthropic/v1', 'https://provider.example/anthropic/v1/files'], ['https://provider.example/v1beta/', 'https://provider.example/v1beta/v1/files'], ])('resolves the Messages Files endpoint from %s', async (baseURL, expected) => { - const fetchImpl = vi.fn(async () => new Response(JSON.stringify(messagesFile()))) - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL, apiKey: 'key', fetch: fetchImpl }) + const fetchImpl = vi.fn(async () => new Response(JSON.stringify(file()))) + const client = new DeepSeekFilesClient({ baseURL, apiKey: 'key', fetch: fetchImpl }) await client.upload({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, @@ -102,42 +93,42 @@ describe('DeepSeekFilesClient', () => { expect(requestUrl(fetchImpl.mock.calls[0]![0])).toBe(expected) }) - it('maps Messages list cursors, file metadata and deletion without OpenAI-only fields', async () => { + it('maps Messages list cursors, file metadata and deletion with native metadata', async () => { const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = new URL(requestUrl(input)) if (url.search) { expect(Object.fromEntries(url.searchParams)).toEqual({ after_id: 'file-before', limit: '1000' }) - return new Response(JSON.stringify({ data: [messagesFile()], first_id: 'file-api-one', last_id: 'file-api-one', has_more: false })) + return new Response(JSON.stringify({ data: [file()], first_id: 'file-api-one', last_id: 'file-api-one', has_more: false })) } expect(url.pathname).toBe('/anthropic/v1/files/file-api-one') - return new Response(JSON.stringify(init?.method === 'DELETE' ? { id: 'file-api-one', type: 'file_deleted' } : messagesFile())) + return new Response(JSON.stringify(init?.method === 'DELETE' ? { id: 'file-api-one', type: 'file_deleted' } : file())) }) as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://api.deepseek.com/anthropic', apiKey: 'key', fetch: fetchImpl }) - await expect(client.list({ after: DeepSeekFileId('file-before'), limit: 1_000, order: 'asc' })).resolves.toMatchObject({ data: [{ bytes: 3 }], hasMore: false }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com/anthropic', apiKey: 'key', fetch: fetchImpl }) + await expect(client.list({ after: DeepSeekFileId('file-before'), limit: 1_000 })).resolves.toMatchObject({ data: [{ bytes: 3 }], hasMore: false }) await expect(client.retrieve(DeepSeekFileId('file-api-one'))).resolves.toMatchObject({ id: 'file-api-one', bytes: 3 }) await expect(client.delete(DeepSeekFileId('file-api-one'))).resolves.toBeUndefined() }) it('accepts an empty Messages list with null cursors', async () => { - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example', apiKey: 'key', + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', apiKey: 'key', fetch: async () => new Response(JSON.stringify({ data: [], first_id: null, last_id: null, has_more: false })), }) await expect(client.list()).resolves.toEqual({ data: [], hasMore: false }) }) it.each(['first_id', 'last_id'])('rejects a Messages list with a numeric %s', async (cursor) => { - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example', apiKey: 'key', + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', apiKey: 'key', fetch: async () => new Response(JSON.stringify({ data: [], first_id: null, last_id: null, has_more: false, [cursor]: 1 })), }) await expect(client.list()).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) }) - it.for(['messages', 'chat-completions'] as const)('refuses a redirected %s Files request before contacting another origin', async (protocol, { onTestFinished }) => { + it('refuses a redirected Files request before contacting another origin', async ({ onTestFinished }) => { const forwarded: string[] = [] const origins: string[] = [] const destination = createServer((request, response) => { forwarded.push(String(request.headers['x-api-key'] ?? request.headers.authorization)) - response.end(JSON.stringify(protocol === 'messages' ? messagesFile() : file())) + response.end(JSON.stringify(file())) }) const source = createServer((_request, response) => { response.writeHead(307, { location: `${origins[0]}/file-api-one` }) @@ -159,37 +150,37 @@ describe('DeepSeekFilesClient', () => { if (address === null || typeof address === 'string') throw new Error('expected a TCP server address') origins.push(`http://127.0.0.1:${address.port}`) } - const client = new DeepSeekFilesClient({ protocol, baseURL: origins[1]!, apiKey: 'redirect-test-key' }) + const client = new DeepSeekFilesClient({ baseURL: origins[1]!, apiKey: 'redirect-test-key' }) const error = await client.retrieve(DeepSeekFileId('file-api-one')).catch((cause: unknown) => cause) expect(forwarded).toEqual([]) expect(error).toMatchObject({ code: 'TRANSPORT' }) }) it.each([null, [], { type: 'wrong' }, { mime_type: null }, { created_at: 'invalid' }, { created_at: 123 }, { size_bytes: -1 }])('rejects malformed Messages file metadata %#', async (value) => { - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example', apiKey: 'key', - fetch: async () => new Response(JSON.stringify(value === null || Array.isArray(value) ? value : messagesFile(value))), + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', apiKey: 'key', + fetch: async () => new Response(JSON.stringify(value === null || Array.isArray(value) ? value : file(value))), }) await expect(client.retrieve(DeepSeekFileId('file-api-one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) }) it('rejects a Messages deletion that returns a different file identity', async () => { - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example', apiKey: 'key', + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', apiKey: 'key', fetch: async () => new Response(JSON.stringify({ id: 'wrong', type: 'file_deleted' })), }) await expect(client.delete(DeepSeekFileId('file-api-one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) }) - it('uploads multipart bytes with the required purpose and explicit expiry', async () => { + it('uploads multipart bytes with attribution and explicit expiry', async () => { const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { - expect(requestUrl(url)).toBe('https://api.deepseek.com/files') + expect(requestUrl(url)).toBe('https://api.deepseek.com/v1/files') expect(init?.method).toBe('POST') const headers = new Headers(init?.headers) - expect(headers.get('authorization')).toBe('Bearer key') + expect(headers.get('x-api-key')).toBe('key') expect(headers.get('user-agent')).toBe(userAgent()) const form = init?.body expect(form).toBeInstanceOf(FormData) if (!(form instanceof FormData)) throw new Error('expected multipart body') - expect(form.get('purpose')).toBe('user_data') + expect(form.get('purpose')).toBeNull() expect(form.get('expires_after[anchor]')).toBe('created_at') expect(form.get('expires_after[seconds]')).toBe('604800') const blob = form.get('file') @@ -197,7 +188,7 @@ describe('DeepSeekFilesClient', () => { expect((blob as Blob).size).toBe(3) return new Response(JSON.stringify(file()), { status: 200 }) }) as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com/', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com/', apiKey: 'key', fetch: fetchImpl }) await expect(client.upload({ data: Uint8Array.of(1, 2, 3), @@ -209,7 +200,6 @@ describe('DeepSeekFilesClient', () => { bytes: 3, createdAt: 1_700_000_000, filename: 'image.png', - purpose: 'user_data', expiresAt: 1_700_604_800, }) }) @@ -219,40 +209,28 @@ describe('DeepSeekFilesClient', () => { const target = requestUrl(url) if (target.includes('?')) { return new Response(JSON.stringify({ - object: 'list', data: [file()], first_id: 'file-api-one', last_id: 'file-api-one', has_more: false, + data: [file()], first_id: 'file-api-one', last_id: 'file-api-one', has_more: false, }), { status: 200 }) } if (init?.method === 'DELETE') { - return new Response(JSON.stringify({ id: 'file-api-one', object: 'file', deleted: true }), { status: 200 }) + return new Response(JSON.stringify({ id: 'file-api-one', type: 'file_deleted' }), { status: 200 }) } return new Response(JSON.stringify(file()), { status: 200 }) }) as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) - await expect(client.list({ after: DeepSeekFileId('file-api-before'), limit: 20, order: 'desc' })).resolves.toMatchObject({ + await expect(client.list({ after: DeepSeekFileId('file-api-before'), limit: 20 })).resolves.toMatchObject({ data: [{ id: 'file-api-one' }], firstId: 'file-api-one', lastId: 'file-api-one', hasMore: false, }) await expect(client.retrieve(DeepSeekFileId('file-api-one'))).resolves.toMatchObject({ id: 'file-api-one' }) await expect(client.delete(DeepSeekFileId('file-api-one'))).resolves.toBeUndefined() }) - it('refuses an upload response that omits the requested expiry', async () => { - const fetchImpl = vi.fn(() => Promise.resolve(new Response( - JSON.stringify(file({ expires_at: undefined })), - { status: 200 }, - ))) as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) - - await expect(client.upload({ - data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, - })).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) - }) - it('retains quota error detail for the one cleanup retry policy', async () => { const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ error: { message: 'user storage quota exceeded', type: 'invalid_request_error', code: 'file_quota' }, }), { status: 400 }))) as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) const error = await client.upload({ data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, @@ -269,7 +247,6 @@ describe('DeepSeekFilesClient', () => { [400, 'FILES_API'], ] as const)('classifies HTTP %i Files failures as %s', async (status, code) => { const client = new DeepSeekFilesClient({ - protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: vi.fn(() => Promise.resolve(new Response('not-json', { status }))), @@ -290,7 +267,6 @@ describe('DeepSeekFilesClient', () => { { error: { message: 1, type: 2, code: 3 } }, ])('falls back to the HTTP status for an unstructured provider error %#', async (body) => { const client = new DeepSeekFilesClient({ - protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 400 }))), @@ -303,7 +279,6 @@ describe('DeepSeekFilesClient', () => { it('wraps transport failures but preserves an aborted request reason', async () => { const transport = new Error('socket closed') const client = new DeepSeekFilesClient({ - protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: vi.fn(() => Promise.reject(transport)), @@ -324,19 +299,15 @@ describe('DeepSeekFilesClient', () => { [], file({ id: 1 }), file({ id: '' }), - file({ object: 'wrong' }), - file({ bytes: 1.5 }), - file({ bytes: -1 }), + file({ type: 'wrong' }), + file({ size_bytes: 1.5 }), + file({ size_bytes: -1 }), file({ created_at: 1.5 }), file({ created_at: -1 }), file({ filename: 1 }), file({ filename: '' }), - file({ purpose: 'assistants' }), - file({ expires_at: 1.5 }), - file({ expires_at: -1 }), ])('rejects an invalid file object %#', async (body) => { const client = new DeepSeekFilesClient({ - protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))), @@ -350,7 +321,7 @@ describe('DeepSeekFilesClient', () => { 3_600.5, ])('refuses invalid file expiry %s before transport', async (expiresAfterSeconds) => { const fetchImpl = vi.fn() as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) await expect(client.upload({ data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds, })).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) @@ -359,7 +330,7 @@ describe('DeepSeekFilesClient', () => { it('refuses a file larger than the upload limit before transport', async () => { const fetchImpl = vi.fn() as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) const data = { byteLength: MAX_FILE_UPLOAD_BYTES + 1 } as Uint8Array await expect(client.upload({ data, mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, @@ -371,14 +342,12 @@ describe('DeepSeekFilesClient', () => { null, [], {}, - { object: 'wrong', data: [], has_more: false }, - { object: 'list', data: null, has_more: false }, - { object: 'list', data: [], has_more: 0 }, - { object: 'list', data: [], has_more: false, first_id: 1 }, - { object: 'list', data: [], has_more: false, last_id: 1 }, + { data: null, has_more: false }, + { data: [], has_more: 0 }, + { data: [], has_more: false, first_id: 1 }, + { data: [], has_more: false, last_id: 1 }, ])('rejects an invalid list response %#', async (body) => { const client = new DeepSeekFilesClient({ - protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))), @@ -388,11 +357,11 @@ describe('DeepSeekFilesClient', () => { it('accepts a list without cursors and uses the global fetch default', async () => { const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ - object: 'list', data: [], has_more: false, + data: [], has_more: false, }), { status: 200 }))) vi.stubGlobal('fetch', fetchImpl) try { - const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com///', apiKey: 'key' }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com///', apiKey: 'key' }) await expect(client.list()).resolves.toEqual({ data: [], hasMore: false }) } finally { vi.unstubAllGlobals() @@ -403,12 +372,10 @@ describe('DeepSeekFilesClient', () => { null, [], {}, - { id: 'wrong', object: 'file', deleted: true }, - { id: 'file-api-one', object: 'wrong', deleted: true }, - { id: 'file-api-one', object: 'file', deleted: false }, + { id: 'wrong', type: 'file_deleted' }, + { id: 'file-api-one', type: 'wrong' }, ])('rejects an invalid delete response %#', async (body) => { const client = new DeepSeekFilesClient({ - protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))), diff --git a/packages/llm/llm-deepseek/tests/messages/files.spec.ts b/packages/llm/llm-deepseek/tests/files.spec.ts similarity index 91% rename from packages/llm/llm-deepseek/tests/messages/files.spec.ts rename to packages/llm/llm-deepseek/tests/files.spec.ts index e4f1a9060e..aee62c111b 100644 --- a/packages/llm/llm-deepseek/tests/messages/files.spec.ts +++ b/packages/llm/llm-deepseek/tests/files.spec.ts @@ -1,16 +1,17 @@ /** Messages file-reference admission, bounded recovery and request-wide inline fallback. */ +import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { afterEach, describe, expect, it, vi } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { createAssistantMessage, createToolResultMessage, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' -import { DeepSeekFileId } from '../../src/common/file-id.ts' -import type { DeepSeekFileStore } from '../../src/common/file-store.ts' -import { resolveAdapterOptions } from '../../src/config.ts' -import type { Config } from '../../src/config.ts' -import { DeepSeekMessagesAdapter } from '../../src/protocols/messages/adapter.ts' -import { prepareImages } from '../../src/protocols/messages/images.ts' -import { providerErrorDetail } from '../../src/protocols/messages/transport.ts' -import { chunks, options, prepareExtensions, sse, textEvents, user } from './helpers.ts' +import { DeepSeekFileId } from '../src/file-id.ts' +import { DeepSeekFileStore } from '../src/file-store.ts' +import { resolveAdapterOptions } from '../src/config.ts' +import type { Config } from '../src/config.ts' +import { DeepSeekAdapter } from '../src/adapter.ts' +import { prepareImages } from '../src/images.ts' +import { providerErrorDetail } from '../src/transport.ts' +import { chunks, options, prepareExtensions, sse, textEvents, user, requestImageStore } from './helpers.ts' const model = 'deepseek-flash' const ref: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), width: 1, height: 1, mediaType: 'image/png', bytes: 3 } @@ -31,15 +32,15 @@ function harness(config: Config = {}) { record: { fileId: DeepSeekFileId(image.attachment.attachmentId === ref.attachmentId ? 'file-a' : 'file-b') }, uploaded: false, } as Awaited>)) const invalidate = vi.fn(async () => {}) - const files = { ensureUploaded, invalidate } as unknown as DeepSeekFileStore + const files = Object.assign(new DeepSeekFileStore(), { ensureUploaded, invalidate }) const readImageRequest = vi.fn(async (attachment: ImageAttachmentRef) => version(attachment)) // The codec and remote upload are the expensive boundaries; request projection and recovery stay real. - const attachments = { readImageRequest } as unknown as AttachmentStore + const attachments = requestImageStore(readImageRequest) const prepare = vi.fn(prepareExtensions) - const adapter = new DeepSeekMessagesAdapter({ - connection: () => resolveAdapterOptions(Object.assign({ baseURL: 'https://gateway.example/custom' }, config)), - apiKey: async () => 'test-key', userId: () => 'test-user', attachments: () => attachments, - imageAccess: () => ({ readonlyPath: '/workspace/image.png' }), files: () => files, prepareExtensions: prepare, + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions(Object.assign({ baseURL: 'https://gateway.example/custom' }, config)), + resolveApiKey: async () => 'test-key', resolveUserId: () => 'test-user' as AnonymousUserId, resolveAttachments: () => attachments, + resolveImageAccess: () => ({ readonlyPath: '/workspace/image.png' }), resolveFiles: () => files, prepareExtensions: prepare, }) return { adapter, ensureUploaded, invalidate, readImageRequest, prepare } } @@ -55,7 +56,7 @@ describe('Messages Files requests', () => { createToolResultMessage({ callId, isError: false, content: [{ type: 'image', attachment: ref }, { type: 'image', attachment: ref }] })] await chunks(h.adapter.stream(options({ model, messages }))) expect(h.readImageRequest).toHaveBeenCalledTimes(1) - expect(h.ensureUploaded).toHaveBeenCalledWith(expect.anything(), { baseURL: 'https://gateway.example/custom', apiKey: 'test-key', protocol: 'messages' }, expect.anything(), expect.any(AbortSignal)) + expect(h.ensureUploaded).toHaveBeenCalledWith(expect.anything(), { baseURL: 'https://gateway.example/custom', apiKey: 'test-key' }, expect.anything(), expect.any(AbortSignal)) const [url, init] = fetchImpl.mock.calls[0]! expect(url).toBe('https://gateway.example/custom/v1/messages') expect(new Headers(init?.headers).get('anthropic-beta')).toBe('files-api-2025-04-14') @@ -72,7 +73,7 @@ describe('Messages Files requests', () => { vi.stubGlobal('fetch', fetchImpl) const h = harness() await chunks(h.adapter.stream(request([ref, second]))) - expect(h.invalidate).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ attachment: ref }), 'file-a', expect.objectContaining({ protocol: 'messages' })) + expect(h.invalidate).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ attachment: ref }), 'file-a', expect.objectContaining({})) expect(fetchImpl).toHaveBeenCalledTimes(2) expect(h.prepare).toHaveBeenCalledTimes(2) expect(h.ensureUploaded).toHaveBeenCalledTimes(4) diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/cordis.yml b/packages/llm/llm-deepseek/tests/fixtures/cordis.yml similarity index 100% rename from packages/llm/llm-deepseek/tests/messages/fixtures/cordis.yml rename to packages/llm/llm-deepseek/tests/fixtures/cordis.yml diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/history.ts b/packages/llm/llm-deepseek/tests/fixtures/history.ts similarity index 77% rename from packages/llm/llm-deepseek/tests/messages/fixtures/history.ts rename to packages/llm/llm-deepseek/tests/fixtures/history.ts index 4c5b78c6d1..0a02332dfc 100644 --- a/packages/llm/llm-deepseek/tests/messages/fixtures/history.ts +++ b/packages/llm/llm-deepseek/tests/fixtures/history.ts @@ -1,7 +1,7 @@ /** Exercise Messages request conversion with recorded responses from another protocol. */ import type { Context } from '@deepseek-ai/cordis' -import { resolveAdapterOptions } from '../../../src/config.ts' -import { serialize } from '../../../src/protocols/messages/serialize.ts' +import { resolveAdapterOptions } from '../../src/config.ts' +import { serialize } from '../../src/serialize.ts' export const name = 'messages-history-snapshot' export const inject = ['llm'] diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts b/packages/llm/llm-deepseek/tests/fixtures/input-history.ts similarity index 93% rename from packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts rename to packages/llm/llm-deepseek/tests/fixtures/input-history.ts index ce98439370..33f45f6291 100644 --- a/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts +++ b/packages/llm/llm-deepseek/tests/fixtures/input-history.ts @@ -3,8 +3,8 @@ import assert from 'node:assert/strict' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' -import { resolveAdapterOptions } from '../../../src/config.ts' -import { serialize } from '../../../src/protocols/messages/serialize.ts' +import { resolveAdapterOptions } from '../../src/config.ts' +import { serialize } from '../../src/serialize.ts' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { diff --git a/packages/llm/llm-deepseek/tests/messages/helpers.ts b/packages/llm/llm-deepseek/tests/helpers.ts similarity index 63% rename from packages/llm/llm-deepseek/tests/messages/helpers.ts rename to packages/llm/llm-deepseek/tests/helpers.ts index 1aff3c74f6..d5f8e780d7 100644 --- a/packages/llm/llm-deepseek/tests/messages/helpers.ts +++ b/packages/llm/llm-deepseek/tests/helpers.ts @@ -1,14 +1,18 @@ /** Deterministic Messages fixtures and loopback transport with explicit teardown. */ +import { Context } from '@deepseek-ai/cordis' +import AttachmentStore from '@deepseek-ai/dsh-attachment' +import type { ModuleLoaderV2 } from '@deepseek-ai/cordis-plugin-loader' +import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { createServer } from 'node:http' import type { IncomingHttpHeaders, ServerResponse } from 'node:http' import { once } from 'node:events' -import { object } from '../../src/protocols/messages/replay.ts' +import { object } from '../src/replay.ts' import { BlockAssembler, createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { resolveAdapterOptions } from '../../src/index.ts' -import { DeepSeekMessagesAdapter } from '../../src/protocols/messages/adapter.ts' -import type { Config } from '../../src/config.ts' -import { DeepSeekFileStore } from '../../src/common/file-store.ts' +import { resolveAdapterOptions } from '../src/index.ts' +import { DeepSeekAdapter } from '../src/adapter.ts' +import type { Config } from '../src/config.ts' +import { DeepSeekFileStore } from '../src/file-store.ts' export const prepareExtensions = async () => ({ fields: {}, accept: async () => {} }) @@ -40,7 +44,7 @@ export async function assemble(stream: AsyncIterable, model = MODEL } export function adapter(config: Config = {}) { const files = new DeepSeekFileStore() - return new DeepSeekMessagesAdapter({ connection: () => resolveAdapterOptions(config), apiKey: () => Promise.resolve('test-key'), userId: () => 'test-user', attachments: () => undefined, imageAccess: () => undefined, files: () => files, prepareExtensions }) + return new DeepSeekAdapter({ options: () => resolveAdapterOptions(config), resolveApiKey: () => Promise.resolve('test-key'), resolveUserId: () => 'test-user' as AnonymousUserId, resolveAttachments: () => undefined, resolveImageAccess: () => undefined, resolveFiles: () => files, prepareExtensions }) } export async function server(reply: (response: ServerResponse, count: number) => void = response => response.end(sse(textEvents))) { const requests: { path: string; headers: IncomingHttpHeaders; body: Record }[] = [] @@ -70,3 +74,28 @@ export async function server(reply: (response: ServerResponse, count: number) => }, } } + +/** Supply only the image projection operation; any unexpected storage work fails. */ +export function requestImageStore(readImageRequest: AttachmentStore['readImageRequest']): AttachmentStore { + class ProjectedAttachments extends AttachmentStore { + get imageLimits(): never { throw new Error('unexpected image policy read') } + validateImage(): never { throw new Error('unexpected image validation') } + saveImage(): never { throw new Error('unexpected image save') } + readImage(): never { throw new Error('unexpected original image read') } + override readImageRequest = readImageRequest + } + return new ProjectedAttachments(new Context()) +} + +/** Import mapped source modules without claiming support for Node's HMR internals. */ +export function sourceModuleLoader(importModule: (specifier: string) => Promise): ModuleLoaderV2 { + return { + version: 'v2', + import: importModule, + get loadCache(): never { throw new Error('unexpected module cache access') }, + register(): never { throw new Error('unexpected module hook registration') }, + getOrCreateModuleJob(): never { throw new Error('unexpected module job creation') }, + resolveSync(): never { throw new Error('unexpected synchronous module resolution') }, + load(): never { throw new Error('unexpected module load') }, + } +} diff --git a/packages/llm/llm-deepseek/tests/image-tokens.spec.ts b/packages/llm/llm-deepseek/tests/image-tokens.spec.ts index 5825de033e..0796e3d113 100644 --- a/packages/llm/llm-deepseek/tests/image-tokens.spec.ts +++ b/packages/llm/llm-deepseek/tests/image-tokens.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { deepSeekImageTokens, deepSeekRequestImageDimensions } from '../src/common/image-tokens.ts' +import { deepSeekImageTokens, deepSeekRequestImageDimensions } from '../src/image-tokens.ts' describe('DeepSeek image tokens', () => { // Reference values from the provider's published image token calculator diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 146fd111d4..0626186629 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -29,14 +29,13 @@ import * as DeepSeekPluginPackageInventory from '@deepseek-ai/dsh-plugin-package import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' -import { server as messagesServer } from './messages/helpers.ts' +import { sourceModuleLoader } from './helpers.ts' const NS = 'llm-deepseek' const KEY_REF = credentialRef('DEEPSEEK_API_KEY') let root: string | undefined let context: Context | undefined -const closeMessagesServers: (() => Promise)[] = [] afterEach(async () => { await context?.fiber.dispose() @@ -44,12 +43,11 @@ afterEach(async () => { if (root !== undefined) await rm(root, { recursive: true, force: true }) root = undefined await closeMockServers() - while (closeMessagesServers.length) await closeMessagesServers.pop()!() vi.unstubAllEnvs() }) async function loadComposition( - options: { withDynamic: boolean; baseURL: string; reuseRoot?: string; enableSessionLog?: boolean; protocol?: 'chat-completions' | 'messages' }, + options: { withDynamic: boolean; baseURL: string; reuseRoot?: string; enableSessionLog?: boolean }, ): Promise<{ ctx: Context; settingsPath: string; credentialsPath: string }> { // A reused root is the restart case: the same harness home, its documents // exactly as the previous process left them. @@ -97,7 +95,6 @@ async function loadComposition( '- id: llm-deepseek', " name: '@deepseek-ai/dsh-llm-deepseek'", ' config:', - ` protocol: ${options.protocol ?? 'chat-completions'}`, ` baseURL: ${JSON.stringify(options.baseURL)}`, '', ].join('\n')) @@ -129,13 +126,10 @@ async function loadComposition( type: 'module', })}\n`) })) - ctx.loader.internal = { - version: 'v2', - async import(specifier: string) { - if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) - return modules.get(specifier) - }, - } as unknown as NonNullable + ctx.loader.internal = sourceModuleLoader(async (specifier) => { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href }, @@ -144,18 +138,12 @@ async function loadComposition( return { ctx, settingsPath, credentialsPath } } -async function extensionServer(protocol: 'chat-completions' | 'messages') { - if (protocol === 'chat-completions') return mockServer([{ kind: 'sse', events: textEvents }]) - const server = await messagesServer() - closeMessagesServers.push(() => server.close()) - return { url: server.url, get requests() { return server.requests.map(request => request.body) } } -} describe('llm-deepseek real dynamic composition', () => { - it.each(['chat-completions', 'messages'] as const)('keeps package inventory on when the %s Loader composition disables session upload', async (protocol) => { + it('keeps package inventory on when the Loader composition disables session upload', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key') - const server = await extensionServer(protocol) - const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url, protocol, enableSessionLog: false }) + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url, enableSessionLog: false }) const session = ctx.sessions.create(SessionId('extension-composition')) session.append('turn/start', { turn: 1 }) @@ -171,13 +159,12 @@ describe('llm-deepseek real dynamic composition', () => { expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(-1) }) - it.each(['chat-completions', 'messages'] as const)('sends the canonical session suffix by default through %s Loader composition', async (protocol) => { + it('sends the canonical session suffix by default through Loader composition', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key') - const server = await extensionServer(protocol) + const server = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url, - protocol, }) const session = ctx.sessions.create(SessionId('extension-composition-enabled')) session.append('turn/start', { turn: 1 }) @@ -210,7 +197,7 @@ describe('llm-deepseek real dynamic composition', () => { expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS]) await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key') + expect(serverA.headers[0]?.['x-api-key']).toBe('boot-key') expect(serverA.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) // External edits, exactly as a user or the web UI would leave them on disk. @@ -225,7 +212,7 @@ describe('llm-deepseek real dynamic composition', () => { await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(serverA.requests).toHaveLength(1) - expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key') + expect(serverB.headers[0]?.['x-api-key']).toBe('rotated-key') }) it('keeps a stored key writable and rotatable across a real restart', async () => { @@ -240,7 +227,7 @@ describe('llm-deepseek real dynamic composition', () => { expect(await boot.ctx.get('credentials')!.describe(KEY_REF)) .toEqual({ configured: true, source: 'file', writable: true }) await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(first.headers[0]?.authorization).toBe('Bearer stored-by-ui') + expect(first.headers[0]?.['x-api-key']).toBe('stored-by-ui') await boot.ctx.fiber.dispose() context = undefined @@ -254,7 +241,7 @@ describe('llm-deepseek real dynamic composition', () => { // Rotation still works after the restart, and the next request uses it. await credentials.set(KEY_REF, 'rotated-after-restart') await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart') + expect(second.headers[0]?.['x-api-key']).toBe('rotated-after-restart') }) it('boots the same adapter on entry config alone, resolving the reference from the environment', async () => { @@ -267,6 +254,6 @@ describe('llm-deepseek real dynamic composition', () => { expect(ctx.get('settings')).toBeUndefined() expect(ctx.get('credentials')).toBeUndefined() await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(server.headers[0]?.authorization).toBe('Bearer entry-key') + expect(server.headers[0]?.['x-api-key']).toBe('entry-key') }) }) diff --git a/packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts deleted file mode 100644 index aeb6ebe3e8..0000000000 --- a/packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * Real Messages round trips use the official root and require credentials. - * System-update checks additionally require DEEPSEEK_IN_HISTORY_MODEL. - */ -import { randomUUID } from 'node:crypto' -import { mkdtemp, readFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context, LoggerLevel } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import LocalAttachments from '@deepseek-ai/dsh-attachment-local' -import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import LlmRuntime, { BlockAssembler, createAssistantMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' -import type { Message } from '@deepseek-ai/dsh-llm' -import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek' -import * as Messages from '../../src/index.ts' -import { DeepSeekFilesClient } from '../../src/common/files-api.ts' -import { MESSAGES_FILES_BETA } from '../../src/common/messages-api.ts' -import { assemble, options, user } from './helpers.ts' - -const IN_HISTORY_MODEL = process.env.DEEPSEEK_IN_HISTORY_MODEL -const cleanups: (() => Promise)[] = [] -afterEach(async () => { - while (cleanups.length) await cleanups.pop()!() - vi.unstubAllEnvs() - vi.unstubAllGlobals() -}) -async function boot(models?: Messages.Config['models']) { - const home = await mkdtemp(join(tmpdir(), 'dsh-messages-e2e-')) - cleanups.push(() => rm(home, { recursive: true, force: true })) - vi.stubEnv('DSH_HOME', home) - const ctx = new Context() - cleanups.push(() => ctx.fiber.dispose()) - await ctx.plugin(LlmRuntime) - await ctx.plugin(Messages, { - baseURL: Messages.MESSAGES_BASE_URL, - maxTokens: 4096, - ...models === undefined ? {} : { models }, - }) - return ctx -} -const tool = { name: 'lookup_value', description: 'Read the requested value. Always call this tool to obtain a value.', parameters: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] } } - -describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () => { - it.skipIf(!IN_HISTORY_MODEL).each([false, true])('updates system instructions during a conversation, in-history=%s', async (inHistory) => { - const model = IN_HISTORY_MODEL as string - // Each case owns the capability, even for a model with an in-history catalog default. - const ctx = await boot([{ id: model, ...inHistory ? { systemPromptUpdate: 'in-history' as const } : {} }]) - const history: Message[] = [createSystemMessage('Reply to every user message with exactly PROMPT_FIRST.'), user('Answer now.')] - const reply = async (expected: string) => { - const saved = JSON.stringify(history) - const response = await assemble(ctx.llm.stream(options({ model, messages: history, reasoningEffort: ReasoningEffortId('high') })), model) - expect(response.assembler.finish.kind).toBe('stop') - expect(response.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain(expected) - expect(JSON.stringify(history)).toBe(saved) - history.push(response.message) - } - await reply('PROMPT_FIRST') - history.push(createSystemMessage('Reply to every user message with exactly PROMPT_SECOND.'), user('Answer again.')) - await reply('PROMPT_SECOND') - const withoutSystem = history.filter(message => message.role !== 'system') - history.splice(0, history.length, createSystemMessage(''), ...withoutSystem, user('Reply with exactly PROMPT_CLEARED.')) - await reply('PROMPT_CLEARED') - }) - - it('uploads, lists, retrieves, reuses, and replaces a deleted Files image across Messages requests', async () => { - const ctx = await boot() - await ctx.plugin(LocalAttachments) - const fetchImpl = globalThis.fetch - const uploads: string[] = [] - const bodies: string[] = [] - const files = new DeepSeekFilesClient({ baseURL: Messages.MESSAGES_BASE_URL, protocol: 'messages', apiKey: process.env.DEEPSEEK_API_KEY as string, fetch: fetchImpl }) - const ownedFiles = new Set>() - cleanups.push(async () => { - for (const id of ownedFiles) await files.delete(id) - }) - vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - const response = await fetchImpl(input, init) - if (url === `${Messages.MESSAGES_BASE_URL}/v1/files` && init?.method === 'POST' && response.ok) { - const file = await response.clone().json() as { id: string } - uploads.push(file.id) - ownedFiles.add(Messages.DeepSeekFileId(file.id)) - } - if (url === `${Messages.MESSAGES_BASE_URL}/v1/messages`) { - expect(new Headers(init?.headers).get('anthropic-beta')).toBe(MESSAGES_FILES_BETA) - if (typeof init?.body !== 'string') throw new Error('expected a JSON Messages request') - bodies.push(init.body) - } - return response - }) - const attachment = await ctx.attachments.saveImage({ data: await readFile(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) - const message = user('What is the dominant color of this image? Reply with one English color word.') - const request = options({ - model: 'deepseek-flash', reasoningEffort: ReasoningEffortId('off'), - messages: [{ ...message, content: [...message.content, { type: 'image', attachment }] }], - }) - for (let run = 0; run < 2; run++) { - const result = await assemble(ctx.llm.stream(request), request.model) - expect(result.assembler.finish.kind).toBe('stop') - expect(result.message.content.filter(block => block.type === 'text').map(block => block.text).join('').toLowerCase()).toContain('red') - } - expect(uploads).toHaveLength(1) - expect(bodies).toHaveLength(2) - expect(bodies.every(body => body.includes(`"file_id":"${uploads[0]}"`) && !body.includes('"type":"base64"'))).toBe(true) - const fileId = Messages.DeepSeekFileId(uploads[0]!) - const retrieved = await files.retrieve(fileId) - expect(retrieved).toMatchObject({ id: fileId, bytes: attachment.bytes, purpose: 'user_data' }) - expect(retrieved.expiresAt).toBeUndefined() - let page = await files.list({ limit: 1_000 }) - const cursors = new Set() - while (!page.data.some(file => file.id === fileId) && page.hasMore) { - expect(page.lastId).toBeDefined() - const after = page.lastId! - expect(cursors.has(after)).toBe(false) - cursors.add(after) - page = await files.list({ after, limit: 1_000 }) - } - expect(page.data).toContainEqual(retrieved) - await files.delete(fileId) - ownedFiles.delete(fileId) - const recovered = await assemble(ctx.llm.stream(request), request.model) - expect(recovered.assembler.finish.kind).toBe('stop') - expect(recovered.message.content.filter(block => block.type === 'text').map(block => block.text).join('').toLowerCase()).toContain('red') - expect(uploads).toHaveLength(2) - expect(uploads[1]).not.toBe(fileId) - expect(bodies).toHaveLength(4) - expect(bodies[2]).toContain(`"file_id":"${fileId}"`) - expect(bodies[3]).toContain(`"file_id":"${uploads[1]}"`) - expect(bodies[3]).not.toContain('"type":"base64"') - }) - - it.each([false, true])('submits Loader package inventory and records HTTP acceptance with session-log enabled=%s', async (enabled) => { - const ctx = await boot() - await ctx.plugin(Loader) - await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionStore) - await ctx.plugin(DeepSeekLlmApiExtensionRegistry) - await ctx.plugin(SessionLogDeepSeek, enabled ? {} : { enabled: false }) - ctx.baseUrl = import.meta.url - // Select the source module while Loader owns its active package entry. - ctx.loader.internal = { - version: 'v2', - async import(specifier: string) { - if (specifier !== '@deepseek-ai/dsh-plugin-package-inventory-deepseek') throw new Error(`unexpected Loader import: ${specifier}`) - return PluginPackageInventoryDeepSeek - }, - } as unknown as NonNullable - await ctx.loader.create({ name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' }) - await ctx.loader.await() - const packageIdentity = JSON.parse(await readFile(new URL('../../../plugin-package-inventory-deepseek/package.json', import.meta.url), 'utf8')) as { name: string; version: string } - const session = ctx.sessions.create(SessionId(`real-messages-extensions-${randomUUID()}`)) - session.append('turn/start', { turn: 1 }) - const fetchImpl = globalThis.fetch - let afterSeq: number = -1 - let throughSeq = 0 - let requests = 0 - vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - if (url !== `${Messages.MESSAGES_BASE_URL}/v1/messages`) return fetchImpl(input, init) - if (typeof init?.body !== 'string') throw new Error('expected a JSON Messages request') - const body = JSON.parse(init.body) as Record - expect(body).toMatchObject({ dsh_plugin_packages: { - version: 1, packages: [{ name: packageIdentity.name, version: packageIdentity.version }], - } }) - if (enabled) { - expect(body).toMatchObject({ dsh_session_log: { - version: 1, sessionFormatVersion: session.header.version, session: { id: session.id }, - afterSeq, throughSeq, - events: Array.from({ length: throughSeq - afterSeq }, (_, index) => ({ seq: afterSeq + index + 1 })), - } }) - } else expect(body).not.toHaveProperty('dsh_session_log') - expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(afterSeq) - const response = await fetchImpl(input, init) - expect(response.ok).toBe(true) - expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(afterSeq) - requests += 1 - return response - }) - for (let run = 0; run < 2; run++) { - afterSeq = SessionLogDeepSeek.acceptedThrough(session) - throughSeq = Number(session.seq) - 1 - const assembler = new BlockAssembler() - for await (const chunk of ctx.llm.stream(options({ sessionId: session.id, reasoningEffort: ReasoningEffortId('off'), messages: [user('Reply with exactly PONG.')] }))) { - expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(enabled ? throughSeq : -1) - assembler.push(chunk) - } - expect(assembler.finish.kind).toBe('stop') - expect(assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('')).toContain('PONG') - expect(requests).toBe(run + 1) - if (run === 0) session.append('step/start', { turn: 1, step: 1 }) - } - expect(session.snapshotEvents().filter(event => event.type === 'session-log-deepseek/delivery-accepted')).toHaveLength(enabled ? 2 : 0) - }) - - it.each(['off', 'low', 'high', 'max'])('streams text with %s effort', async (effort) => { - const ctx = await boot() - const result = await assemble(ctx.llm.stream(options({ reasoningEffort: ReasoningEffortId(effort), temperature: 0, messages: [user('Reply with exactly PONG.')] }))) - expect(result.assembler.finish).toEqual({ kind: 'stop' }) - expect(result.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('PONG') - expect(result.assembler.usage?.outputTokens).toBeGreaterThan(0) - expect(result.message.source.replayState).toBeDefined() - }) - - it('replays a JSON-persisted thinking/tool turn and an error result before continuing', async () => { - const ctx = await boot() - const history: Message[] = [user('Use lookup_value with key secret. If the tool returns an error, report its exact error text and stop.')] - const request = options({ messages: history, tools: [tool], reasoningEffort: ReasoningEffortId('high') }) - const first = await assemble(ctx.llm.stream(request)) - expect(first.assembler.finish.kind).toBe('tool-calls') - const calls = first.message.content.filter(block => block.type === 'tool-call') - expect(calls).toHaveLength(1) - const restored = JSON.parse(JSON.stringify(first.message)) as Message - history.push(restored) - for (const call of calls) history.push(createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: 'LOOKUP_DENIED_731' }], isError: true })) - const second = await assemble(ctx.llm.stream({ ...request, messages: history })) - expect(second.assembler.finish.kind).toBe('stop') - expect(second.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('LOOKUP_DENIED_731') - history.push(second.message, user('Reply with exactly DONE.')) - const third = await assemble(ctx.llm.stream({ ...request, messages: history })) - expect(third.assembler.finish.kind).toBe('stop') - }) - - it('continues persisted foreign history containing malformed tool arguments', async () => { - const ctx = await boot() - const callId = ToolCallId('historical_lookup') - const history = [ - user('Look up the secret value.'), - createAssistantMessage({ source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, content: [ - { type: 'tool-call', id: callId, name: 'lookup_value', arguments: '{"key":"the "secret""}' }, - ] }), - createToolResultMessage({ callId, content: [{ type: 'text', text: 'Invalid arguments: expected an object' }], isError: true }), - user('Do not retry the lookup. Reply with exactly HISTORY_RECOVERED_731.'), - ] - const saved = JSON.stringify(history) - const restored = JSON.parse(saved) as Message[] - const response = await assemble(ctx.llm.stream(options({ messages: restored, tools: [tool], reasoningEffort: ReasoningEffortId('off') }))) - expect(response.assembler.finish.kind).toBe('stop') - expect(response.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('HISTORY_RECOVERED_731') - expect(JSON.stringify(restored)).toBe(saved) - }) - - it('cancels an active stream without committing a successful response', async () => { - const ctx = await boot() - const controller = new AbortController() - const stream = ctx.llm.stream(options({ signal: controller.signal, messages: [user('List the integers from one to one thousand, one per line.')] })) - let cancelled = false - let finish: unknown - for await (const chunk of stream) { - if (!cancelled && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta')) { cancelled = true; controller.abort() } - if (chunk.type === 'finish') finish = chunk.reason - } - expect(cancelled).toBe(true) - expect(finish).toMatchObject({ kind: 'aborted' }) - }) - - it('continues a thinking/tool turn after degrading unusable persisted replay metadata', async () => { - const ctx = await boot() - const warnings: unknown[][] = [] - ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } }) - const history: Message[] = [user('Use lookup_value with key secret. Then reply with the exact tool result and stop.')] - const request = options({ messages: history, tools: [tool], reasoningEffort: ReasoningEffortId('high') }) - const first = await assemble(ctx.llm.stream(request)) - expect(first.assembler.finish.kind).toBe('tool-calls') - const calls = first.message.content.filter(block => block.type === 'tool-call') - expect(calls).toHaveLength(1) - const restored = JSON.parse(JSON.stringify(first.message)) as typeof first.message - restored.source.replayState = { response: { kind: 'deepseek-messages', version: 2 }, blocks: [] } - const saved = JSON.stringify(restored) - history.push(restored, ...calls.map(call => createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: 'REPLAY_RECOVERED_731' }], isError: false }))) - const second = await assemble(ctx.llm.stream({ ...request, messages: history })) - expect(second.assembler.finish.kind).toBe('stop') - expect(second.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('REPLAY_RECOVERED_731') - expect(warnings).toEqual([[expect.stringContaining('unsupported kind or version')]]) - expect(JSON.stringify(restored)).toBe(saved) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts b/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts deleted file mode 100644 index 6a9fa25af6..0000000000 --- a/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts +++ /dev/null @@ -1,450 +0,0 @@ -/** HTTP lifecycle, routing and optional Cordis services under real composition. */ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context, LoggerLevel, Service } from '@deepseek-ai/cordis' -import LocalAttachments from '@deepseek-ai/dsh-attachment-local' -import AgentRegistry, { installModelSelection } from '@deepseek-ai/dsh-agent' -import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include from '@deepseek-ai/cordis-plugin-include' -import LlmRuntime, { createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' -import { credentialRef } from '@deepseek-ai/dsh-credentials' -import LocalCredentials from '@deepseek-ai/dsh-credentials-local' -import FileSettings from '@deepseek-ai/dsh-settings-file' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import { DeepSeekMessagesAdapter } from '../../src/protocols/messages/adapter.ts' -import { DeepSeekFileStore } from '../../src/common/file-store.ts' -import * as Messages from '../../src/index.ts' -import { adapter, assemble, chunks, MODEL, options, prepareExtensions, server, sse, textEvents, user } from './helpers.ts' - -const cleanup: (() => Promise)[] = [] -afterEach(async () => { - while (cleanup.length) await cleanup.pop()!() - vi.unstubAllEnvs() - vi.unstubAllGlobals() -}) -async function endpoint(...args: Parameters) { - const instance = await server(...args) - cleanup.push(() => instance.close()) - return instance -} -async function context() { - const home = await mkdtemp(join(tmpdir(), 'dsh-messages-test-')) - cleanup.push(() => rm(home, { recursive: true, force: true })) - vi.stubEnv('DSH_HOME', home) - const ctx = new Context() - cleanup.push(() => ctx.fiber.dispose()) - return { ctx, home } -} - -async function send(agent: Agent, text: string) { - agent.followup(user(text)) - await agent.whenIdle() - expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) -} - -declare module '@deepseek-ai/dsh-llm' { - interface MessageSourceMap { - 'saved-notice': { kind: 'saved-notice' } - } -} - -describe('direct Messages HTTP', () => { - it('continues through Messages with assistant blocks in saved user history', async () => { - const http = await endpoint() - const notice = createUserMessage({ source: { kind: 'saved-notice' }, content: [ - { type: 'text', text: 'Background subagent finished.' }, - { type: 'reasoning', text: 'child reasoning' }, - { type: 'tool-call', id: ToolCallId('child-call'), name: 'read', arguments: '{}' }, - { type: 'text', text: 'Its closing message: answer.' }, - ] }) - const empty = createUserMessage({ source: { kind: 'user' }, content: [{ type: 'reasoning', text: 'no closing text' }] }) - const history = [notice, empty] - const saved = JSON.stringify(history) - const llm = adapter({ baseURL: http.url }) - const first = await assemble(llm.stream(options({ messages: history }))) - const second = await assemble(llm.stream(options({ messages: [...history, first.message, user('continue')] }))) - - expect(first.assembler.finish.kind).toBe('stop') - expect(second.assembler.finish.kind).toBe('stop') - expect(http.requests).toHaveLength(2) - const wireNotice = { role: 'user', content: [ - { type: 'text', text: 'Background subagent finished.' }, - { type: 'text', text: 'Its closing message: answer.' }, - ] } - expect(http.requests[0]?.body.messages).toEqual([wireNotice]) - expect(http.requests[1]?.body.messages).toEqual([ - wireNotice, - { role: 'assistant', content: [{ type: 'text', text: 'Hello 世界' }] }, - { role: 'user', content: [{ type: 'text', text: 'continue' }] }, - ]) - expect(JSON.stringify(history)).toBe(saved) - }) - - it('continues without a diagnostic callback when replay metadata is unusable', async () => { - const http = await endpoint() - const message = createAssistantMessage({ content: [{ type: 'text', text: 'Remember 731.' }], source: { - provider: 'deepseek-official', model: MODEL, replayState: { response: {}, blocks: [] }, - } }) - const response = await assemble(adapter({ baseURL: http.url }).stream(options({ messages: [user(), message, user()] }))) - expect(response.assembler.finish.kind).toBe('stop') - expect(http.requests[0]?.body.messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'Remember 731.' }] }, - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - ]) - }) - - it('uses the Messages endpoint, authentication, attribution and final usage', async () => { - const http = await endpoint() - const llm = adapter({ baseURL: http.url }) - const response = await assemble(llm.stream(options({ model: 'deepseek-flash', sessionId: SessionId('session-test'), purpose: 'compaction' })), 'deepseek-flash') - expect(response.message.content).toEqual([{ type: 'text', text: 'Hello 世界' }]) - expect(response.message.source).toMatchObject({ - model: 'deepseek-flash', replayState: { response: { model: 'deepseek-flash' } }, - }) - expect(http.requests[0]).toMatchObject({ path: '/anthropic/v1/messages', headers: { - 'x-api-key': 'test-key', 'anthropic-version': '2023-06-01', - 'user-agent': expect.stringContaining('deepseek-harness/') as string, 'x-deepseek-harness-user-id': 'test-user', - 'x-deepseek-harness-session-id': 'session-test', 'x-deepseek-harness-compact': '1', - }, body: { thinking: { type: 'enabled' }, output_config: { effort: 'high' } } }) - expect(llm.providerInfo('deepseek-official')).toEqual({ id: 'deepseek-official', name: 'DeepSeek' }) - expect((await llm.listModels('deepseek-official')).map(model => model.id)).toEqual([ - 'deepseek-flash', 'deepseek-v4-pro', - ]) - expect(await llm.resolveModel('deepseek-official', 'deepseek-flash')).toMatchObject({ - name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', - }) - expect(await llm.resolveModel('deepseek-official', MODEL)).toMatchObject({ id: MODEL }) - expect(llm.imageRequestPricing('deepseek-official', MODEL)).toBeDefined() - }) - - it.each([ - ['https://provider.example', 'https://provider.example/v1/messages'], - ['https://provider.example/v1/', 'https://provider.example/v1/messages'], - ['https://provider.example/v1beta', 'https://provider.example/v1beta/v1/messages'], - ['https://provider.example/v2', 'https://provider.example/v2/v1/messages'], - ['https://provider.example/anthropic', 'https://provider.example/anthropic/v1/messages'], - ['https://v1.provider.example', 'https://v1.provider.example/v1/messages'], - ])('resolves the Messages endpoint from %s', async (baseURL, expected) => { - const fetchImpl = vi.fn(async () => new Response(sse(textEvents), { - headers: { 'content-type': 'text/event-stream' }, - })) - vi.stubGlobal('fetch', fetchImpl) - - await chunks(adapter({ baseURL }).stream(options())) - - expect(fetchImpl.mock.calls[0]?.[0]).toBe(expected) - }) - - it.each([true, false])('maps non-2xx responses (JSON=%s)', async (json) => { - const http = await endpoint((response) => { response.statusCode = 429; response.setHeader('retry-after', '3'); response.end(json ? JSON.stringify({ error: { type: 'rate_limit_error', message: 'slow down' } }) : 'busy') }) - await expect(chunks(adapter({ baseURL: http.url }).stream(options()))).rejects.toMatchObject({ code: 'RATE_LIMIT', failure: { status: 429, providerRetryAfterMs: 3000 } }) - }) - - it('refuses redirects before credentials reach another origin or request extensions are accepted', async () => { - const destination = await endpoint() - const source = await endpoint((response) => { - response.writeHead(307, { location: `${destination.url}/v1/messages` }) - response.end() - }) - const accept = vi.fn(async () => {}) - const prepare = vi.fn(async () => ({ fields: {}, accept })) - const files = new DeepSeekFileStore() - const llm = new DeepSeekMessagesAdapter({ - connection: () => Messages.resolveAdapterOptions({ baseURL: source.url }), - apiKey: () => Promise.resolve('test-key'), userId: () => 'test-user', - attachments: () => undefined, imageAccess: () => undefined, files: () => files, - prepareExtensions: prepare, - }) - const error = await chunks(llm.stream(options())).catch((cause: unknown) => cause) - expect(source.requests).toHaveLength(1) - expect(source.requests[0]?.headers['x-api-key']).toBe('test-key') - expect(destination.requests).toEqual([]) - expect(error).toMatchObject({ code: 'TRANSPORT' }) - expect(prepare).toHaveBeenCalledOnce() - expect(accept).not.toHaveBeenCalled() - }) - - it('freezes endpoint and defaults for a prepared call while the next call sees new settings', async () => { - const first = await endpoint(), second = await endpoint() - let config = Messages.resolveAdapterOptions({ baseURL: first.url, maxTokens: 10, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) - const files = new DeepSeekFileStore() - const llm = new DeepSeekMessagesAdapter({ connection: () => config, apiKey: snapshot => Promise.resolve(snapshot.maxTokens === 10 ? 'first' : 'second'), userId: () => 'user', attachments: () => undefined, imageAccess: () => undefined, files: () => files, prepareExtensions }) - const prepared = await llm.prepareCall('deepseek-official', MODEL) - config = Messages.resolveAdapterOptions({ baseURL: second.url, maxTokens: 20 }) - expect(prepared.model.systemPromptUpdate).toBe('in-history') - expect((await llm.resolveModel('deepseek-official', MODEL)).systemPromptUpdate).toBeUndefined() - await chunks(prepared.stream(options())) - await chunks(llm.stream(options())) - expect(first.requests[0]).toMatchObject({ headers: { 'x-api-key': 'first' }, body: { max_tokens: 10 } }) - expect(second.requests[0]).toMatchObject({ headers: { 'x-api-key': 'second' }, body: { max_tokens: 20 } }) - }) - - it('aborts an open provider response when its consumer stops', async () => { - let closed!: () => void - const stopped = new Promise((resolve) => { closed = resolve }) - const http = await endpoint((response) => { - response.once('close', closed) - response.write(sse(textEvents.slice(0, 3))) - }) - const stream = adapter({ baseURL: http.url }).stream(options())[Symbol.asyncIterator]() - expect((await stream.next()).value).toMatchObject({ type: 'block-start' }) - await stream.return!() - await stopped - }) - - it('distinguishes caller cancellation from idle timeout and transport failure', async () => { - const http = await endpoint((response) =>{ response.flushHeaders() }) - await expect(chunks(adapter({ baseURL: http.url, streamIdleTimeoutMs: 30 }).stream(options()))).rejects.toMatchObject({ code: 'TIMEOUT' }) - const controller = new AbortController(); controller.abort() - await expect(chunks(adapter({ baseURL: http.url }).stream(options({ signal: controller.signal })))).rejects.toMatchObject({ code: 'ABORTED' }) - vi.stubGlobal('fetch', async () => { throw new TypeError('network down') }) - await expect(chunks(adapter().stream(options()))).rejects.toMatchObject({ code: 'TRANSPORT' }) - }) - - it('rejects a successful response with no readable body', async () => { - vi.stubGlobal('fetch', async () => new Response(null, { status: 200 })) - await expect(chunks(adapter().stream(options()))).rejects.toMatchObject({ code: 'EMPTY_RESPONSE' }) - }) -}) - -describe('Cordis provider composition', () => { - it('resolves an attachment service loaded after the adapter and maps its read-only path', async () => { - const http = await endpoint() - const { ctx, home } = await context() - vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') - await ctx.plugin(LlmRuntime) - await ctx.plugin(Messages, { baseURL: http.url }) - const model = 'deepseek-flash' - const price = () => ctx.llm.imageRequestPricing('deepseek-official', model)! - const dummy = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), width: 1, height: 1, bytes: 3, mediaType: 'image/png' as const } - expect(price().priceImages([{ type: 'image', attachment: dummy }])[0]?.text).toBeDefined() - await ctx.plugin(LocalAttachments, { dshHome: home }) - const attachment = await ctx.attachments.saveImage({ data: await readFile(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) - expect(price().priceImages([{ type: 'image', attachment }])[0]?.text).not.toContain('/mounted/image.png') - class MappedFiles extends Service { - constructor(context: Context) { super(context, 'fs') } - processPathFromHostPath(_path: string) { return '/mounted/image.png' } - } - await ctx.plugin(MappedFiles) - const message = user() - await chunks(ctx.llm.stream(options({ model, messages: [{ ...message, content: [...message.content, { type: 'image', attachment }] }] }))) - expect(JSON.stringify(http.requests[0]?.body)).toContain('/mounted/image.png') - expect(price().priceImages([{ type: 'image', attachment }])[0]?.text).toContain('/mounted/image.png') - }) - - async function boot(...args: Parameters) { - const http = await endpoint(...args) - const { ctx, home } = await context() - vi.stubEnv('DEEPSEEK_API_KEY', '') - await writeFile(join(home, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: stored-key\n', { mode: 0o600 }) - await writeFile(join(home, 'settings.yaml'), '{}\n') - const template = await readFile(new URL('fixtures/cordis.yml', import.meta.url), 'utf8') - await writeFile(join(home, 'cordis.yml'), template.replaceAll('{{endpoint}}', JSON.stringify(http.url)).replaceAll('{{settings}}', JSON.stringify(join(home, 'settings.yaml'))).replaceAll('{{credentials}}', JSON.stringify(join(home, '.credentials.yaml')))) - ctx.baseUrl = pathToFileURL(home).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - const modules = new Map([ - ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-llm-deepseek', Messages], - ['@deepseek-ai/dsh-credentials-local', LocalCredentials], ['@deepseek-ai/dsh-settings-file', FileSettings], - ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], - ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], - ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], - ]) - // The importer supplies source modules while Loader still owns configuration and effects. - for (const name of modules.keys()) { - const directory = join(home, 'node_modules', name) - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'package.json'), JSON.stringify({ name, version: '0.1.3-alpha.1', type: 'module' })) - } - ctx.loader.internal = { version: 'v2', async import(name: string) { - if (!modules.has(name)) throw new Error(`unexpected module ${name}`) - return modules.get(name) - } } as unknown as NonNullable - await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(home, 'cordis.yml')).href } }) - await ctx.loader.await() - return { ctx, http } - } - - it.each([ - { model: MODEL, inHistory: false }, - { model: MODEL, inHistory: true }, - { model: 'deepseek-flash', inHistory: true }, - ])('updates, clears and restores prompts across continued and resumed sessions, model=$model in-history=$inHistory', async ({ model, inHistory }) => { - const { ctx, http } = await boot() - if (inHistory && model === MODEL) await ctx.settings.update(Messages.name, { models: [{ id: model, systemPromptUpdate: 'in-history' }] }) - let prompt = 'first prompt' - ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ - ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], - })) - const agentOptions = { provider: 'deepseek-official', model } - const agent = await ctx.agentLoop.create(SessionId('prompt-update'), agentOptions) - await send(agent, 'first') - prompt = 'second prompt' - await send(agent, 'second') - const count = agent.session.snapshotEvents().filter(event => event.type === 'system/message').length - await send(agent, 'unchanged') - expect(agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(count) - prompt = '' - await send(agent, 'clear') - const { agent: resumed } = await ctx.agents.create({ sessionId: SessionId('prompt-resume'), agentOptions, - seed: [...agent.session.snapshotEvents()] }) - await send(resumed, 'resume cleared') - prompt = 'restored prompt' - await send(resumed, 'restore') - expect(http.requests.map(request => request.body.system)).toEqual(inHistory - ? ['first prompt', 'first prompt', 'first prompt', undefined, undefined, undefined] - : ['first prompt', 'second prompt', 'second prompt', undefined, undefined, 'restored prompt']) - for (const [index, request] of http.requests.entries()) { - const messages = request.body.messages as { role: string; content: unknown[] }[] - expect(messages.filter(message => message.role === 'assistant')).toHaveLength(index) - expect(messages.filter(message => message.role === 'system').map(message => message.content)).toEqual( - !inHistory ? [] : index === 1 || index === 2 ? [[{ type: 'text', text: 'second prompt' }]] - : index === 5 ? [[{ type: 'text', text: 'restored prompt' }]] : [], - ) - expect(JSON.stringify(messages.filter(message => message.role !== 'system'))).not.toMatch(/first prompt|second prompt|restored prompt/) - } - expect(resumed.session.requestContext()?.systemPromptUpdate).toBe(inHistory ? 'in-history' : undefined) - }) - - it.each([false, true])('continues and resumes Chat Completions sessions through Messages, in-history=%s', async (inHistory) => { - let messagesProtocol = false - const { ctx, http } = await boot(response => response.end(messagesProtocol ? sse(textEvents) : [ - 'data: {"choices":[{"delta":{"content":"OK"}}]}\n\n', - 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n', - 'data: [DONE]\n\n', - ].join(''))) - await ctx.settings.update('llm-deepseek', { protocol: 'chat-completions', baseURL: http.url, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) - let prompt = 'old prompt' - ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ - ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], - })) - const selection: ModelSelectionRef = { current: { provider: 'deepseek-official', model: MODEL }, assembled: undefined } - const agent = await ctx.agentLoop.create(SessionId('protocol-switch'), selection.current) - installModelSelection(agent.ctx, selection) - await send(agent, 'first') - prompt = 'current prompt' - await send(agent, 'second') - expect((http.requests[1]?.body.messages as { role: string }[]).filter(message => message.role === 'system')).toHaveLength(2) - const seed = [...agent.session.snapshotEvents()] - const saved = JSON.stringify(seed) - messagesProtocol = true - await ctx.settings.update(Messages.name, { protocol: 'messages', models: [{ id: MODEL, ...inHistory ? { systemPromptUpdate: 'in-history' } : {} }] }) - selection.current = { provider: 'deepseek-official', model: MODEL } - await send(agent, 'switch') - const { agent: resumed } = await ctx.agents.create({ sessionId: SessionId('switch-resume'), agentOptions: selection.current, seed }) - await send(resumed, 'resume') - for (const request of http.requests.slice(2)) { - expect(request.path).toBe('/anthropic/v1/messages') - expect(request.body.system).toBe(inHistory ? 'old prompt' : 'current prompt') - const messages = request.body.messages as { role: string }[] - expect(messages.filter(message => message.role === 'assistant')).toHaveLength(2) - expect(messages.filter(message => message.role === 'system')).toHaveLength(inHistory ? 1 : 0) - expect(JSON.stringify(messages.filter(message => message.role !== 'system'))).not.toMatch(/old prompt|current prompt/) - } - expect(JSON.stringify(seed)).toBe(saved) - expect(agent.session.deriveMessages().filter(message => message.role === 'system')).toHaveLength(inHistory ? 2 : 1) - expect(resumed.session.deriveMessages().filter(message => message.role === 'system')).toHaveLength(inHistory ? 2 : 1) - }) - - it('maps multiple system snapshots on direct compaction calls to the latest prompt', async () => { - const { ctx, http } = await boot() - const history = [createSystemMessage('old'), user(), - createAssistantMessage({ content: [{ type: 'text', text: 'OK' }], source: { provider: 'deepseek-official', model: MODEL } }), - createSystemMessage('current'), user('summarize')] - const saved = JSON.stringify(history) - const response = await assemble(ctx.llm.stream(options({ messages: history, purpose: 'compaction' }))) - expect(response.assembler.finish.kind).toBe('stop') - expect(http.requests[0]?.body.system).toBe('current') - expect((http.requests[0]?.body.messages as { role: string }[]).map(message => message.role)).toEqual(['user', 'assistant', 'user']) - expect(JSON.stringify(history)).toBe(saved) - }) - - it('continues a recorded tool turn with a warning when its native replay version is unknown', async () => { - const { ctx, http } = await boot() - const warnings: unknown[][] = [] - ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } }) - const fixture = await readFile(new URL('../../../../../snapshots/session/deepseek-messages-degraded-replay/session.v2.jsonl', import.meta.url), 'utf8') - const records = fixture.trim().split('\n').map(line => JSON.parse(line) as { type: string; data: { message?: Message } }) - const assistant = records.find(record => record.type === 'assistant/message')!.data.message! - if (assistant.source.kind === 'model') assistant.source.provider = 'deepseek-official' - // The released v2 row still wraps its tool result inside a user message. - const released = records.find(record => record.type === 'tool/result')!.data.message! as unknown as { - readonly content: readonly { - readonly type: string - readonly toolCallId: ToolCallId - readonly content: readonly ContentBlock[] - readonly isError?: boolean - }[] - } - const releasedBlock = released.content[0]! - const result = createToolResultMessage({ - callId: releasedBlock.toolCallId, content: [...releasedBlock.content], isError: releasedBlock.isError === true, - }) - const saved = JSON.stringify([assistant, result]) - const response = await assemble(ctx.llm.stream(options({ messages: [user(), assistant, result] }))) - expect(response.assembler.finish.kind).toBe('stop') - expect(warnings).toEqual([[`llm-deepseek: unusable Messages replay state on assistant history for route "deepseek-official/${MODEL}"; sending provider-neutral content (DeepSeek Messages replay: unsupported kind or version)`]]) - expect(http.requests).toHaveLength(1) - expect(http.requests[0]?.body.messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [ - { type: 'thinking', thinking: 'The user wants me to run a simple bash command and then reply with "DONE".' }, - { type: 'tool_use', id: 'call_00_fkbBRJsUrGKd1pWVc4Gn8233', name: 'bash', input: { command: 'echo TERMINAL_OK', description: 'Echo TERMINAL_OK to verify terminal access' } }, - ] }, - { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_00_fkbBRJsUrGKd1pWVc4Gn8233', content: [{ type: 'text', text: 'TERMINAL_OK\n' }], is_error: false }] }, - ]) - expect(JSON.stringify([assistant, result])).toBe(saved) - }) - - it('loads one provider from YAML, rotates settings and credentials, then removes disposed registrations', async () => { - const { ctx, http } = await boot() - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek-official']) - expect((await assemble(ctx.llm.stream(options()))).assembler.finish.kind).toBe('stop') - expect(http.requests[0]?.headers['x-api-key']).toBe('stored-key') - const second = await endpoint() - await ctx.settings.update(Messages.name, { baseURL: second.url, maxTokens: 51, retryPolicy: { mode: 'always' } }) - await ctx.credentials.set(credentialRef('DEEPSEEK_API_KEY'), 'rotated') - await chunks(ctx.llm.stream(options())) - expect(second.requests[0]).toMatchObject({ headers: { 'x-api-key': 'rotated' }, body: { max_tokens: 51 } }) - await ctx.settings.update(Messages.name, { models: [{ id: 'duplicate' }, { id: 'duplicate' }], baseURL: http.url }) - await chunks(ctx.llm.stream(options())) - expect(second.requests).toHaveLength(2) - expect(http.requests).toHaveLength(1) - await ctx.settings.update(Messages.name, { models: [{ id: MODEL }], baseURL: http.url }) - await chunks(ctx.llm.stream(options())) - expect(http.requests).toHaveLength(2) - const llm = ctx.llm - await ctx.fiber.dispose() - expect(llm.listProviders()).toEqual([]) - expect(llm.listConfigurableProviders()).toEqual([]) - }) - - it('uses environment credentials and reports missing or malformed keys without network access', async () => { - const http = await endpoint() - const { ctx } = await context() - vi.stubEnv('DEEPSEEK_BASE_URL', http.url) - vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') - await ctx.plugin(LlmRuntime) - const fiber = ctx.plugin(Messages) - await fiber - await chunks(ctx.llm.stream(options())) - expect(http.requests[0]?.headers['x-api-key']).toBe('env-key') - vi.stubEnv('DEEPSEEK_API_KEY', '') - expect((await assemble(ctx.llm.stream(options()))).assembler.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) - vi.stubEnv('DEEPSEEK_API_KEY', 'bad\nkey') - expect((await assemble(ctx.llm.stream(options()))).assembler.finish).toMatchObject({ kind: 'error', failure: { code: 'INVALID_CREDENTIAL' } }) - await fiber.dispose() - expect(ctx.llm.listProviders()).toEqual([]) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/red.png b/packages/llm/llm-deepseek/tests/messages/fixtures/red.png deleted file mode 100644 index 62a5f8f47fec02344e5bf9061888262f677cf5d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; diff --git a/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts b/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts deleted file mode 100644 index dbb1424056..0000000000 --- a/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts +++ /dev/null @@ -1,454 +0,0 @@ -/** Request conversion and durable replay validation. */ -import { describe, expect, it, vi } from 'vitest' -import { createDeveloperMessage, createUserMessage, createAssistantMessage, createMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, ImageBlock, Message, RequestMessage, RequestUserInput } from '@deepseek-ai/dsh-llm' -import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import { resolveAdapterOptions } from '../../src/config.ts' -import { modelInfo } from '../../src/common/model-info.ts' -import type { Config } from '../../src/config.ts' -import { imagePricing, inlineImages, prepareImages } from '../../src/protocols/messages/images.ts' -import { readReplay, replayState } from '../../src/protocols/messages/replay.ts' -import { serialize } from '../../src/protocols/messages/serialize.ts' -import { MODEL, options, user } from './helpers.ts' - -const connection = resolveAdapterOptions({}) -const call = (id = 'a'): ContentBlock => ({ type: 'tool-call', id: ToolCallId(id), name: 'read', arguments: '{"path":"a"}' }) -const assistant = (content: ContentBlock[]) => createAssistantMessage({ content, source: { provider: 'deepseek-official', model: MODEL } }) -const result = (id = 'a', content: ContentBlock[] = [{ type: 'text', text: 'result' }]) => createToolResultMessage({ callId: ToolCallId(id), content, isError: false }) -const body = (messages: RequestMessage[] = [user()], overrides: Partial = {}) => serialize( - options({ messages, ...overrides }), connection, messages, new Map(), () => undefined, -) -const capable = resolveAdapterOptions({ models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) -const nativeBody = (messages: Message[]) => serialize(options({ messages }), capable, messages, new Map(), () => undefined) - -describe('Messages request conversion', () => { - it('rejects unknown plugin content without interpreting its payload', () => { - expect(() => body([createUserMessage({ source: { kind: 'user' }, content: [ - { type: 'plugin:text', text: 'opaque' } as never, - ] })])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - }) - it.each(['user', 'system', 'assistant', 'tool'] as const)('rejects tool-change blocks in %s history', (role) => { - for (const type of ['tool-addition', 'tool-removal'] as const) { - const message = { id: 'invalid', role, source: { kind: 'test' }, content: [{ type, toolName: 'search' }] } as unknown as Message - expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - } - }) - - it('rejects deferred tool definitions until provider loading is implemented', () => { - expect(() => body([], { tools: [{ name: 'search', description: '', parameters: {}, deferLoading: true }] })) - .toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - }) - - it('preserves the exact request with request-only text after durable tool results', () => { - const prefix = [user(), assistant([call()]), result()] - const input: RequestUserInput = { role: 'user', content: [{ type: 'text', text: 'review or summarize this input' }] } - const durable = createUserMessage({ content: input.content, source: { kind: 'user' } }) - expect(body([...prefix, input], { system: 'policy' })).toEqual(body([...prefix, durable], { system: 'policy' })) - }) - - it('preserves request-only image content through image preparation and wire conversion', async () => { - const attachment: ImageAttachmentRef = { - attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), mediaType: 'image/png', bytes: 3, width: 1, height: 1, - } - const version: RequestImageAttachment = { - variantId: ImageVariantId(`sha256:${'f'.repeat(64)}`), attachment, data: Uint8Array.of(1, 2, 3), - mediaType: 'image/png', bytes: 3, width: 1, height: 1, depth: 'uchar', space: 'srgb', hasAlpha: true, - } - const input: RequestUserInput = { role: 'user', content: [ - { type: 'text', text: 'before' }, { type: 'image', attachment }, { type: 'text', text: 'after' }, - ] } - const store = { readImageRequest: async () => version } as unknown as AttachmentStore - const vision = resolveAdapterOptions({ models: [{ id: MODEL, inputModalities: ['text', 'image'] }] }) - const durable = createUserMessage({ content: input.content, source: { kind: 'user' } }) - const actual = await prepareImages([input], vision, MODEL, store, () => undefined, new AbortController().signal) - const expected = await prepareImages([durable], vision, MODEL, store, () => undefined, new AbortController().signal) - expect(serialize(options({ messages: [input] }), vision, actual.messages, actual.versions, () => undefined)) - .toEqual(serialize(options({ messages: [durable] }), vision, expected.messages, expected.versions, () => undefined)) - expect(actual.messages[0]).toBe(input) - expect(input).not.toHaveProperty('id') - expect(input).not.toHaveProperty('source') - }) - - it('rejects developer history while provider serialization is unsupported', () => { - const message = createDeveloperMessage({ content: [{ type: 'tool-addition', toolName: 'search' }], source: { kind: 'tool-registry' } }) - expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - }) - - it('keeps the original top-level prompt and cached prefix while appending native system updates', () => { - const head = createSystemMessage('original') - const first = [head, user('first')] - const update = createSystemMessage('updated') - const second = [...first, assistant([{ type: 'text', text: 'one' }]), update, user('second')] - const saved = JSON.stringify(second) - const before = nativeBody(first) - const after = nativeBody(second) - expect(after.system).toBe(before.system) - expect(after.messages.slice(0, before.messages.length)).toEqual(before.messages) - expect(after.messages.slice(-2)).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'second' }] }, - { role: 'system', content: [{ type: 'text', text: 'updated' }] }, - ]) - const third = nativeBody([...second, assistant([{ type: 'text', text: 'two' }]), user('third')]) - expect(third.messages.slice(0, after.messages.length)).toEqual(after.messages) - expect(JSON.stringify(second)).toBe(saved) - }) - - it('places system updates after all parallel tool results and before the next assistant', () => { - const history = [createSystemMessage('original'), user(), assistant([call(), call('b')]), - createSystemMessage('first update'), result(), createSystemMessage('second update'), result('b'), - user('more input'), assistant([{ type: 'text', text: 'done' }])] - const request = nativeBody(history) - expect(request.messages.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'system', 'system', 'assistant']) - expect(request.messages[2]?.content.map(block => block.type)).toEqual(['tool_result', 'tool_result', 'text']) - expect(request.messages.slice(3, 5).map(message => message.content)).toEqual([ - [{ type: 'text', text: 'first update' }], [{ type: 'text', text: 'second update' }], - ]) - }) - - it('accepts native trailing updates without a top-level prompt and rejects unrepresentable positions', () => { - const update = createSystemMessage('update') - expect(nativeBody([user(), update])).toMatchObject({ messages: [ - { role: 'user' }, { role: 'system', content: [{ type: 'text', text: 'update' }] }, - ] }) - expect(nativeBody([user(), update]).system).toBeUndefined() - expect(() => nativeBody([user(), assistant([{ type: 'text', text: 'done' }]), update])).toThrow(/preceding user/) - expect(() => nativeBody([user(), createSystemMessage('')])).toThrow(/empty in-history/) - expect(() => nativeBody([user(), assistant([call(), call('b')]), update, result()])).toThrow(/immediate results/) - }) - - it.each([[], [{ type: 'reasoning', text: 'child reasoning' }], [call('child-call')]] satisfies ContentBlock[][])( - 'rejects native system updates when their user input is omitted %#', (...content) => { - const empty = createMessage({ role: 'user', source: { kind: 'user' }, content }) - const history = [user(), assistant([{ type: 'text', text: 'answer' }]), createSystemMessage('updated'), empty] - const saved = JSON.stringify(history) - for (const messages of [history, [...history, assistant([{ type: 'text', text: 'next answer' }])]]) { - expect(() => nativeBody(messages)).toThrow(expect.objectContaining({ - code: 'UNSUPPORTED_CONTENT', - message: 'DeepSeek Messages cannot represent system update without a preceding user or tool-result turn', - })) - } - expect(JSON.stringify(history)).toBe(saved) - }, - ) - - it('keeps native system updates after retained text or tool results beside omitted user input', () => { - const reasoning: ContentBlock = { type: 'reasoning', text: 'child reasoning' } - const empty = createMessage({ role: 'user', source: { kind: 'user' }, content: [reasoning] }) - const update = createSystemMessage('updated') - for (const [previous, retained, expected] of [ - [assistant([{ type: 'text', text: 'answer' }]), user('continue'), [{ type: 'text', text: 'continue' }]], - [assistant([call()]), result('a', [reasoning]), [{ type: 'tool_result', tool_use_id: 'a', content: [], is_error: false }]], - ] as const) { - const history = [user(), previous, update, empty, retained, assistant([{ type: 'text', text: 'done' }])] - const saved = JSON.stringify(history) - const request = nativeBody(history) - expect(request.messages.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'system', 'assistant']) - expect(request.messages[2]?.content).toEqual(expected) - expect(request.messages[3]?.content).toEqual([{ type: 'text', text: 'updated' }]) - expect(JSON.stringify(history)).toBe(saved) - } - }) - - it('groups parallel results before ordinary text and keeps tool failure content', () => { - const messages = [user(), assistant([call(), call('b')]), user('follow-up'), result(), createToolResultMessage({ callId: ToolCallId('b'), content: [{ type: 'text', text: 'permission denied' }], isError: true })] - expect(body(messages).messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: ['a', 'b'].map(id => ({ type: 'tool_use', id, name: 'read', input: { path: 'a' } })) }, - { role: 'user', content: [ - { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: 'result' }], is_error: false }, - { type: 'tool_result', tool_use_id: 'b', content: [{ type: 'text', text: 'permission denied' }], is_error: true }, - { type: 'text', text: 'follow-up' }, - ] }, - ]) - expect(messages[2]?.content).toEqual([{ type: 'text', text: 'follow-up' }]) - }) - - it('preserves empty results without inventing model-visible output', () => { - const response = body([user(), assistant([call()]), result('a', [])]) - expect(response.messages[2]?.content[0]).toMatchObject({ content: [] }) - const { isError: _isError, ...minimal } = createToolResultMessage({ callId: ToolCallId('a'), content: [{ type: 'text', text: '' }], isError: false }) - expect(body([assistant([call()]), minimal]).messages[1]?.content[0]).toEqual({ type: 'tool_result', tool_use_id: 'a', content: [] }) - }) - - it('omits assistant-only blocks from user input while preserving text and durable content', () => { - const history = [createMessage({ role: 'user', source: { kind: 'user' }, content: [ - { type: 'text', text: 'Background subagent finished.\n' }, - { type: 'reasoning', text: 'child reasoning' }, - call('child-call'), - { type: 'text', text: ' child answer ' }, - ] })] - const saved = JSON.stringify(history) - - expect(body(history).messages).toEqual([{ role: 'user', content: [ - { type: 'text', text: 'Background subagent finished.\n' }, - { type: 'text', text: ' child answer ' }, - ] }]) - expect(JSON.stringify(history)).toBe(saved) - }) - - it('omits assistant-only blocks inside tool results while retaining calls, errors and empty results', () => { - const reasoning: ContentBlock = { type: 'reasoning', text: 'tool reasoning' } - const history = [assistant([reasoning, call(), call('b')]), - createToolResultMessage({ callId: ToolCallId('a'), content: [reasoning, call('nested-call'), { type: 'text', text: ' result\n' }], isError: true }), - result('b', [reasoning, call('another-nested-call')]), - ] - const saved = JSON.stringify(history) - - expect(body(history).messages).toEqual([ - { role: 'assistant', content: [ - { type: 'thinking', thinking: 'tool reasoning' }, - ...['a', 'b'].map(id => ({ type: 'tool_use', id, name: 'read', input: { path: 'a' } })), - ] }, - { role: 'user', content: [ - { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: ' result\n' }], is_error: true }, - { type: 'tool_result', tool_use_id: 'b', content: [], is_error: false }, - ] }, - ]) - expect(JSON.stringify(history)).toBe(saved) - }) - - it.each([ - [], [{ type: 'reasoning', text: 'child reasoning' }], [call('child-call')], - ] satisfies ContentBlock[][])('omits empty user input after conversion %#', (...content) => { - const empty = createMessage({ role: 'user', source: { kind: 'user' }, content }) - expect(body([empty, user(), assistant([{ type: 'text', text: 'answer' }]), empty]).messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'answer' }] }, - ]) - }) - - it('collects leading system text and maps tools, stop sequences and explicit output cap', () => { - const system = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'text', text: 'instructions' }] }) - expect(body([system, user()], { system: 'top', maxTokens: 123, stop: ['END'], tools: [{ name: 'read', description: 'Read a file', parameters: { type: 'object' } }] })).toMatchObject({ - system: 'top\n\ninstructions', max_tokens: 123, stop_sequences: ['END'], tools: [{ name: 'read', description: 'Read a file', input_schema: { type: 'object' } }], - }) - expect(body([user(), system]).system).toBe('instructions') - }) - - it('uses the latest complete system snapshot without changing tool history or durable messages', () => { - const conversation = [user(), assistant([call()]), result(), assistant([{ type: 'text', text: 'done' }]), user('continue')] - const history = [createSystemMessage('obsolete'), ...conversation.slice(0, 2), - createSystemMessage('intermediate'), ...conversation.slice(2, 4), - createSystemMessage('current'), conversation[4]!] - const saved = JSON.stringify(history) - expect(body(history)).toEqual({ ...body(conversation), system: 'current' }) - expect(body(history, { system: 'one-shot prefix' }).system).toBe('one-shot prefix\n\ncurrent') - expect(JSON.stringify(history)).toBe(saved) - }) - - it('replaces adjacent system snapshots and joins blocks only within the current snapshot', () => { - const latest = createMessage({ role: 'system', source: { kind: 'system-prompt' }, - content: [{ type: 'text', text: 'part one' }, { type: 'text', text: ' and part two' }] }) - expect(body([createSystemMessage('old'), latest, user()]).system).toBe('part one and part two') - }) - - it.each([[], [{ type: 'text' as const, text: '' }]].map(content => ({ content })))('clears earlier prompt snapshots with empty content %#', ({ content }) => { - const cleared = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content }) - const history = [createSystemMessage('old'), user(), cleared] - expect(body(history).system).toBeUndefined() - expect(body(history, { system: 'one-shot prefix' }).system).toBe('one-shot prefix') - expect(body(history, { system: '' }).system).toBeUndefined() - }) - - it('rejects non-text system content even when a later snapshot supersedes it', () => { - const invalid = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'reasoning', text: 'bad' }] }) - expect(() => body([invalid, user(), createSystemMessage('current')])).toThrow(/non-text system/) - }) - - it.each(['off', 'low', 'high', 'max'])('maps reasoning effort %s', (effort) => { - const request = body([user()], { reasoningEffort: ReasoningEffortId(effort) }) - expect(request.thinking.type).toBe(effort === 'off' ? 'disabled' : 'enabled') - expect(request.output_config).toEqual(effort === 'off' ? undefined : { effort }) - }) - - it('disables thinking for titles, passes temperature with thinking and refuses unsupported effort', () => { - expect(body([user()], { purpose: 'session-title', temperature: 0 })).toMatchObject({ thinking: { type: 'disabled' }, temperature: 0 }) - expect(body([user()], { temperature: 0 })).toMatchObject({ thinking: { type: 'enabled' }, temperature: 0 }) - expect(() => body([user()], { reasoningEffort: ReasoningEffortId('medium') })).toThrow(/effort/) - const disabled = resolveAdapterOptions({ thinking: 'disabled' }) - expect(serialize(options(), disabled, [user()], new Map(), () => undefined).thinking).toEqual({ type: 'disabled' }) - expect(() => serialize(options({ reasoningEffort: ReasoningEffortId('high') }), disabled, [user()], new Map(), () => undefined)).toThrow(/effort/) - const capped = resolveAdapterOptions({ models: [{ id: MODEL, maxTokens: 321 }] }) - expect(serialize(options(), capped, [user()], new Map(), () => undefined).max_tokens).toBe(321) - }) - - it.each([ - [result()], [assistant([call()])], [assistant([call()]), user()], - [assistant([call(), call()]), result()], - [assistant([call()]), result(), result()], - ])('rejects unmatched or duplicated tool history %#', (...messages) => { - expect(() => body(messages)).toThrow(/tool/) - }) - - it.each(['{', '', '[]', 'null', '42', 'true', '"text"', '{"description":"最快,但"某个说法"没有证据。"}'])('uses empty input for malformed or non-object historical tool arguments %s', (arguments_) => { - const message = assistant([{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: arguments_ }]) - const history = [user(), message, createToolResultMessage({ callId: ToolCallId('a'), content: [{ type: 'text', text: 'Invalid arguments' }], isError: true }), user('Continue')] - const saved = JSON.stringify(history) - const restored = JSON.parse(saved) as Message[] - expect(body(restored).messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [{ type: 'tool_use', id: 'a', name: 'read', input: {} }] }, - { role: 'user', content: [ - { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: 'Invalid arguments' }], is_error: true }, - { type: 'text', text: 'Continue' }, - ] }, - ]) - expect(JSON.stringify(restored)).toBe(saved) - }) - - it('preserves own signed thinking, omits absent signatures and validates durable metadata', () => { - const content: ContentBlock[] = [{ type: 'reasoning', text: '' }, { type: 'text', text: 'answer' }] - const source = { provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [{ type: 'reasoning', signature: 'signed' }, { type: 'text' }]) } - const message = createAssistantMessage({ content, source }) - expect(body([user(), message, user()]).messages[1]?.content).toEqual([{ type: 'thinking', thinking: '', signature: 'signed' }, { type: 'text', text: 'answer' }]) - expect(body([assistant([{ type: 'reasoning', text: 'foreign thought' }])]).messages[0]?.content).toEqual([{ type: 'thinking', thinking: 'foreign thought' }]) - expect(readReplay(message, 'different-model')).toBeUndefined() - expect(readReplay(user(), MODEL)).toBeUndefined() - }) - - it.each([ - null, - [], - { response: null, blocks: [] }, - { response: { kind: 'other', version: 1 }, blocks: [] }, - { response: { kind: 'deepseek-messages', version: 2 }, blocks: [] }, - { response: { kind: 'deepseek-messages', version: 1, model: 'wrong' }, blocks: [] }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [] }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: null }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [null] }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [{ type: 'tool-call' }] }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [{ type: 'reasoning', signature: 3 }] }, - ].map(state => ({ state })))('degrades unusable replay state with a diagnostic %#', ({ state }) => { - const message = createAssistantMessage({ content: [{ type: 'reasoning', text: 'think' }], source: { provider: 'deepseek-official', model: MODEL, replayState: state } }) - const onDegrade = vi.fn() - expect(readReplay(message, MODEL, onDegrade)).toBeUndefined() - expect(onDegrade).toHaveBeenCalledExactlyOnceWith(expect.any(String)) - expect(body([message]).messages[0]?.content).toEqual([{ type: 'thinking', thinking: 'think' }]) - }) - - it.each([MODEL, 'different-model'])('keeps durable content when replay degrades for %s', async (model) => { - const message = createAssistantMessage({ - content: [{ type: 'reasoning', text: 'Read the file.' }, { type: 'text', text: 'Checking a.' }, call()], - source: { provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [ - { type: 'reasoning', signature: 'do-not-send' }, { type: 'text', signature: 'invalid-for-text' }, { type: 'tool-call' }, - ]) }, - }) - const saved = JSON.stringify(message) - const restored = JSON.parse(saved) as Message - const messages = [user(), restored, result()] - const onDegrade = vi.fn() - const request = serialize(options({ model }), connection, messages, new Map(), () => undefined, onDegrade) - expect(onDegrade).toHaveBeenCalledExactlyOnceWith('DeepSeek Messages replay: invalid signature') - await expect(JSON.stringify(request.messages, null, 2) + '\n').toMatchFileSnapshot('expected/degraded-replay.json') - expect(JSON.stringify(restored)).toBe(saved) - }) - - it('keeps valid cross-model and foreign history quiet and propagates diagnostic failures', () => { - const onDegrade = vi.fn() - const message = createAssistantMessage({ content: [{ type: 'reasoning', text: 'think' }], source: { - provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [{ type: 'reasoning', signature: '' }]), - } }) - expect(readReplay(message, MODEL, onDegrade)).toEqual([{ type: 'reasoning', signature: '' }]) - expect(readReplay(message, 'different-model', onDegrade)).toBeUndefined() - expect(readReplay(assistant([{ type: 'text', text: 'foreign' }]), MODEL, onDegrade)).toBeUndefined() - expect(onDegrade).not.toHaveBeenCalled() - const damaged = { ...message, source: { ...message.source, replayState: { response: {}, blocks: [] } } } - const failure = new Error('diagnostic failed') - expect(() => readReplay(damaged, MODEL, () => { throw failure })).toThrow(failure) - }) - - it.each([1, 2])('uses empty historical tool input with replay version %s', (version) => { - const message = createAssistantMessage({ content: [{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: '{' }], source: { - provider: 'deepseek-official', model: MODEL, replayState: { response: { kind: 'deepseek-messages', version, model: MODEL }, blocks: [{ type: 'tool-call' }] }, - } }) - const saved = JSON.stringify(message) - const onDegrade = vi.fn() - const request = serialize(options(), connection, [message, result()], new Map(), () => undefined, onDegrade) - expect(request.messages[0]?.content).toEqual([{ type: 'tool_use', id: 'a', name: 'read', input: {} }]) - expect(onDegrade).toHaveBeenCalledTimes(version === 1 ? 0 : 1) - expect(JSON.stringify(message)).toBe(saved) - }) -}) - -describe('validated configuration', () => { - it('advertises exact model metadata and allows unlisted text models', () => { - expect(modelInfo(connection, 'deepseek-official', MODEL)).toMatchObject({ context: { contextWindow: 1_000_000 }, defaultMaxTokens: 256_000, reasoning: { defaultEffort: 'high' } }) - expect(modelInfo(connection, 'deepseek-official', 'custom').inputModalities).toEqual(['text']) - expect(modelInfo(connection, 'deepseek-official', MODEL).systemPromptUpdate).toBeUndefined() - expect(modelInfo(connection, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined() - expect(modelInfo(capable, 'deepseek-official', MODEL).systemPromptUpdate).toBe('in-history') - expect(modelInfo(capable, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined() - expect(modelInfo(resolveAdapterOptions({ thinking: 'disabled' }), 'deepseek-official', MODEL).reasoning?.efforts).toMatchObject([{ id: 'off', name: 'Off' }]) - expect(resolveAdapterOptions({ baseURL: 'https://example.com/anthropic///' }).baseURL).toBe('https://example.com/anthropic///') - }) - it.each([ - { thinking: 'disabled', reasoningEffort: 'high' }, { models: [{ id: '' }] }, - { models: [{ id: 'a' }, { id: 'a' }] }, { models: [{ id: 'a', name: '' }] }, - { maxInlineRequestImageBytes: 1 }, { maxImagesPerRequest: 1 }, - { baseURL: 'ftp://example.com' }, { baseURL: 'https://user:pass@example.com' }, - { baseURL: 'https://example.com/?key=x' }, { baseURL: 'https://example.com/#x' }, - { maxTokens: 0 }, { streamIdleTimeoutMs: 0 }, - { models: [{ id: MODEL, systemPromptUpdate: 'unsupported' }] }, - ])('rejects invalid composition input %#', (value) => { - expect(() => resolveAdapterOptions(value as Config)).toThrow() - }) -}) - -describe('Messages images', () => { - const ref: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), mediaType: 'image/png', width: 1, height: 1, bytes: 3 } - const image: ImageBlock = { type: 'image', attachment: ref } - const version: RequestImageAttachment = { attachment: ref, variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), mediaType: 'image/png', bytes: 3, data: Uint8Array.of(1, 2, 3), width: 1, height: 1, depth: 'uchar', space: 'srgb', hasAlpha: false } - const access = () => ({ readonlyPath: '/workspace/image.png' }) - const model = 'deepseek-flash' - // Only the read operation is consumed by image preparation; the transport is mocked, not durable content. - const attachments = { readImageRequest: async () => version } as unknown as AttachmentStore - const signal = new AbortController().signal - it('keeps image bytes inside tool results and deduplicates normalization', async () => { - const history = [assistant([call()]), result('a', [image, image])] - const prepared = await prepareImages(history, connection, model, attachments, access, signal) - expect(prepared.versions.size).toBe(1) - const request = serialize(options({ model }), connection, prepared.messages, prepared.versions, access) - expect(request.messages[1]?.content[0]).toMatchObject({ type: 'tool_result', content: [ - { type: 'text', text: expect.stringContaining('/workspace/image.png') as string }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AQID' } }, - { type: 'text' }, { type: 'image' }, - ] }) - expect(imagePricing(connection, model, access).priceImages([image])[0]?.visualTokens).toBeGreaterThan(0) - expect(imagePricing(connection, MODEL, access).priceImages([image])[0]?.visualTokens).toBe(0) - }) - it('requires logged offload at exact encoded bytes and preserves durable references', async () => { - const config = resolveAdapterOptions({ - maxInlineRequestImageBytes: 4, inlineImageOffloadByteQuantum: 1, maxImagesPerRequest: 2, imageOffloadCountQuantum: 1, - }) - const history = [result('a', [image, image])] - const prepared = await prepareImages(history, config, model, attachments, access, signal) - expect(prepared.messages[0]?.content).toMatchObject([image, image]) - expect(() => inlineImages(prepared.messages, prepared.versions, config)).toThrow(expect.objectContaining({ - failure: expect.objectContaining({ code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }) as unknown, - })) - const offloaded: ImageBlock = { ...image, offloaded: true } - const retry = await prepareImages([result('a', [offloaded, image])], config, model, attachments, access, signal) - expect(inlineImages(retry.messages, retry.versions, config)[0]?.content).toMatchObject([{ type: 'text' }, { type: 'image' }]) - expect(history[0]?.content).toMatchObject([image, image]) - expect(imagePricing(config, model, access).priceImages([image, image]).map(entry => entry.visualTokens)) - .toEqual([expect.any(Number), expect.any(Number)]) - const large = { readImageRequest: async () => ({ ...version, bytes: 30, data: new Uint8Array(30) }) } as unknown as AttachmentStore - const exact = await prepareImages([result('a', [image])], config, model, large, access, signal) - expect(() => inlineImages(exact.messages, exact.versions, config)).toThrow(expect.objectContaining({ - failure: expect.objectContaining({ code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }) as unknown, - })) - }) - it('rejects unsupported roles and unavailable image capabilities before HTTP', async () => { - const history = [result('a', [image])] - await expect(prepareImages(history, connection, MODEL, attachments, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - await expect(prepareImages(history, connection, model, undefined, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - await expect(prepareImages([assistant([image])], connection, model, attachments, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(() => body([result('a', [image])])).toThrow(/image/) - expect(() => body([assistant([image])])).toThrow(/assistant/) - // @ts-expect-error -- malformed provider input can carry a retired nested result block. - expect(() => body([result('a', [{ type: 'tool-result', toolCallId: ToolCallId('nested'), content: [] }])])) - .toThrow(/user\/tool-result content tool-result/) - expect(() => serialize(options({ model }), connection, [result('a', [image])], new Map([[ref.attachmentId, version]]), access, undefined, new Map())) - .toThrow(/request file id is missing/) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/mock-server.ts b/packages/llm/llm-deepseek/tests/mock-server.ts index 819a945e93..45402bdc1b 100644 --- a/packages/llm/llm-deepseek/tests/mock-server.ts +++ b/packages/llm/llm-deepseek/tests/mock-server.ts @@ -13,7 +13,7 @@ export interface MockServer { requests: unknown[] /** Header bags of received requests, in order (parallel to `requests`). */ headers: IncomingMessage['headers'][] - /** Parsed Files API operations, excluded from chat request ordering. */ + /** Parsed Files API operations, excluded from model request ordering. */ fileRequests: Array<{ method: string; path: string; filename?: string; bytes?: number }> script: Behavior[] close(): Promise @@ -28,18 +28,20 @@ export async function closeMockServers(): Promise { /** A minimal complete text generation, reused by request-shape assertions. */ export const textEvents = [ - '{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', - '{"choices":[{"delta":{"content":"hello"}}]}', - '{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - '[DONE]', -] + { type: 'message_start', message: { id: 'msg_1', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hello' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, +].map(event => JSON.stringify(event)) -/** Local chat-completions stand-in: replays scripted behaviors per request. */ +/** Local Messages stand-in: replays scripted behaviors per request. */ export async function mockServer(script: Behavior[]): Promise { const requests: unknown[] = [] const headers: IncomingMessage['headers'][] = [] const fileRequests: MockServer['fileRequests'] = [] - const files = new Map() + const files = new Map() let nextFile = 1 const server = createServer((request: IncomingMessage, response: ServerResponse) => { const chunks: Buffer[] = [] @@ -48,12 +50,12 @@ export async function mockServer(script: Behavior[]): Promise { void (async () => { const url = new URL(request.url ?? '/', 'http://localhost') const body = Buffer.concat(chunks) - if (url.pathname === '/files' && request.method === 'POST') { + if (url.pathname === '/v1/files' && request.method === 'POST') { const headers = new Headers() for (const [name, value] of Object.entries(request.headers)) { if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : value) } - const form = await new Request('http://localhost/files', { + const form = await new Request('http://localhost/v1/files', { method: 'POST', headers, body, @@ -62,28 +64,25 @@ export async function mockServer(script: Behavior[]): Promise { if (!(blob instanceof Blob)) throw new Error('mock upload omitted file') const name = 'name' in blob && typeof blob.name === 'string' ? blob.name : 'uploaded_file' const id = `file-api-${nextFile}` - const createdAt = Math.floor(Date.now() / 1_000) + const createdAt = new Date().toISOString() nextFile += 1 - const expiresSeconds = Number(form.get('expires_after[seconds]')) const file = { id, - object: 'file' as const, - bytes: blob.size, + type: 'file' as const, + size_bytes: blob.size, created_at: createdAt, filename: name, - purpose: 'user_data' as const, - expires_at: createdAt + expiresSeconds, + mime_type: blob.type, } files.set(id, file) fileRequests.push({ method: 'POST', path: url.pathname, filename: name, bytes: blob.size }) response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(file)) return } - if (url.pathname === '/files' && request.method === 'GET') { + if (url.pathname === '/v1/files' && request.method === 'GET') { fileRequests.push({ method: 'GET', path: `${url.pathname}${url.search}` }) const data = [...files.values()] response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ - object: 'list', data, first_id: data[0]?.id, last_id: data.at(-1)?.id, @@ -91,17 +90,17 @@ export async function mockServer(script: Behavior[]): Promise { })) return } - if (url.pathname.startsWith('/files/') && request.method === 'DELETE') { - const id = decodeURIComponent(url.pathname.slice('/files/'.length)) + if (url.pathname.startsWith('/v1/files/') && request.method === 'DELETE') { + const id = decodeURIComponent(url.pathname.slice('/v1/files/'.length)) files.delete(id) fileRequests.push({ method: 'DELETE', path: url.pathname }) response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ - id, object: 'file', deleted: true, + id, type: 'file_deleted', })) return } - if (url.pathname.startsWith('/files/') && request.method === 'GET') { - const id = decodeURIComponent(url.pathname.slice('/files/'.length)) + if (url.pathname.startsWith('/v1/files/') && request.method === 'GET') { + const id = decodeURIComponent(url.pathname.slice('/v1/files/'.length)) fileRequests.push({ method: 'GET', path: url.pathname }) const file = files.get(id) if (file === undefined) { diff --git a/packages/llm/llm-deepseek/tests/prepared-call.spec.ts b/packages/llm/llm-deepseek/tests/prepared-call.spec.ts new file mode 100644 index 0000000000..68e38db8ee --- /dev/null +++ b/packages/llm/llm-deepseek/tests/prepared-call.spec.ts @@ -0,0 +1,58 @@ +/** Prepared calls retain their endpoint and credential generation. */ +import { afterEach, expect, it } from 'vitest' +import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' +import { Config, DeepSeekAdapter, resolveAdapterOptions } from '../src/index.ts' +import type { DeepSeekConnectionOptions } from '../src/index.ts' +import { assemble, chunks, MODEL, options, server } from './helpers.ts' + +const close: (() => Promise)[] = [] +afterEach(async () => { + while (close.length) await close.pop()!() +}) +async function endpoint(...args: Parameters) { + const instance = await server(...args) + close.push(() => instance.close()) + return instance +} +function adapter(connection: () => DeepSeekConnectionOptions) { + return new DeepSeekAdapter({ + options: connection, + resolveApiKey: snapshot => Promise.resolve(`key-for-${snapshot.apiKeyEnv}`), + resolveUserId: () => '00000000-0000-4000-8000-000000000001' as AnonymousUserId, + prepareExtensions: () => Promise.resolve({ fields: {}, accept: () => Promise.resolve() }), + }) +} + +it.each([false, true])('uses Messages, schema=%s', async (schema) => { + const http = await endpoint() + const raw = { baseURL: http.url } + const connection = resolveAdapterOptions(schema ? Config(raw) : raw) + const response = await assemble(adapter(() => connection).stream(options())) + + expect(response.message.content).toEqual([{ type: 'text', text: 'Hello 世界' }]) + expect(http.requests).toHaveLength(1) + expect(http.requests[0]).toMatchObject({ + path: '/anthropic/v1/messages', + headers: { 'x-api-key': 'key-for-DEEPSEEK_API_KEY', 'anthropic-version': '2023-06-01' }, + body: { model: MODEL, messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }] }, + }) +}) + +it('keeps the prepared credential reference and endpoint after configuration changes', async () => { + const first = await endpoint(), second = await endpoint() + let connection = resolveAdapterOptions({ baseURL: first.url, apiKeyEnv: 'FIRST_KEY', maxTokens: 12 }) + const llm = adapter(() => connection) + const prepared = await llm.prepareCall('deepseek-official', MODEL) + connection = resolveAdapterOptions({ baseURL: second.url, apiKeyEnv: 'SECOND_KEY', maxTokens: 24 }) + await chunks(prepared.stream(options())) + await chunks(prepared.stream(options())) + expect(prepared.model.defaultMaxTokens).toBe(12) + expect((await llm.resolveModel('deepseek-official', MODEL)).defaultMaxTokens).toBe(24) + await chunks(llm.stream(options())) + expect(first.requests).toHaveLength(2) + for (const request of first.requests) expect(request).toMatchObject({ + path: '/anthropic/v1/messages', headers: { 'x-api-key': 'key-for-FIRST_KEY' }, body: { max_tokens: 12 }, + }) + expect(second.requests).toHaveLength(1) + expect(second.requests[0]).toMatchObject({ path: '/anthropic/v1/messages', headers: { 'x-api-key': 'key-for-SECOND_KEY' } }) +}) diff --git a/packages/llm/llm-deepseek/tests/protocol.spec.ts b/packages/llm/llm-deepseek/tests/protocol.spec.ts deleted file mode 100644 index 788df7982f..0000000000 --- a/packages/llm/llm-deepseek/tests/protocol.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** Same-provider protocol changes retain prepared requests and durable conversation content. */ -import { afterEach, expect, it } from 'vitest' -import type { Message } from '@deepseek-ai/dsh-llm' -import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' -import { Config, DeepSeekAdapter, resolveAdapterOptions } from '../src/index.ts' -import type { DeepSeekConnectionOptions } from '../src/index.ts' -import { assemble, chunks, end, MODEL, options, server, sse, start, textEvents, user } from './messages/helpers.ts' - -const close: (() => Promise)[] = [] -afterEach(async () => { - while (close.length) await close.pop()!() -}) -async function endpoint(...args: Parameters) { - const instance = await server(...args) - close.push(() => instance.close()) - return instance -} -const chat = 'data: {"choices":[{"delta":{"content":"Chat answer"}}]}\n\n' - + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n' -function adapter(connection: () => DeepSeekConnectionOptions) { - return new DeepSeekAdapter({ - options: connection, - resolveApiKey: snapshot => Promise.resolve(`key-for-${snapshot.apiKeyEnv}`), - resolveUserId: () => '00000000-0000-4000-8000-000000000001' as AnonymousUserId, - prepareExtensions: () => Promise.resolve({ fields: {}, accept: () => Promise.resolve() }), - }) -} - -it.each([false, true])('uses Messages when protocol is omitted, schema=%s', async (schema) => { - const http = await endpoint() - const raw = { baseURL: http.url } - const connection = resolveAdapterOptions(schema ? Config(raw) : raw) - const response = await assemble(adapter(() => connection).stream(options())) - - expect(response.message.content).toEqual([{ type: 'text', text: 'Hello 世界' }]) - expect(http.requests).toHaveLength(1) - expect(http.requests[0]).toMatchObject({ - path: '/anthropic/v1/messages', - headers: { 'x-api-key': 'key-for-DEEPSEEK_API_KEY', 'anthropic-version': '2023-06-01' }, - body: { model: MODEL, messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }] }, - }) -}) - -it('keeps the prepared Messages protocol, credential reference and endpoint after switching to Chat', async () => { - const first = await endpoint(), second = await endpoint(response => response.end(chat)) - let connection = resolveAdapterOptions({ protocol: 'messages', baseURL: first.url, apiKeyEnv: 'MESSAGES_KEY', maxTokens: 12 }) - const llm = adapter(() => connection) - const prepared = await llm.prepareCall('deepseek-official', MODEL) - connection = resolveAdapterOptions({ protocol: 'chat-completions', baseURL: second.url, apiKeyEnv: 'CHAT_KEY', maxTokens: 24 }) - await chunks(prepared.stream(options())) - await chunks(prepared.stream(options())) - expect(prepared.model.defaultMaxTokens).toBe(12) - expect((await llm.resolveModel('deepseek-official', MODEL)).defaultMaxTokens).toBe(24) - await chunks(llm.stream(options())) - expect(first.requests).toHaveLength(2) - for (const request of first.requests) expect(request).toMatchObject({ - path: '/anthropic/v1/messages', headers: { 'x-api-key': 'key-for-MESSAGES_KEY' }, body: { max_tokens: 12 }, - }) - expect(second.requests).toHaveLength(1) - expect(second.requests[0]).toMatchObject({ path: '/anthropic/chat/completions', headers: { authorization: 'Bearer key-for-CHAT_KEY' } }) -}) - -it('continues Messages → Chat → Messages with the same provider and without leaking native signatures to Chat', async () => { - const signed = [start, - { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: 'Reasoning', signature: 'native-signature' } }, - { type: 'content_block_stop', index: 0 }, - { type: 'content_block_start', index: 1, content_block: { type: 'text', text: 'Messages answer' } }, - { type: 'content_block_stop', index: 1 }, ...end(), - ] - const http = await endpoint((response, count) => response.end(count === 1 ? sse(signed) : count === 2 ? chat : sse(textEvents))) - let connection = resolveAdapterOptions({ protocol: 'messages', baseURL: http.url }) - const llm = adapter(() => connection) - const history: Message[] = [user()] - const first = await assemble(llm.stream(options({ messages: history }))) - history.push(first.message, user('continue with Chat')) - const saved = JSON.stringify(history) - connection = resolveAdapterOptions({ protocol: 'chat-completions', baseURL: http.url }) - const second = await assemble(llm.stream(options({ messages: history }))) - expect(JSON.stringify(http.requests[1]?.body)).not.toContain('signature') - expect(http.requests[1]?.body.messages).toContainEqual({ role: 'assistant', content: 'Messages answer', reasoning_content: 'Reasoning' }) - expect(JSON.stringify(history)).toBe(saved) - expect(second.message.source.replayState).toBeUndefined() - history.push(second.message, user('continue with Messages')) - connection = resolveAdapterOptions({ protocol: 'messages', baseURL: http.url }) - const third = await assemble(llm.stream(options({ messages: history }))) - expect(third.assembler.finish.kind).toBe('stop') - const messages = http.requests[2]?.body.messages as { role: string; content: unknown[] }[] - expect(messages.filter(message => message.role === 'assistant')).toEqual([ - { role: 'assistant', content: [{ type: 'thinking', thinking: 'Reasoning', signature: 'native-signature' }, { type: 'text', text: 'Messages answer' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'Chat answer' }] }, - ]) -}) diff --git a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts index bf2216d6c8..cb4b31d3c6 100644 --- a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts +++ b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts @@ -3,7 +3,7 @@ import { offloadedImageText, requestImageHandleText, textOnlyImageText } from '@ import type { ImageBlock } from '@deepseek-ai/dsh-llm' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { deepSeekImageRequestPricing } from '../src/common/request-pricing.ts' +import { deepSeekImageRequestPricing } from '../src/request-pricing.ts' import { resolveAdapterOptions } from '../src/index.ts' import type { Config } from '../src/index.ts' diff --git a/packages/llm/llm-deepseek/tests/runtime.e2e.ts b/packages/llm/llm-deepseek/tests/runtime.e2e.ts new file mode 100644 index 0000000000..62c727a719 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/runtime.e2e.ts @@ -0,0 +1,462 @@ +import { readFileSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomBytes } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LlmRuntime, { createToolResultMessage, createUserMessage, ToolCallId, ReasoningEffortId, createMessage, createSystemMessage } from '@deepseek-ai/dsh-llm' +import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import AttachmentStore, { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import LocalAttachments from '@deepseek-ai/dsh-attachment-local' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + ImageRequestTarget, + RequestImageAttachment, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' +import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' +import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import type { Config } from '@deepseek-ai/dsh-llm-deepseek' +import type { WireRequest } from '../src/wire-types.ts' +import { assemble, type AssembledResult } from './assemble.ts' + +/** + * Real-API e2e for the direct-fetch adapter: V4 Flash across thinking modes + * and a max-effort tool round trip with reasoning passback. The suite skips + * entirely without $DEEPSEEK_API_KEY; the pre-release vision smoke additionally + * requires $DEEPSEEK_VISION_E2E=1, and the Flash image/system-update smoke + * requires $DEEPSEEK_FLASH_E2E=1 (see vitest.e2e.config.ts). + */ + +const FLASH = 'deepseek-v4-flash' +const VISION = 'deepseek-v4-flash-vision-exp' +const VISION_E2E_ENABLED = process.env.DEEPSEEK_VISION_E2E === '1' +/** A model whose endpoint reads the latest `system` message at any position; unset skips the in-history smoke. */ +const IN_HISTORY_MODEL = process.env.DEEPSEEK_IN_HISTORY_MODEL +const TEST_PNG = Uint8Array.from(readFileSync( + new URL('../../llm-pi-ai/tests/fixtures/qr-code.png', import.meta.url), +)) +const contexts: Context[] = [] +let identityHome: string + +class E2eAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: TEST_PNG.byteLength, + maxImagesPerMessage: 1, + maxMessageImageBytes: TEST_PNG.byteLength, + maxImagePixels: 256 * 256, + maxImageDimension: 256, + mediaTypes: ['image/png'], + } + readonly ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${randomBytes(32).toString('hex')}`), + mediaType: 'image/png', + bytes: TEST_PNG.byteLength, + width: 256, + height: 256, + name: 'files-api-e2e.png', + } + readonly version: RequestImageAttachment = { + variantId: ImageVariantId(`sha256:${randomBytes(32).toString('hex')}`), + attachment: this.ref, + data: TEST_PNG, + mediaType: 'image/png', + bytes: TEST_PNG.byteLength, + width: 256, + height: 256, + depth: 'uchar', + space: 'srgb', + hasAlpha: false, + } + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.resolve(this.ref) + } + + readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { + return Promise.resolve({ ref, data: TEST_PNG }) + } + + override readImageRequest( + _ref: ImageAttachmentRef, + _target: ImageRequestTarget, + _signal?: AbortSignal, + ): Promise { + return Promise.resolve(this.version) + } +} + +beforeEach(async () => { + identityHome = await mkdtemp(join(tmpdir(), 'dsh-e2e-user-id-')) + vi.stubEnv('DSH_HOME', identityHome) +}) + +async function harness(model: string, config: Partial = {}) { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + await ctx.plugin(E2eAttachmentStore) + await ctx.plugin(LlmDeepSeek, { + baseURL: LlmDeepSeek.PUBLIC_BASE_URL, + ...model === VISION ? { models: [{ id: VISION, inputModalities: ['text', 'image'] }] } : {}, + ...config, + }) + return ctx +} + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + vi.unstubAllGlobals() + vi.unstubAllEnvs() + await rm(identityHome, { recursive: true, force: true }) +}) + +function ask(text: string): Message[] { + return [createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + })] +} + +function textOf(result: AssembledResult): string { + return result.message.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') +} + +const weatherTool: ToolSchema = { + name: 'get_weather', + description: 'Get the current weather for a city.', + parameters: { + type: 'object', + properties: { city: { type: 'string', description: 'City name' } }, + required: ['city'], + }, +} + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { + it.skipIf(process.env.DEEPSEEK_FLASH_E2E !== '1')('deepseek-flash accepts images and retains system updates', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + await ctx.plugin(LocalAttachments) + await ctx.plugin(LlmDeepSeek, { baseURL: LlmDeepSeek.PUBLIC_BASE_URL, maxTokens: 4096 }) + const model = 'deepseek-flash' + await expect(ctx.llm.resolveModelInfo('deepseek-official', model)).resolves.toMatchObject({ + inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', + }) + const attachment = await ctx.attachments.saveImage({ data: readFileSync(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) + const message = ask('What is the dominant color of this image?')[0]! + const history: Message[] = [ + createSystemMessage('Answer with one English color word.'), + { ...message, content: [...message.content, { type: 'image', attachment }] }, + ] + const reply = async () => { + const response = await assemble(ctx, { model, messages: history, reasoningEffort: ReasoningEffortId('high') }) + expect(response.finish.kind, JSON.stringify(response.finish)).toBe('stop') + history.push(response.message) + return textOf(response).trim().toLowerCase() + } + expect(await reply()).toMatch(/^red[.!]?$/) + history.push(createSystemMessage('Reply to every user message with exactly banana.'), ...ask('Answer now.')) + expect(await reply()).toBe('banana') + history.push(...ask('Answer again.')) + expect(await reply()).toBe('banana') + }) + + it.skipIf(!VISION_E2E_ENABLED)('uses the built-in official route to upload, reference, and delete one image', async () => { + const key = process.env.DEEPSEEK_API_KEY + if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') + const baseURL = LlmDeepSeek.PUBLIC_BASE_URL + const ctx = await harness(VISION, { baseURL }) + await ctx.plugin(E2eAttachmentStore) + const attachments = ctx.attachments as E2eAttachmentStore + let uploadedFile: LlmDeepSeek.DeepSeekFileIdType | undefined + const nativeFetch = globalThis.fetch + const observedFetch: typeof fetch = async (input, init) => { + const response = await nativeFetch(input, init) + const url = new URL(input instanceof Request ? input.url : input) + const method = init?.method ?? (input instanceof Request ? input.method : 'GET') + if (method === 'POST' && url.pathname.endsWith('/files') && response.ok) { + const value = await response.clone().json() as { id?: unknown } + if (typeof value.id === 'string') uploadedFile = LlmDeepSeek.DeepSeekFileId(value.id) + } + return response + } + vi.stubGlobal('fetch', observedFetch) + const files = new LlmDeepSeek.DeepSeekFilesClient({ baseURL, apiKey: key }) + + try { + const result = await assemble(ctx, { + model: VISION, + messages: [createUserMessage({ + content: [ + { type: 'text', text: 'Briefly describe this image.' }, + { type: 'image', attachment: attachments.ref }, + ], + source: { kind: 'user' }, + })], + maxTokens: 100, + }) + expect( + result.finish.kind, + `DeepSeek vision result: ${JSON.stringify(result.finish)}`, + ).toBe('stop') + expect(textOf(result).trim().length).toBeGreaterThan(0) + expect(uploadedFile).toMatch(/^file-api-/u) + } finally { + if (uploadedFile !== undefined) await files.delete(uploadedFile) + } + }) + + it('accepts the session-log and plugin-package request extension fields', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(DeepSeekLlmApiExtensionRegistry) + await ctx.plugin(SessionLogDeepSeek, { enabled: true }) + await ctx.plugin(PluginPackageInventoryDeepSeek) + await ctx.plugin(LlmDeepSeek, { baseURL: LlmDeepSeek.PUBLIC_BASE_URL, thinking: 'disabled' }) + const session = ctx.sessions.create(SessionId('real-extension-fields')) + session.append('turn/start', { turn: 1 }) + + const result = await assemble(ctx, { + model: FLASH, + messages: ask('Reply with exactly the word: pong'), + maxTokens: 50, + sessionId: session.id, + }) + expect(result.finish.kind).toBe('stop') + expect(textOf(result).toLowerCase()).toContain('pong') + expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(0) + }) + + it('serves a real request with the key held only by a credentials-local document', async () => { + const key = process.env.DEEPSEEK_API_KEY + if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') + const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-')) + try { + // JSON.stringify quotes the value: YAML is a JSON superset, so a real + // key survives whatever characters it happens to carry. + await writeFile(join(dir, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 }) + // Scrub the ambient variable so only the credential seam can supply the + // key: this request proves the per-request resolution path end to end. + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) + await ctx.plugin(LlmDeepSeek, { baseURL: LlmDeepSeek.PUBLIC_BASE_URL }) + + const result = await assemble(ctx, { + model: FLASH, + messages: ask('Reply with exactly the word: pong'), + maxTokens: 50, + }) + expect(result.finish.kind).toBe('stop') + expect(textOf(result).toLowerCase()).toContain('pong') + } finally { + vi.unstubAllEnvs() + await rm(dir, { recursive: true, force: true }) + } + }) + + it('flash dynamically switches from off to low', async () => { + const ctx = await harness(FLASH, { reasoningEffort: 'off' }) + const withoutThinking = await assemble(ctx,{ + model: FLASH, + messages: ask('Reply with exactly the word: pong'), + maxTokens: 50, + }) + expect(withoutThinking.finish.kind).toBe('stop') + expect(textOf(withoutThinking).toLowerCase()).toContain('pong') + expect(withoutThinking.message.content.some(block => block.type === 'reasoning')).toBe(false) + expect(withoutThinking.usage?.inputTokens).toBeGreaterThan(0) + expect(withoutThinking.usage?.outputTokens).toBeGreaterThan(0) + + const withThinking = await assemble(ctx,{ + model: FLASH, + reasoningEffort: ReasoningEffortId('low'), + messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'), + maxTokens: 2000, + }) + expect(withThinking.finish.kind).toBe('stop') + expect(withThinking.message.content.some(block => block.type === 'reasoning')).toBe(true) + expect(textOf(withThinking)).toContain('9.8') + expect(withThinking.usage?.reasoningTokens).toBeGreaterThan(0) + }) + + it( + 'flash + thinking enabled (effort max): tool-call round trip with reasoning passback', + async () => { + const ctx = await harness(FLASH, { thinking: 'enabled' }) + + // Turn 1: the model must call the tool (and think before it). + const first = await assemble(ctx,{ + model: FLASH, + reasoningEffort: ReasoningEffortId('max'), + messages: ask('What is the weather in Paris right now? Use the get_weather tool.'), + tools: [weatherTool], + maxTokens: 2000, + }) + expect( + first.finish.kind, + `DeepSeek Flash tool-call turn finished as ${JSON.stringify(first.finish)}`, + ).toBe('tool-calls') + const call = first.message.content.find(block => block.type === 'tool-call') + expect(call).toBeDefined() + expect(call!.name).toBe('get_weather') + expect(JSON.parse(call!.arguments)).toMatchObject({ city: expect.stringMatching(/paris/i) as string }) + + // Turn 2: send the tool result back WITH the assistant's reasoning + // block in history (the official thinking+tools passback rule). + const second = await assemble(ctx,{ + model: FLASH, + reasoningEffort: ReasoningEffortId('max'), + messages: [ + ...ask('What is the weather in Paris right now? Use the get_weather tool.'), + createMessage({ + role: 'assistant', content: first.message.content, + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + createToolResultMessage({ + callId: ToolCallId(call!.id), + content: [{ type: 'text', text: 'Sunny, 22°C' }], + isError: false, + }), + ], + tools: [weatherTool], + maxTokens: 2000, + }) + expect( + second.finish.kind, + `DeepSeek Flash tool-result turn finished as ${JSON.stringify(second.finish)}`, + ).toBe('stop') + expect(textOf(second).toLowerCase()).toMatch(/sunny|22/) + }, + ) + + it.skipIf(IN_HISTORY_MODEL === undefined)( + 'an in-history model follows a mid-history system message and keeps the cached prefix', + async () => { + const model = IN_HISTORY_MODEL as string + const ctx = await harness(model, { + thinking: 'disabled', + models: [{ id: model, systemPromptUpdate: 'in-history' }], + }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', model)) + .resolves.toMatchObject({ systemPromptUpdate: 'in-history' }) + const system = (text: string) => createSystemMessage(text) + // A nonce before the padding isolates the provider cache across runs and retries. + const nonce = randomBytes(16).toString('hex') + const padding = Array.from({ length: 40 }, (_, index) => `Rule ${String(index + 1)}: keep every answer short and factual.`).join('\n') + // Both update strategies use identical prompt bytes, with the changed instruction near the head. + const prompt = (word: string) => `Test ${nonce}. When the user says ping, reply with exactly the word: ${word}\n${padding}` + const initial = prompt('pong') + const latest = prompt('banana') + const history = [system(initial), ...ask('ping')] + const wireRequests: WireRequest[] = [] + const nativeFetch = globalThis.fetch + const observedFetch: typeof fetch = async (input, init) => { + if (init?.method !== 'POST' || typeof init.body !== 'string') return nativeFetch(input, init) + const body = JSON.parse(init.body) as WireRequest + if (body.model === model) { + wireRequests.push(body) + if (wireRequests.length === 1) { + expect(body.system).toBe(initial) + expect(body.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'ping' }] }]) + } else if (wireRequests.length === 2) { + expect(body).toEqual(wireRequests[0]) + } else if (wireRequests.length === 3) { + expect(body.system).toBe(initial) + expect(body.messages[0]).toEqual(wireRequests[0]!.messages[0]) + expect(body.messages.at(-1)).toEqual({ role: 'system', content: [{ type: 'text', text: latest }] }) + } else if (wireRequests.length === 4) { + expect(body.system).toBe(latest) + expect(body.messages).toEqual(wireRequests[2]!.messages.filter(message => message.role !== 'system')) + } + } + return nativeFetch(input, init) + } + vi.stubGlobal('fetch', observedFetch) + try { + const first = await assemble(ctx, { model, messages: history, maxTokens: 50 }) + expect(first.finish.kind).toBe('stop') + expect(textOf(first).trim()).toBe('pong') + const initialTokens = first.usage?.inputTokens ?? 0 + expect(initialTokens).toBeGreaterThan(0) + + // Measure reusable tokens rather than assuming a provider cache-block size. + const warm = await assemble(ctx, { model, messages: history, maxTokens: 50 }) + expect(warm.finish.kind).toBe('stop') + expect(textOf(warm).trim()).toBe('pong') + const reusableTokens = warm.usage?.cacheReadTokens ?? 0 + expect(reusableTokens).toBeLessThanOrEqual(initialTokens) + expect(reusableTokens).toBeGreaterThan(0) + const assistant = createMessage({ + role: 'assistant', content: first.message.content, source: first.message.source, + }) + + const updated = await assemble(ctx, { + model, + messages: [...history, assistant, system(latest), ...ask('ping')], + maxTokens: 50, + }) + expect(updated.finish.kind).toBe('stop') + expect(textOf(updated).trim()).toBe('banana') + + const replaced = await assemble(ctx, { + model, + messages: [system(latest), ...ask('ping'), assistant, ...ask('ping')], + maxTokens: 50, + }) + expect(replaced.finish.kind).toBe('stop') + expect(textOf(replaced).trim()).toBe('banana') + expect(wireRequests).toHaveLength(4) + + const cached = updated.usage?.cacheReadTokens ?? 0 + expect(replaced.usage?.cacheReadTokens).toBeDefined() + const baselineCached = replaced.usage?.cacheReadTokens ?? 0 + expect(cached).toBeGreaterThanOrEqual(reusableTokens) + expect(cached).toBeGreaterThan(baselineCached) + } finally { + vi.stubGlobal('fetch', nativeFetch) + } + }, + ) + + it('streams raw chunks in protocol order', async () => { + const ctx = await harness(FLASH, { thinking: 'disabled' }) + const kinds: string[] = [] + for await (const chunk of ctx.llm.stream({ + provider: 'deepseek-official', + model: FLASH, + messages: ask('Count from 1 to 5, digits only.'), + maxTokens: 50, + })) { + kinds.push(chunk.type) + } + expect(kinds[0]).toBe('block-start') + expect(kinds.at(-1)).toBe('finish') + expect(kinds.filter(kind => kind === 'finish')).toHaveLength(1) + // usage precedes finish (deferred-emit contract) + expect(kinds.indexOf('usage')).toBeLessThan(kinds.indexOf('finish')) + }) +}) diff --git a/packages/llm/llm-deepseek/tests/runtime.spec.ts b/packages/llm/llm-deepseek/tests/runtime.spec.ts new file mode 100644 index 0000000000..55ab3acb4f --- /dev/null +++ b/packages/llm/llm-deepseek/tests/runtime.spec.ts @@ -0,0 +1,2340 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import LlmRuntime, { ToolCallId, createUserMessage, + CONTEXT_WINDOW_EXCEEDED_CODE, + createToolResultMessage, + createAssistantMessage, + LlmError, + ProviderRequestId, + QUOTA_EXCEEDED_CODE, + ReasoningEffortId, + userAgent, +} from '@deepseek-ai/dsh-llm' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' +import { SessionId } from '@deepseek-ai/dsh-session' +import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import type { PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' +import type { ContextFormed } from '@deepseek-ai/dsh-llm' +import { providerError } from '../src/transport.ts' +import { resolveRequestImageTarget } from '../src/request-pricing.ts' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' +import type { Behavior } from './mock-server.ts' +import { requestImageStore } from './helpers.ts' + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + 'test': { kind: 'test' } & ContextFormed + } +} + +const TEST_USER_ID = '00000000-0000-4000-8000-000000000001' as AnonymousUserId +let testHome: string + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), 'dsh-llm-deepseek-')) + vi.stubEnv('DSH_HOME', testHome) +}) + +afterEach(async () => { + await closeMockServers() + vi.unstubAllEnvs() + vi.useRealTimers() + rmSync(testHome, { recursive: true, force: true }) +}) + +async function harness(baseURL: string, config: object = {}) { + // Configuration carries only the reference; the key comes from the + // environment, which is the whole credential plane without a mounted seam. + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(DeepSeekLlmApiExtensionRegistry) + await ctx.plugin(LlmDeepSeek, { baseURL, ...config }) + return ctx +} + +/** Direct adapter over the plugin's real resolve step, with a static key. */ +function noExtensions(): Promise { + return Promise.resolve({ fields: {}, accept: () => Promise.resolve() }) +} + +/** Direct adapter over the plugin's real resolve step, with a static key. */ +function adapterOf( + config: Partial & { apiKey?: string } = {}, + attachments?: AttachmentStore, + files?: LlmDeepSeek.DeepSeekFileStore, +): DeepSeekAdapter { + const { apiKey, ...rest } = config + return new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ ...rest }), + resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), + resolveUserId: () => TEST_USER_ID, + resolveAttachments: () => attachments, + ...files === undefined ? {} : { resolveFiles: () => files }, + prepareExtensions: noExtensions, + }) +} + +async function drain(stream: AsyncIterable): Promise { + for await (const _chunk of stream) { /* drain */ } +} + +const imageRef: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, +} + +function requestImage(ref = imageRef): RequestImageAttachment { + return { + variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), + attachment: ref, + data: Uint8Array.of(1, 2, 3), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, + depth: 'uchar', + space: 'srgb', + hasAlpha: true, + } +} + +function attachmentStoreOf( + project: (ref: ImageAttachmentRef, policy: unknown, signal?: AbortSignal) => Promise, +): { + store: AttachmentStore + readImageRequest: ReturnType> +} { + const readImageRequest = vi.fn(project) + return { + store: requestImageStore(readImageRequest), + readImageRequest, + } +} + +function fileStoreOf( + implementation: (...args: Parameters) => ReturnType, +) { + const ensureUploaded = vi.fn(implementation) + const invalidate = vi.fn(() => Promise.resolve()) + return { + store: Object.assign(new LlmDeepSeek.DeepSeekFileStore(), { ensureUploaded, invalidate }), + ensureUploaded, + invalidate, + } +} + +function fileReference(fileId: string): Awaited> { + return { + record: { fileId: LlmDeepSeek.DeepSeekFileId(fileId) }, + uploaded: true, + } as Awaited> +} + +function successfulSseResponse(): Response { + return new Response(textEvents.map(event => `data: ${event}\n\n`).join(''), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) +} + +describe('request image target', () => { + it.each([ + [ + { id: 'default' }, + { width: 1302, height: 1302, maxBytes: 2 * 1024 * 1024 }, + ], + [ + { id: 'low', imagePixelBudget: 'low' as const }, + { width: 512, height: 512, maxBytes: 2 * 1024 * 1024 }, + ], + [ + { id: 'custom', imagePixelBudget: 320_000, imageMaxBytes: 512_000 }, + { width: 565, height: 565, maxBytes: 512_000 }, + ], + ])('resolves route-owned defaults and overrides for %s on a 4096x4096 source', (model, expected) => { + expect(resolveRequestImageTarget(model, { width: 4096, height: 4096 })).toEqual(expected) + }) + + it('caps every request image at the provider per-side limit', () => { + expect(resolveRequestImageTarget({ id: 'default' }, { width: 8192, height: 78 })) + .toEqual({ width: 4096, height: 39, maxBytes: 2 * 1024 * 1024 }) + expect(resolveRequestImageTarget({ id: 'custom', imagePixelBudget: 640_000 }, { width: 10_000, height: 100 })) + .toEqual({ width: 4096, height: 41, maxBytes: 2 * 1024 * 1024 }) + }) + + it('answers image request pricing from the current connection snapshot', () => { + const adapter = adapterOf({ + models: [{ id: 'vision', inputModalities: ['text', 'image'] }], + }) + const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([{ type: 'image', attachment: imageRef }]) + expect(priced).toHaveLength(1) + expect(priced?.[0]!.visualTokens).toBeGreaterThan(0) + const textOnly = adapter.imageRequestPricing('deepseek-official', 'unlisted')?.priceImages([{ type: 'image', attachment: imageRef }]) + expect(textOnly?.[0]!.visualTokens).toBe(0) + }) + + it('prices descriptor text through the serializer\'s access resolution', () => { + const attachments = {} as AttachmentStore + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ models: [{ id: 'vision', inputModalities: ['text', 'image'] }] }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + resolveAttachments: () => attachments, + resolveImageAccess: (store, ref) => (store === attachments && ref === imageRef + ? { readonlyPath: '/world/img.png' } + : undefined), + prepareExtensions: noExtensions, + }) + const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([{ type: 'image', attachment: imageRef }]) + expect(priced?.[0]?.text).toContain('/world/img.png') + }) +}) + +describe('DeepSeekAdapter against a mock server', () => { + it('merges prepared extension fields and accepts them once after HTTP 2xx', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const accept = vi.fn() + const prepareExtensions = vi.fn(async () => ({ + fields: { dsh_test: { version: 1 } }, + accept: async () => { accept() }, + })) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: prepareExtensions as never, + }) + + await drain(adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [], sessionId: SessionId('s') })) + expect(server.requests[0]).toMatchObject({ dsh_test: { version: 1 } }) + expect(prepareExtensions).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 's' })) + expect(accept).toHaveBeenCalledOnce() + }) + + it('fails before fetch on extension preparation or base-field collision', async () => { + const server = await mockServer([]) + const base = { + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + } + const failed = new DeepSeekAdapter({ + ...base, + prepareExtensions: () => Promise.reject(new Error('metadata unavailable')), + }) + await expect(drain(failed.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) + .rejects.toMatchObject({ code: 'REQUEST_EXTENSION' }) + + const collision = new DeepSeekAdapter({ + ...base, + prepareExtensions: (() => Promise.resolve({ fields: { model: 'replacement' }, accept: () => Promise.resolve() })) as never, + }) + await expect(drain(collision.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) + .rejects.toMatchObject({ code: 'REQUEST_EXTENSION' }) + expect(server.requests).toHaveLength(0) + }) + + it('passes cancellation into extension preparation and aborts before fetch', async () => { + const server = await mockServer([]) + const controller = new AbortController() + const started = Promise.withResolvers() + let signalSeen: AbortSignal | undefined + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: ((request: { signal: AbortSignal }) => { + signalSeen = request.signal + started.resolve(undefined) + if (request.signal === undefined) return new Promise(() => {}) + return new Promise((_resolve, reject) => { + request.signal.addEventListener('abort', () => { + const reason: unknown = request.signal.reason + reject(reason instanceof Error ? reason : new Error('extension preparation aborted', { cause: reason })) + }, { once: true }) + }) + }) as never, + }) + + const pending = drain(adapter.stream({ + provider: 'deepseek-official', + model: 'm', + messages: [], + signal: controller.signal, + })) + await started.promise + expect(signalSeen).toBeInstanceOf(AbortSignal) + controller.abort() + await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) + expect(server.requests).toHaveLength(0) + }) + + it('passes cancellation through an outstanding fetch', async () => { + const controller = new AbortController() + const started = Promise.withResolvers() + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { + const signal = init?.signal + return new Promise((_resolve, reject) => { + started.resolve(undefined) + signal?.addEventListener('abort', () => { + const reason: unknown = signal.reason + reject(reason instanceof Error ? reason : new Error('fetch aborted', { cause: reason })) + }, { once: true }) + }) + }) + try { + const adapter = adapterOf({ baseURL: 'https://provider.invalid' }) + const pending = drain(adapter.stream({ + provider: 'deepseek-official', + model: 'm', + messages: [], + signal: controller.signal, + })) + await started.promise + controller.abort() + await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) + } finally { + fetch.mockRestore() + } + }) + + it('does not accept extensions on non-2xx and does accept before a later stream failure', async () => { + const server = await mockServer([ + { kind: 'http-error', status: 500, body: '{}' }, + { kind: 'close-early', events: textEvents.slice(0, 3) }, + ]) + const accept = vi.fn() + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: () => Promise.resolve({ fields: { dsh_test: 1 }, accept: async () => { accept() } }) as never, + }) + const request = { provider: 'deepseek-official', model: 'm', messages: [] } + + await expect(drain(adapter.stream(request))).rejects.toMatchObject({ code: 'SERVER' }) + expect(accept).not.toHaveBeenCalled() + await expect(drain(adapter.stream(request))).rejects.toBeDefined() + expect(accept).toHaveBeenCalledOnce() + }) + + it('reports a post-2xx extension acceptance failure without relabelling it as transport', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const failure = new Error('watermark append failed') + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: () => Promise.resolve({ + fields: { dsh_test: 1 }, + accept: () => Promise.reject(failure), + }) as never, + }) + + await expect(drain(adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) + .rejects.toMatchObject({ code: 'REQUEST_EXTENSION', cause: failure }) + expect(server.requests).toHaveLength(1) + }) + + it('streams a text generation end to end through the assembler', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) + + const result = await assemble(ctx, { + model: 'deepseek-v4-pro', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(result.finish).toEqual({ kind: 'stop' }) + expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1, totalTokens: 4 }) + + // The wire request carried the auth header contents we configured. + expect(server.requests[0]).toMatchObject({ + model: 'deepseek-v4-pro', + max_tokens: 256_000, + output_config: { effort: 'high' }, + stream: true, + }) + // App attribution and DeepSeek request identity are independent wire facts. + expect(server.headers[0]?.['user-agent']).toBe(userAgent()) + expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) + expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-session-id') + expect(server.headers[0]).not.toHaveProperty('http-referer') + expect(server.headers[0]).not.toHaveProperty('x-openrouter-title') + expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories') + expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-compact') + }) + + it('uploads a durable image once and sends only its Files API id to the vision model', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const signalSeen: (AbortSignal | undefined)[] = [] + const policies: unknown[] = [] + const attachmentMocks = attachmentStoreOf((ref, policy, signal) => { + signalSeen.push(signal) + policies.push(policy) + return Promise.resolve(requestImage(ref)) + }) + const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-flash', + messages: [createUserMessage({ + content: [ + { type: 'text', text: 'describe ' }, + { type: 'image', attachment: imageRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(server.requests[0]).toMatchObject({ + model: 'deepseek-flash', + messages: [{ + role: 'user', + content: [ + { type: 'text', text: 'describe ' }, + { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request preview 1x1px.`) as string }, + { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, + ], + }], + }) + expect(server.fileRequests).toEqual([{ + method: 'POST', + path: '/v1/files', + filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`, + bytes: 3, + }]) + expect(signalSeen[0]).toBeInstanceOf(AbortSignal) + expect(policies).toEqual([{ width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }]) + }) + + it('rejects unknown plugin content without preparing nested images', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store) + await expect(drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-pro', messages: [ + createUserMessage({ source: { kind: 'user' }, content: [ + { type: 'plugin:message', + message: { role: 'tool', toolCallId: 'foreign', content: [{ type: 'image', attachment: { ...imageRef } }] }, + } as never, + { type: 'text', text: 'context' }, + ] }), + ] }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(attachmentMocks.readImageRequest).not.toHaveBeenCalled() + expect(server.fileRequests).toHaveLength(0) + expect(server.requests).toHaveLength(0) + }) + + it('falls back to one all-base64 request when Files API resolution fails', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + const body = server.requests[0] as { messages: Array<{ content: unknown }> } + expect(JSON.stringify(body.messages[0]?.content).match(/"type":"image"/g)).toHaveLength(2) + expect(JSON.stringify(body)).not.toContain('file_id') + expect(files.ensureUploaded).toHaveBeenCalledTimes(1) + }) + + it('fails a base64 fallback whose retained images exceed the inline bound with the count to offload', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) + const adapter = adapterOf({ + baseURL: server.url, + maxInlineRequestImageBytes: 80, + inlineImageOffloadByteQuantum: 40, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: Array.from({ length: 21 }, () => ({ type: 'image' as const, attachment: imageRef })), + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', message: expect.stringContaining('11 more') as string }) + expect(server.requests).toHaveLength(0) + }) + it('discards partially resolved file ids and falls back with every retained image inline', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const files = fileStoreOf(() => Promise.reject(new Error('unused'))) + files.ensureUploaded + .mockResolvedValueOnce(fileReference('file-api-partial')) + .mockRejectedValueOnce(new LlmError('Files unavailable', 'TRANSPORT')) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + const body = server.requests[0] as { messages: Array<{ content: unknown }> } + expect(JSON.stringify(body.messages[0]?.content).match(/"type":"image"/g)).toHaveLength(2) + expect(JSON.stringify(body)).not.toContain('file-api-partial') + }) + + it('falls back after the configured Files API deadline without aborting the model request', async () => { + vi.useFakeTimers() + const started = Promise.withResolvers() + const files = fileStoreOf((_version, _connection, _policy, signal) => new Promise((_resolve, reject) => { + started.resolve(undefined) + signal?.addEventListener('abort', () => { + const reason: unknown = signal.reason + reject(reason instanceof Error ? reason : new Error('files operation aborted')) + }, { once: true }) + })) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulSseResponse()) + const adapter = adapterOf({ + baseURL: 'https://deepseek.invalid', + filesApiTimeoutMs: 50, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + const pending = drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + await started.promise + await vi.advanceTimersByTimeAsync(50) + await pending + + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy.mock.calls[0]?.[1]?.body).toEqual(expect.stringContaining('base64')) + fetchSpy.mockRestore() + }) + + it('does not turn caller cancellation during file resolution into base64 fallback', async () => { + const started = Promise.withResolvers() + const files = fileStoreOf((_version, _connection, _policy, signal) => new Promise((_resolve, reject) => { + started.resolve(undefined) + signal?.addEventListener('abort', () => { reject(new Error('cancelled')) }, { once: true }) + })) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const fetchSpy = vi.spyOn(globalThis, 'fetch') + const controller = new AbortController() + const adapter = adapterOf({ + baseURL: 'https://deepseek.invalid', + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + const pending = drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + signal: controller.signal, + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + await started.promise + controller.abort() + + await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) + expect(fetchSpy).not.toHaveBeenCalled() + fetchSpy.mockRestore() + }) + + it('does not retry a generic Messages failure through base64 fallback', async () => { + const server = await mockServer([{ + kind: 'http-error', + status: 503, + body: JSON.stringify({ error: { message: 'chat unavailable' } }), + }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const files = fileStoreOf(() => Promise.resolve(fileReference('file-api-ready'))) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'SERVER', message: 'chat unavailable' }) + + expect(server.requests).toHaveLength(1) + expect(JSON.stringify(server.requests[0])).toContain('file-api-ready') + expect(JSON.stringify(server.requests[0])).not.toContain('base64') + }) + + it('does not prepare a surface-offloaded image and sends its placeholder instead', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const old = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), bytes: 3 } + const recent = { ...imageRef, attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`), bytes: 3 } + const attachmentMocks = attachmentStoreOf((ref) => { + if (ref.attachmentId === old.attachmentId) throw new Error('old image must not be read') + return Promise.resolve(requestImage(ref)) + }) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachmentMocks.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: old, offloaded: true }, + { type: 'image', attachment: recent }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(attachmentMocks.readImageRequest).toHaveBeenCalledWith( + recent, + { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, + expect.any(AbortSignal), + ) + const body = server.requests[0] as { messages: unknown[] } + expect(body.messages[0]).toMatchObject({ + role: 'user', + content: [ + { type: 'text', text: expect.stringContaining(`image omitted to fit request image limits; ${old.attachmentId}`) as string }, + { type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string }, + { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, + ], + }) + }) + + it('projects nested tool-result images with route-owned request budgets', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const adapter = adapterOf({ + baseURL: server.url, + models: [ + { + id: 'vision-low', + inputModalities: ['text', 'image'], + imagePixelBudget: 'low', + imageMaxBytes: 512_000, + }, + { + id: 'vision-custom', + inputModalities: ['text', 'image'], + imagePixelBudget: 320_000, + }, + ], + }, attachmentMocks.store) + const nested = createToolResultMessage({ + callId: ToolCallId('image-result'), + content: [{ type: 'image', attachment: imageRef }], + isError: false, + }) + + const call = createAssistantMessage({ + source: { provider: 'deepseek-official', model: 'vision-low' }, + content: [{ type: 'tool-call', id: nested.toolCallId, name: 'read_image', arguments: '{}' }], + }) + await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-low', messages: [call, nested] })) + await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-custom', messages: [call, nested] })) + + expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( + 1, + imageRef, + { width: 1, height: 1, maxBytes: 512_000 }, + expect.any(AbortSignal), + ) + expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( + 2, + imageRef, + { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, + expect.any(AbortSignal), + ) + }) + + it('reuses the exact request version between agent and compaction calls', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + const messages = [createUserMessage({ + content: [{ type: 'image' as const, attachment: imageRef }], + source: { kind: 'test' as const }, + })] + + await drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-flash-vision-exp', messages })) + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages, + purpose: 'compaction', + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(1) + expect(server.requests).toMatchObject([ + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }] }] }, + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }] }] }, + ]) + expect(server.headers[1]?.['x-deepseek-harness-compact']).toBe('1') + }) + + it('explains a provider rejection of a normalized image and retains the raw response as cause', async () => { + const raw = JSON.stringify({ error: { message: 'unsupported image payload for file-api-1' } }) + const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + let failure: unknown + try { + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + } catch (error: unknown) { + failure = error + } + expect(failure).toMatchObject({ + code: 'INVALID_REQUEST', + message: expect.stringContaining( + `normalized image "${imageRef.attachmentId}" at message 1, image 1`, + ) as string, + cause: { message: raw }, + }) + expect((failure as Error).message).toContain('image/png, 8-bit sRGBA, 1x1') + expect((failure as Error).message).toContain('unsupported image payload for file-api-1') + expect((failure as Error).message).not.toBe(raw) + }) + + it('identifies the sole image when a normalized rejection omits its file id', async () => { + const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) + const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ + message: expect.stringContaining(`normalized image "${imageRef.attachmentId}"`) as string, + }) + }) + + it('lists every candidate when a normalized multi-image rejection names no file id', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) + const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) + const attachments = attachmentStoreOf((ref) => { + const first = ref.attachmentId === imageRef.attachmentId + return Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(first ? 'b' : 'd').repeat(64)}`), + attachment: first ? { ...ref, name: 'diagram.png' } : ref, + hasAlpha: false, + }) + }).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ + code: 'INVALID_REQUEST', + message: expect.stringContaining('Candidate images: "diagram.png"') as string, + cause: { message: raw }, + }) + }) + + it.each([ + 'file-api-1 expired', + 'file_id file-api-10 invalid; file_id file-api-1 expired', + 'file_id file-api-1 expired', + 'file_not_found', + 'file_id file-api-1 deleted', + 'invalid file_id file-api-1', + ])('reuploads once when Messages rejects a Files API reference as %s', async (providerMessage) => { + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: providerMessage } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachmentMocks.store) + const options = { + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image' as const, attachment: imageRef }], + source: { kind: 'test' as const }, + })], + } + + await drain(adapter.stream(options)) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(2) + expect(server.requests).toMatchObject([ + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }] }] }, + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { type: 'image', source: { type: 'file', file_id: 'file-api-2' } }] }] }, + ]) + expect(attachmentMocks.readImageRequest).toHaveBeenCalledTimes(1) + }) + + it('uses inline fallback when stale-id recovery cannot resolve a replacement file', async () => { + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: 'file_id file-api-stale expired' } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const files = fileStoreOf(() => Promise.reject(new Error('unused'))) + files.ensureUploaded + .mockResolvedValueOnce(fileReference('file-api-stale')) + .mockRejectedValueOnce(new LlmError('Files unavailable', 'SERVER')) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(files.invalidate).toHaveBeenCalledTimes(1) + expect(server.requests).toHaveLength(2) + expect(JSON.stringify(server.requests[0])).toContain('file-api-stale') + expect(JSON.stringify(server.requests[1])).toContain('base64') + }) + + it('invalidates only the identified mapping when a multi-image request names one stale file id', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: 'file_id file-api-2 expired' } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(3) + const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> + expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([{ type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, { type: 'image', source: { type: 'file', file_id: 'file-api-2' } }]) + expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([{ type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, { type: 'image', source: { type: 'file', file_id: 'file-api-3' } }]) + }) + + it('invalidates every listed missing file id and preserves unlisted mappings', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const thirdRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), + } + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ + error: { + message: 'path.to.object[index]: the following file_ids do not exist or are not created under your account: ' + + 'file-api-1, file-api-3, file-api-unknown', + }, + }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf((ref) => { + let digest = 'f' + if (ref.attachmentId === imageRef.attachmentId) digest = 'b' + else if (ref.attachmentId === secondRef.attachmentId) digest = 'd' + return Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${digest.repeat(64)}`), + }) + }).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + { type: 'image', attachment: thirdRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(5) + const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> + expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([ + { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, + { type: 'image', source: { type: 'file', file_id: 'file-api-2' } }, + { type: 'image', source: { type: 'file', file_id: 'file-api-3' } }, + ]) + expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([ + { type: 'image', source: { type: 'file', file_id: 'file-api-4' } }, + { type: 'image', source: { type: 'file', file_id: 'file-api-2' } }, + { type: 'image', source: { type: 'file', file_id: 'file-api-5' } }, + ]) + }) + + it('invalidates every used mapping when a stale-file response does not identify one file id', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: 'file reference expired' } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(4) + const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> + expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([{ type: 'image', source: { type: 'file', file_id: 'file-api-3' } }, { type: 'image', source: { type: 'file', file_id: 'file-api-4' } }]) + }) + + it('returns the second stale-file rejection without a third model attempt', async () => { + const stale = JSON.stringify({ error: { message: 'file_id file-api-1 expired' } }) + const server = await mockServer([ + { kind: 'http-error', status: 400, body: stale }, + { kind: 'http-error', status: 400, body: stale }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'INVALID_REQUEST', message: 'file_id file-api-1 expired' }) + expect(server.requests).toHaveLength(2) + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(2) + }) + + it.each(['deepseek-v4-flash', 'unlisted-pass-through'])( + 'rejects image input for text-only model %s before credentials, image reads, or fetch', + async (model) => { + const server = await mockServer([]) + const resolveApiKey = vi.fn(() => Promise.resolve('k')) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const resolveAttachments = vi.fn(() => attachments.store) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey, + resolveUserId: () => TEST_USER_ID, + resolveAttachments, + prepareExtensions: noExtensions, + }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model, + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(resolveApiKey).not.toHaveBeenCalled() + expect(attachments.readImageRequest).not.toHaveBeenCalled() + expect(server.requests).toHaveLength(0) + }, + ) + + it('rejects vision input without an attachment provider before credentials or fetch', async () => { + const server = await mockServer([]) + const resolveApiKey = vi.fn(() => Promise.resolve('k')) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }), + resolveApiKey, + resolveUserId: () => TEST_USER_ID, + prepareExtensions: noExtensions, + }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(resolveApiKey).not.toHaveBeenCalled() + expect(server.requests).toHaveLength(0) + }) + + it('streams raw chunks through ctx.llm.stream', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }]) + const ctx = await harness(server.url) + + const kinds: string[] = [] + for await (const chunk of ctx.llm.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) { + kinds.push(chunk.type) + } + expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) + }) + + it('forwards the harness user and session ids for host-side trajectory routing', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) + + await assemble(ctx, { + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + sessionId: SessionId('child-session'), + }) + + expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session') + expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) + }) + + it('marks the auxiliary compaction call on the wire', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) + + await assemble(ctx, { + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + purpose: 'compaction', + }) + + expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1') + }) + + it('switches dynamically from the configured low default through off to max', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'low' }) + + await assemble(ctx,{ + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + await assemble(ctx,{ + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('off'), + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi again' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + await assemble(ctx,{ + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('max'), + messages: [createUserMessage({ + content: [{ type: 'text', text: 'one more time' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + expect(server.requests[0]).toMatchObject({ + thinking: { type: 'enabled' }, + output_config: { effort: 'low' }, + }) + expect(server.requests[1]).toMatchObject({ + thinking: { type: 'disabled' }, + }) + expect(server.requests[1]).not.toHaveProperty('output_config') + expect(server.requests[2]).toMatchObject({ + thinking: { type: 'enabled' }, + output_config: { effort: 'max' }, + }) + }) + + it('uses the configured maxTokens default and preserves an explicit request cap', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const ctx = await harness(server.url, { maxTokens: 32_000 }) + + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], maxTokens: 8_192 }) + + expect(server.requests[0]).toMatchObject({ max_tokens: 32_000 }) + expect(server.requests[1]).toMatchObject({ max_tokens: 8_192 }) + }) + + it('publishes only off and omits the wire effort when thinking is disabled', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url, { thinking: 'disabled' }) + + await assemble(ctx,{ + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + expect(server.requests[0]).toMatchObject({ + thinking: { type: 'disabled' }, + }) + expect(server.requests[0]).not.toHaveProperty('output_config') + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash')) + .resolves.toMatchObject({ + reasoning: { + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], + defaultEffort: ReasoningEffortId('off'), + }, + }) + }) + + it('reports a per-request effort failure before I/O when thinking is disabled', async () => { + const server = await mockServer([]) + const ctx = await harness(server.url, { thinking: 'disabled' }) + + const result = await assemble(ctx, { + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('high'), + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { code: 'UNSUPPORTED_REASONING_EFFORT' }, + }) + expect(server.requests).toHaveLength(0) + }) + + it.each(['high', 'max'])( + 'rejects direct adapter effort %s before I/O when thinking is disabled', + async (effort) => { + const server = await mockServer([]) + const adapter = adapterOf({ apiKey: 'test-key', baseURL: server.url, thinking: 'disabled' }) + + const stream = adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId(effort), + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + await expect(async () => { + for await (const _chunk of stream) { /* drain */ } + }).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + expect(server.requests).toHaveLength(0) + }, + ) + + it.each([ + [401, 'AUTH'], + [403, 'AUTH'], + [429, 'RATE_LIMIT'], + [400, 'INVALID_REQUEST'], + [500, 'SERVER'], + [503, 'SERVER'], + ])('maps HTTP %d to failure code %s with the body message', async (status, code) => { + const behavior: Behavior = { + kind: 'http-error', + status, + body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), + } + const server = await mockServer([behavior]) + const ctx = await harness(server.url) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { message: `failed with ${status}`, code, status }, + }) + }) + + it('retains the HTTP status when an error response has no body', async () => { + const server = await mockServer([{ kind: 'http-error', status: 500, body: '' }]) + const adapter = adapterOf({ baseURL: server.url }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + messages: [], + }))).rejects.toMatchObject({ + failure: { code: 'SERVER', status: 500 }, + message: 'DeepSeek Messages request failed (500)', + }) + }) + + it('classifies an HTTP context-window failure with the canonical code', async () => { + const server = await mockServer([{ + kind: 'http-error', + status: 400, + body: JSON.stringify({ + error: { + message: 'This model maximum context length is 128000 tokens; your input exceeds that limit.', + type: 'invalid_request_error', + code: 'context_length_exceeded', + }, + }), + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE }, + }) + }) + + it('retains status, Retry-After seconds, and provider request id as structured facts', async () => { + const server = await mockServer([{ + kind: 'http-error', + status: 429, + body: JSON.stringify({ error: { message: 'slow down' } }), + headers: { 'retry-after': '2', 'x-request-id': 'req-429' }, + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { + message: 'slow down', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 2_000, + requestId: ProviderRequestId('req-429'), + }, + }) + }) + + it('parses a future Retry-After HTTP date and the DeepSeek request-id fallback', async () => { + const now = 1_800_000_000_000 + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now) + try { + const server = await mockServer([{ + kind: 'http-error', + status: 503, + body: JSON.stringify({ error: { message: 'come back later' } }), + headers: { + 'retry-after': new Date(now + 3_000).toUTCString(), + 'x-deepseek-request-id': 'deepseek-503', + }, + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { + message: 'come back later', + code: 'SERVER', + status: 503, + providerRetryAfterMs: 3_000, + requestId: ProviderRequestId('deepseek-503'), + }, + }) + } finally { + dateNow.mockRestore() + } + }) + + it('omits zero, non-finite, invalid, and past Retry-After values', async () => { + const values = [ + '0', + '9'.repeat(400), + 'not-a-date', + new Date(0).toUTCString(), + ] + for (const value of values) { + const server = await mockServer([{ + kind: 'http-error', + status: 429, + body: JSON.stringify({ error: { message: 'retry later' } }), + headers: { 'retry-after': value }, + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { message: 'retry later', code: 'RATE_LIMIT', status: 429 }, + }) + } + }) + + it('recognizes context-capacity details independently of the HTTP status', () => { + expect(providerError({ error: { message: 'request too large for model context' } }, 400).code) + .toBe(CONTEXT_WINDOW_EXCEEDED_CODE) + expect(providerError({ error: { message: 'invalid input: temperature exceeds maximum allowed value' } }, 400).code) + .toBe('INVALID_REQUEST') + expect(providerError({ error: { code: 'context_length_exceeded' } }, 413).code).toBe('CONTEXT_WINDOW_EXCEEDED') + }) + + it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => { + expect(providerError({ error: { code: 'insufficient_quota', message: 'account credits exhausted' } }, 429).code) + .toBe(QUOTA_EXCEEDED_CODE) + expect(providerError({ error: { message: 'request rate limit exceeded' } }, 429).code).toBe('RATE_LIMIT') + }) + + it('includes the status for JSON error bodies without a message', async () => { + const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }]) + const ctx = await harness(server.url) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('SERVER') + expect(result.finish.failure.message).toBe('DeepSeek Messages request failed (500)') + }) + + it('includes the status for non-JSON error bodies', async () => { + const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }]) + const ctx = await harness(server.url) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('SERVER') + expect(result.finish.failure.message).toBe('DeepSeek Messages request failed (502)') + }) + + it('maps unusual statuses to HTTP_', () => { + expect(providerError({}, 418).code).toBe('HTTP_418') + }) + + it('reports a failed endpoint connection as a transport failure', async () => { + // Port 1 is reserved/unbound, so the service normalizes the fetch failure. + const ctx = await harness('http://127.0.0.1:1') + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { + code: 'TRANSPORT', + message: 'DeepSeek Messages transport failed', + }, + }) + }) + + it('classifies an aborted request as an aborted finish', async () => { + const controller = new AbortController() + controller.abort() + const ctx = await harness('http://127.0.0.1:1') + const result = await assemble(ctx, { + model: 'deepseek-v4-flash', + messages: [], + signal: controller.signal, + }) + expect(result.finish).toMatchObject({ kind: 'aborted', failure: { code: 'ABORTED' } }) + }) + + it('throws EMPTY_RESPONSE when the response has no body', async () => { + const adapter = adapterOf({ baseURL: 'http://127.0.0.1:1' }) + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 200 }), + ) + try { + const iterate = async (): Promise => { + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + } + await expect(iterate()).rejects.toThrow(/no response body/) + } finally { + fetchSpy.mockRestore() + } + }) + + it('classifies an abrupt body close as TRANSPORT', async () => { + const server = await mockServer([{ + kind: 'close-early', + events: textEvents.slice(0, 3), + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('TRANSPORT') + expect(result.finish.failure.message).toBe('DeepSeek Messages transport failed') + }) + + it('aborts mid-stream via the request signal', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 50 }]) + const ctx = await harness(server.url) + const controller = new AbortController() + + const pending = (async () => { + const chunks = [] + for await (const chunk of ctx.llm.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + messages: [], + signal: controller.signal, + })) { + chunks.push(chunk) + } + return chunks + })() + + setTimeout(() => { controller.abort() }, 30) + const chunks = await pending + expect(chunks).toHaveLength(1) + expect(chunks[0]?.type).toBe('finish') + if (chunks[0]?.type !== 'finish') throw new Error('expected a finish chunk') + expect(chunks[0].reason.kind).toBe('aborted') + if (chunks[0].reason.kind !== 'aborted') throw new Error('expected an aborted finish') + expect(chunks[0].reason.failure.code).toBe('ABORTED') + }) + + it('maps connection failures to TRANSPORT without losing the cause', async () => { + const cause = new TypeError('connection refused') + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause) + const adapter = adapterOf({ baseURL: 'https://example.invalid' }) + try { + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + } + await expect(drain()).rejects.toMatchObject({ code: 'TRANSPORT', cause }) + } finally { + fetchSpy.mockRestore() + } + }) + + it('renders a non-Error transport rejection without losing its cause', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + const failed = Promise.withResolvers() + failed.reject('offline') + return failed.promise + }) + const adapter = adapterOf({ baseURL: 'https://example.invalid' }) + try { + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + } + await expect(drain()).rejects.toMatchObject({ + message: 'DeepSeek Messages transport failed', + code: 'TRANSPORT', + cause: 'offline', + }) + } finally { + fetchSpy.mockRestore() + } + }) + + it('aborts the underlying body when the stream stays idle past its watchdog', async () => { + vi.useFakeTimers() + let stopped = false + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { + const signal = init?.signal + const body = new ReadableStream({ + start(controller) { + signal?.addEventListener('abort', () => { + stopped = true + controller.error(signal.reason) + }, { once: true }) + }, + }) + return Promise.resolve(new Response(body, { status: 200 })) + }) + const adapter = adapterOf({ + baseURL: 'https://example.invalid', + streamIdleTimeoutMs: 100, + }) + try { + const drain = (async () => { + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + })() + const rejected = expect(drain).rejects.toMatchObject({ code: 'TIMEOUT' }) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(100) + await rejected + expect(stopped).toBe(true) + } finally { + fetchSpy.mockRestore() + } + }) + + it('keeps an idle provider read alive through SSE comments', async () => { + vi.useFakeTimers() + const encoder = new TextEncoder() + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + const body = new ReadableStream({ + start(controller) { + setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 75) + setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 150) + setTimeout(() => { + controller.enqueue(encoder.encode(textEvents.map(event => `data: ${event}\n\n`).join(''))) + controller.close() + }, 225) + }, + }) + return Promise.resolve(new Response(body, { status: 200 })) + }) + const adapter = adapterOf({ + baseURL: 'https://example.invalid', + streamIdleTimeoutMs: 100, + }) + try { + const chunks: string[] = [] + const drain = (async () => { + for await (const chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { + chunks.push(chunk.type) + } + })() + await vi.advanceTimersByTimeAsync(75) + await vi.advanceTimersByTimeAsync(75) + await vi.advanceTimersByTimeAsync(75) + await expect(drain).resolves.toBeUndefined() + expect(chunks).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) + } finally { + fetchSpy.mockRestore() + } + }) +}) + +describe('plugin registration and config', () => { + it('resolves the Messages endpoint without rewriting overrides', () => { + expect(resolveAdapterOptions({}).baseURL).toBe('https://api.deepseek.com/anthropic') + for (const baseURL of ['https://example.com', 'https://example.com/custom']) { + expect(resolveAdapterOptions({ baseURL }).baseURL).toBe(baseURL) + } + const retired = { protocol: 'messages', baseURL: 'https://gateway.example' } + expect(() => resolveAdapterOptions(retired)).toThrow(/protocol/) + }) + + it('keeps wire helpers off the package root', () => { + for (const helper of [ + 'httpErrorCode', + 'serializeMessages', + 'serializeRequest', + 'DONE', + 'parseSse', + 'mapFinishReason', + 'mapUsage', + 'translate', + ]) expect(LlmDeepSeek).not.toHaveProperty(helper) + }) + + it('registers the deepseek provider and unregisters on dispose (HMR safety)', async () => { + const server = await mockServer([]) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + const fiber = await ctx.plugin(LlmDeepSeek, { + baseURL: server.url, + }) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listConfigurableProviders()).toEqual([{ + provider: 'deepseek-official', + displayName: 'DeepSeek', + settingsNs: 'llm-deepseek', + settingsPath: [], + }]) + await fiber.dispose() + expect(ctx.llm.listProviders()).toEqual([]) + expect(ctx.llm.listConfigurableProviders()).toEqual([]) + }) + + it('registers retryPolicy from the provider config', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + retryPolicy: { + mode: 'always', + backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, + }, + }) + + expect(ctx.llm.providerRetryPolicy('deepseek-official')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + }) + + it('owns the deepseek provider and advertises the default models', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ + { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, + { + provider: 'deepseek-official', + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', + inputModalities: ['text'], + }, + ]) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')) + .resolves.toMatchObject({ + provider: 'deepseek-official', + id: 'deepseek-flash', + name: 'DeepSeek-V41-Flash', + inputModalities: ['text', 'image'], + systemPromptUpdate: 'in-history', + context: { contextWindow: 1_000_000 }, + defaultMaxTokens: 256_000, + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, + { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, + { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, + { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }) + }) + + it('keeps deepseek-v4-pro available with its V4 capabilities', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + const info = await ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-pro') + expect(info).toMatchObject({ + id: 'deepseek-v4-pro', + inputModalities: ['text'], + context: { contextWindow: 1_000_000 }, + defaultMaxTokens: 256_000, + }) + expect(info?.systemPromptUpdate).toBeUndefined() + }) + + it.each(['off', 'low', 'max'] as const)('uses the configured %s reasoning default', async (effort) => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + reasoningEffort: effort, + }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) + .resolves.toMatchObject({ + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, + { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, + { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, + { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, + ], + defaultEffort: ReasoningEffortId(effort), + }, + }) + }) + + it('accepts off as the default when thinking is deployment-disabled', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + thinking: 'disabled', + reasoningEffort: 'off', + }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) + .resolves.toMatchObject({ + reasoning: { + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], + defaultEffort: ReasoningEffortId('off'), + }, + }) + }) + + it.each(['low', 'high', 'max'] as const)( + 'rejects configured reasoning effort %s when thinking is disabled', + async (reasoningEffort) => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + thinking: 'disabled', + reasoningEffort, + })).rejects.toThrow(/only reasoningEffort "off"/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it.each(['low', 'high', 'max'] as const)( + 'rejects disabled-thinking effort %s at the resolver boundary', + (reasoningEffort) => { + expect(() => resolveAdapterOptions({ thinking: 'disabled', reasoningEffort })) + .toThrow(/only reasoningEffort "off"/) + }, + ) + + it('accepts disabled thinking with off at the resolver boundary', async () => { + const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' }) + await expect(adapter.resolveModel('deepseek-official', 'pass-through')).resolves.toMatchObject({ + reasoning: { + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], + defaultEffort: ReasoningEffortId('off'), + }, + }) + }) + + it('uses the default model catalog when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ + { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, + { + provider: 'deepseek-official', + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', + inputModalities: ['text'], + }, + ]) + }) + + it('defaults an adapter-supplied catalog entry to text input', async () => { + const connection = resolveAdapterOptions({ models: [] }) + const adapter = new DeepSeekAdapter({ + options: () => ({ ...connection, models: [{ id: 'adapter-model' }] }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: noExtensions, + }) + await expect(adapter.listModels('deepseek-official')).resolves.toEqual([{ + provider: 'deepseek-official', + id: 'adapter-model', + name: 'adapter-model', + inputModalities: ['text'], + }]) + }) + + it('advertises configured models without restricting arbitrary request ids', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + models: [ + { id: 'private-fast', contextWindow: 32_000 }, + { + id: 'private-reasoner', + name: 'Private Reasoner', + description: 'Higher reasoning budget', + contextWindow: 64_000, + inputModalities: ['text', 'image'], + }, + ], + }) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ + { provider: 'deepseek-official', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] }, + { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text', 'image'] }, + ]) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-fast')) + .resolves.toMatchObject({ context: { contextWindow: 32_000 } }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-reasoner')) + .resolves.toMatchObject({ + name: 'Private Reasoner', + description: 'Higher reasoning budget', + inputModalities: ['text', 'image'], + }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'arbitrary-unlisted')) + .resolves.toMatchObject({ + context: { contextWindow: 1_000_000 }, + defaultMaxTokens: 256_000, + }) + }) + + it('uses exact model capacity before the adapter-wide default', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + defaultContextWindow: 256_000, + models: [ + { id: 'inherits-default' }, + { id: 'exact-override', contextWindow: 64_000 }, + ], + }) + + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'inherits-default')) + .resolves.toMatchObject({ context: { contextWindow: 256_000 } }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'exact-override')) + .resolves.toMatchObject({ context: { contextWindow: 64_000 } }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) + .resolves.toMatchObject({ context: { contextWindow: 256_000 } }) + }) + + it('allows an explicit empty model catalog', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + models: [], + }) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([]) + }) + + const invalidModels: Array<[object[], RegExp]> = [ + [[{ id: '' }], /ids must be non-empty/], + [[{ id: 'm', name: '' }], /empty name/], + [[{ id: 'm', contextWindow: 0 }], /contextWindow/], + [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/], + [[{ id: 'm', inputModalities: [] }], /inputModalities/], + [[{ id: 'm', inputModalities: ['text', 'text'] }], /inputModalities must not contain duplicates/], + [[{ + id: 'm', + inputModalities: ['audio'], + }], /expected "text" \| "image"/], + [[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/], + ] + + it.each(invalidModels)('rejects invalid advisory model config', async (models, message) => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(Reflect.apply(ctx.plugin.bind(ctx), undefined, [LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', models: [...models], + }])).rejects.toThrow(message) + expect(ctx.llm.listProviders()).toEqual([]) + }) + + const invalidProgrammaticModalities: Array<[object[], RegExp]> = [ + [[{ id: 'm', inputModalities: [] }], /inputModalities must not be empty/], + [[{ + id: 'm', + inputModalities: ['audio'], + }], /inputModalities must contain only "text" and "image"/], + ] + + it.each(invalidProgrammaticModalities)('rejects programmatic modality config that bypasses the schema', (models, message) => { + expect(() => { Reflect.apply(resolveAdapterOptions, undefined, [{ models: [...models] }]) }).toThrow(message) + }) + + it('rejects the removed imageDetail model setting through schema and direct construction', async () => { + const legacyModel = { id: 'vision', inputModalities: ['image' as const], imageDetail: 'low' } + expect(() => resolveAdapterOptions({ models: [legacyModel] })).toThrow(/imageDetail is no longer supported/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + models: [legacyModel], + })).rejects.toThrow(/imageDetail is no longer supported/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + + it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => { + expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] })) + .toThrow(/maxTokens must be a positive integer/) + }) + + it('surfaces a catalog model\'s in-history system prompt update mode and rejects any other mode', async () => { + const adapter = adapterOf({ models: [ + { id: 'capable', systemPromptUpdate: 'in-history' }, + { id: 'plain' }, + ] }) + await expect(adapter.resolveModel('deepseek-official', 'capable')) + .resolves.toMatchObject({ systemPromptUpdate: 'in-history' }) + await expect(adapter.resolveModel('deepseek-official', 'plain')) + .resolves.not.toHaveProperty('systemPromptUpdate') + await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) + .resolves.not.toHaveProperty('systemPromptUpdate') + expect(() => { Reflect.apply(resolveAdapterOptions, undefined, [{ + models: [{ id: 'bogus', systemPromptUpdate: 'leading' }], + }]) }).toThrow(/systemPromptUpdate must be "in-history" when present/) + }) + + it('rejects image request limits on a text-only catalog model', () => { + expect(() => resolveAdapterOptions({ + models: [{ id: 'text-only', inputModalities: ['text'], imagePixelBudget: 1 }], + })).toThrow(/text-only catalog model .* cannot declare image request limits/) + }) + + it.each([ + ['imagePixelBudget', 0, /imagePixelBudget must be "low" or a positive safe integer/], + ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be "low" or a positive safe integer/], + ['imagePixelBudget', 'auto', /imagePixelBudget must be "low" or a positive safe integer/], + ['imageMaxBytes', 0, /imageMaxBytes must be a positive safe integer/], + ['imageMaxBytes', 1.5, /imageMaxBytes must be a positive safe integer/], + ] as const)('rejects per-model %s=%s', (field, value, message) => { + expect(() => resolveAdapterOptions({ + models: [{ id: 'vision', inputModalities: ['image'], [field]: value }], + })).toThrow(message) + }) + + it('prefers a model\'s own output cap over the profile default', async () => { + // The profile default stays what an unlisted or uncapped model resolves + // to, so adding a per-model cap changes one model rather than the route. + const adapter = adapterOf({ maxTokens: 4096, models: [ + { id: 'capped', maxTokens: 512 }, + { id: 'uncapped' }, + ] }) + await expect(adapter.resolveModel('deepseek-official', 'capped')) + .resolves.toMatchObject({ defaultMaxTokens: 512 }) + await expect(adapter.resolveModel('deepseek-official', 'uncapped')) + .resolves.toMatchObject({ defaultMaxTokens: 4096 }) + await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) + .resolves.toMatchObject({ defaultMaxTokens: 4096 }) + }) + + it('rejects invalid context capacity when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + expect(() => { + LlmDeepSeek.apply(ctx, { + baseURL: 'http://127.0.0.1:1', + models: [{ id: 'invalid-context', contextWindow: 0 }], + }) + }).toThrow(/contextWindow must be a positive integer/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + + it.each([0, 1.5])( + 'rejects invalid adapter-wide default context capacity %s', + async (defaultContextWindow) => { + expect(() => resolveAdapterOptions({ defaultContextWindow })) + .toThrow(/defaultContextWindow must be a positive integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + defaultContextWindow, + })).rejects.toThrow(/defaultContextWindow/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid adapter-wide maxTokens %s', + async (maxTokens) => { + expect(() => resolveAdapterOptions({ maxTokens })) + .toThrow(/maxTokens must be a positive safe integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + maxTokens, + })).rejects.toThrow(/maxTokens/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it('rejects offload quanta larger than their request bounds', () => { + expect(() => resolveAdapterOptions({ + maxRequestFilesBytes: 10, + imageOffloadByteQuantum: 11, + })).toThrow(/imageOffloadByteQuantum must not exceed maxRequestFilesBytes/) + expect(() => resolveAdapterOptions({ + maxInlineRequestImageBytes: 10, + inlineImageOffloadByteQuantum: 11, + })).toThrow(/inlineImageOffloadByteQuantum must not exceed maxInlineRequestImageBytes/) + expect(() => resolveAdapterOptions({ + maxImagesPerRequest: 10, + imageOffloadCountQuantum: 11, + })).toThrow(/imageOffloadCountQuantum must not exceed maxImagesPerRequest/) + }) + + it.each([ + ['maxImagesPerRequest', 0, /maxImagesPerRequest must be a positive safe integer/], + ['maxImagesPerRequest', 1.5, /maxImagesPerRequest must be a positive safe integer/], + ['imageOffloadByteQuantum', 0, /imageOffloadByteQuantum must be a positive safe integer/], + ['imageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /imageOffloadByteQuantum must be a positive safe integer/], + ['inlineImageOffloadByteQuantum', 0, /inlineImageOffloadByteQuantum must be a positive safe integer/], + ['inlineImageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /inlineImageOffloadByteQuantum must be a positive safe integer/], + ['imageOffloadCountQuantum', 0, /imageOffloadCountQuantum must be a positive safe integer/], + ['imageOffloadCountQuantum', 1.5, /imageOffloadCountQuantum must be a positive safe integer/], + ['fileExpiresAfterSeconds', 3_599, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], + ['fileExpiresAfterSeconds', 2_592_001, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], + ['fileRefreshMarginSeconds', -1, /fileRefreshMarginSeconds must be a non-negative integer/], + ['fileRefreshMarginSeconds', 604_800, /fileRefreshMarginSeconds must be a non-negative integer/], + ['fileQuotaCleanupBatch', 0, /fileQuotaCleanupBatch must be an integer from 1 through 1000/], + ['fileQuotaCleanupBatch', 1_001, /fileQuotaCleanupBatch must be an integer from 1 through 1000/], + ] as const)('rejects %s=%s', (field, value, message) => { + expect(() => resolveAdapterOptions({ [field]: value })).toThrow(message) + }) + + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid request file bound %s', + async (maxRequestFilesBytes) => { + expect(() => resolveAdapterOptions({ maxRequestFilesBytes })) + .toThrow(/maxRequestFilesBytes must be a positive safe integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + maxRequestFilesBytes, + })).rejects.toThrow(/maxRequestFilesBytes/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid inline request image bound %s', + async (maxInlineRequestImageBytes) => { + expect(() => resolveAdapterOptions({ maxInlineRequestImageBytes })) + .toThrow(/maxInlineRequestImageBytes must be a positive safe integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + maxInlineRequestImageBytes, + })).rejects.toThrow(/maxInlineRequestImageBytes/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') + vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, {}) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + }) + + it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + // First-boot onboarding: the route registers so models stay discoverable; + // only the request itself needs a key. + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) + const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) + expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) + // The guidance leads with the managed credential store. + const second = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) + expect(second.finish.kind).toBe('error') + if (second.finish.kind !== 'error') throw new Error('expected an error finish') + // The guidance names both places a credential can come from, and nothing + // else: configuration carries the reference, never a literal key. + expect(second.finish.failure.message) + .toMatch(/store DEEPSEEK_API_KEY through the credentials service.*export DEEPSEEK_API_KEY/s) + }) + + it('reads the ambient variable when no credentials seam is mounted', async () => { + // The plain cordis.yml composition: no credential provider, the key in + // the launching environment. + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: server.url }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.['x-api-key']).toBe('ambient-key') + }) + + it('treats an empty ambient variable as no key when no credentials seam is mounted', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) + }) + + it('prefers explicit config over env for key and base URL', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') + vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) // harness passes explicit config + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(server.requests).toHaveLength(1) // hit the explicit URL, not env + }) + + it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + vi.stubEnv('DEEPSEEK_BASE_URL', server.url) + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, {}) + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(server.requests).toHaveLength(1) + }) + + + it('takes DEEPSEEK_BASE_URL from any environment layer, with explicit config still on top', () => { + const trusted = createLaunchEnvironmentSnapshot([ + { source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } }, + ]) + expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example') + // The product trusts the project it is launched in, so a checkout can + // point its own agent at the gateway that checkout is meant to use. + const project = createLaunchEnvironmentSnapshot([ + { source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://project.example' } }, + ]) + expect(resolveAdapterOptions({}, project).baseURL).toBe('https://project.example') + // An explicitly configured endpoint outranks every environment layer, so a + // stale shell value cannot rewrite a deployment's own gateway. + const shell = createLaunchEnvironmentSnapshot([ + { source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } }, + ]) + expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal') + }) + it('uses the public Messages base URL without an endpoint override', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'k') + vi.stubEnv('DEEPSEEK_BASE_URL', undefined) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + // Registration succeeds; no call is made (would hit api.deepseek.com). + await ctx.plugin(LlmDeepSeek, {}) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + }) + + it('adapter is constructible directly for embedding over the shared resolver', async () => { + const adapter = adapterOf() + expect(adapter).toBeInstanceOf(DeepSeekAdapter) + // Direct embedding shares the plugin's one resolve step, so it advertises + // the same default catalog instead of a divergent empty one. + await expect(adapter.listModels('deepseek-official')).resolves.toHaveLength(2) + }) + + it('resolves connection facts and the credential exactly once per stream call', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url })) + const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key')) + const resolveUserId = vi.fn(() => TEST_USER_ID) + const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId, prepareExtensions: noExtensions }) + + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + + expect(options).toHaveBeenCalledTimes(1) + expect(resolveApiKey).toHaveBeenCalledTimes(1) + expect(resolveUserId).toHaveBeenCalledTimes(1) + expect(server.headers[0]?.['x-api-key']).toBe('per-request-key') + }) + + it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => { + expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: Number.POSITIVE_INFINITY })) + .toThrow(/streamIdleTimeoutMs.*positive finite/) + expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) + .toThrow(/streamIdleTimeoutMs.*no greater/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + streamIdleTimeoutMs: 0, + })).rejects.toThrow(/streamIdleTimeoutMs/) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, + })).rejects.toThrow(/streamIdleTimeoutMs/) + }) + + it('validates Files API timeout bounds independently of the stream idle deadline', async () => { + expect(() => resolveAdapterOptions({ filesApiTimeoutMs: Number.POSITIVE_INFINITY })) + .toThrow(/filesApiTimeoutMs.*positive finite/) + expect(() => resolveAdapterOptions({ filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) + .toThrow(/filesApiTimeoutMs.*no greater/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + filesApiTimeoutMs: 0, + })).rejects.toThrow(/filesApiTimeoutMs/) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1, + })).rejects.toThrow(/filesApiTimeoutMs/) + expect(resolveAdapterOptions({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 })) + .toMatchObject({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 }) + }) + + it('rejects invalid nested retryPolicy before registering the provider', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + retryPolicy: { mode: 'normal', maxRetries: -1 }, + })).rejects.toThrow(/retryPolicy/) + expect(ctx.llm.listProviders()).toEqual([]) + }) +}) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 804536fe3b..de32f4680f 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,832 +1,458 @@ +/** Request conversion and durable replay validation. */ import { describe, expect, it, vi } from 'vitest' +import { createDeveloperMessage, createUserMessage, createAssistantMessage, createMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, ImageBlock, Message, RequestMessage, RequestUserInput } from '@deepseek-ai/dsh-llm' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import { createDeveloperMessage, createToolResultMessage, createUserMessage, ToolCallId, ReasoningEffortId, createMessage } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, Message, RequestUserInput } from '@deepseek-ai/dsh-llm' -import type { ContextFormed } from '@deepseek-ai/dsh-llm' -import { - serializeMessages, - serializeMessagesWithImages, - serializeRequest, - serializeRequestWithImages, -} from '../src/protocols/chat-completions/serialize.ts' -import type { ImageSerializationOptions } from '../src/protocols/chat-completions/serialize.ts' +import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import { resolveAdapterOptions } from '../src/config.ts' +import { modelInfo } from '../src/model-info.ts' +import type { Config } from '../src/config.ts' +import { imagePricing, inlineImages, prepareImages } from '../src/images.ts' +import { readReplay, replayState } from '../src/replay.ts' +import { serialize } from '../src/serialize.ts' +import { MODEL, options, user, requestImageStore } from './helpers.ts' -declare module '@deepseek-ai/dsh-llm' { - interface MessageSourceMap { - 'test': { kind: 'test' } & ContextFormed - } -} +const connection = resolveAdapterOptions({}) +const call = (id = 'a'): ContentBlock => ({ type: 'tool-call', id: ToolCallId(id), name: 'read', arguments: '{"path":"a"}' }) +const assistant = (content: ContentBlock[]) => createAssistantMessage({ content, source: { provider: 'deepseek-official', model: MODEL } }) +const result = (id = 'a', content: ContentBlock[] = [{ type: 'text', text: 'result' }]) => createToolResultMessage({ callId: ToolCallId(id), content, isError: false }) +const body = (messages: RequestMessage[] = [user()], overrides: Partial = {}) => serialize( + options({ messages, ...overrides }), connection, messages, new Map(), () => undefined, +) +const capable = resolveAdapterOptions({ models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) +const nativeBody = (messages: Message[]) => serialize(options({ messages }), capable, messages, new Map(), () => undefined) -type FileResolver = Extract['resolveFileId'] - -function request(overrides: Partial = {}): GenerateOptions { - return { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [], ...overrides } -} - -function imageRef(mediaType: ImageMediaType = 'image/png', bytes = 3): ImageAttachmentRef { - const digit = ({ - 'image/png': 'a', - 'image/jpeg': 'b', - 'image/webp': 'c', - 'image/gif': 'd', - } as const)[mediaType] - return { - attachmentId: AttachmentId(`sha256:${digit.repeat(64)}`), - mediaType, - bytes, - width: 1, - height: 1, - } -} - -function fileResolver(id = 'file-api-image') { - return vi.fn(() => Promise.resolve(id)) -} - -function requestVersion(ref: ImageAttachmentRef): RequestImageAttachment { - const hash = String(ref.attachmentId).slice('sha256:'.length) - return { - variantId: ImageVariantId(`sha256:${hash}`), - attachment: ref, - data: new Uint8Array(ref.bytes), - mediaType: ref.mediaType, - bytes: ref.bytes, - width: ref.width, - height: ref.height, - depth: 'uchar', - space: 'srgb', - hasAlpha: ref.mediaType === 'image/png', - } -} - -function imageOptions( - refs: readonly ImageAttachmentRef[], - resolveFileId: FileResolver = fileResolver(), - maxRequestImageBytes = 20 * 1024 * 1024, -): ImageSerializationOptions { - return { - representation: { kind: 'file' as const, resolveFileId }, - requestImages: new Map(refs.map(ref => [ref.attachmentId, requestVersion(ref)])), - maxRequestImageBytes, - } -} - -function inlineImageOptions( - refs: readonly ImageAttachmentRef[], - maxRequestImageBytes = 20 * 1024 * 1024, - byteQuantum = 10 * 1024 * 1024, -): ImageSerializationOptions { - return { - representation: { kind: 'base64' }, - requestImages: new Map(refs.map(ref => [ref.attachmentId, requestVersion(ref)])), - maxRequestImageBytes, - byteQuantum, - } -} - -it.each(['user', 'system', 'assistant', 'tool'] as const)('rejects tool-change blocks in %s history', (role) => { - for (const type of ['tool-addition', 'tool-removal'] as const) { - const message = { id: 'invalid', role, source: { kind: 'test' }, content: [{ type, toolName: 'search' }] } as unknown as Message - expect(() => serializeMessages([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - } -}) - -it('rejects deferred tool definitions until provider loading is implemented', () => { - expect(() => serializeRequest(request({ tools: [{ name: 'search', description: '', parameters: {}, deferLoading: true }] }))) - .toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) -}) - -describe('request-only user input', () => { - it('preserves the exact text request and durable tool-result prefix', () => { - const prefix = [ - createMessage({ role: 'assistant', content: [{ type: 'tool-call', id: ToolCallId('lookup'), name: 'lookup', arguments: '{}' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' } }), - createToolResultMessage({ callId: ToolCallId('lookup'), content: [{ type: 'text', text: 'result' }], isError: false }), - ] - const input: RequestUserInput = { role: 'user', content: [{ type: 'text', text: 'review or summarize this input' }] } - const durable = createUserMessage({ content: input.content, source: { kind: 'test' } }) - expect(serializeRequest(request({ messages: [...prefix, input], system: 'policy' }))) - .toEqual(serializeRequest(request({ messages: [...prefix, durable], system: 'policy' }))) +describe('Messages request conversion', () => { + it('rejects unknown plugin content without interpreting its payload', () => { + expect(() => body([createUserMessage({ source: { kind: 'user' }, content: [ + { type: 'plugin:text', text: 'opaque' } as never, + ] })])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + }) + it.each(['user', 'system', 'assistant', 'tool'] as const)('rejects tool-change blocks in %s history', (role) => { + for (const type of ['tool-addition', 'tool-removal'] as const) { + const content: ContentBlock[] = [{ type, toolName: 'search' }] + const message = role === 'user' ? createUserMessage({ source: { kind: 'user' }, content }) + : role === 'system' ? createMessage({ role, source: { kind: 'system-prompt' }, content }) + : role === 'assistant' ? assistant(content) : result('invalid', content) + expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + } }) - it('preserves image ordering and bytes without manufacturing durable metadata', async () => { - const ref = imageRef() + it('rejects deferred tool definitions until provider loading is implemented', () => { + expect(() => body([], { tools: [{ name: 'search', description: '', parameters: {}, deferLoading: true }] })) + .toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + }) + + it('preserves the exact request with request-only text after durable tool results', () => { + const prefix = [user(), assistant([call()]), result()] + const input: RequestUserInput = { role: 'user', content: [{ type: 'text', text: 'review or summarize this input' }] } + const durable = createUserMessage({ content: input.content, source: { kind: 'user' } }) + expect(body([...prefix, input], { system: 'policy' })).toEqual(body([...prefix, durable], { system: 'policy' })) + }) + + it('preserves request-only image content through image preparation and wire conversion', async () => { + const attachment: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), mediaType: 'image/png', bytes: 3, width: 1, height: 1, + } + const version: RequestImageAttachment = { + variantId: ImageVariantId(`sha256:${'f'.repeat(64)}`), attachment, data: Uint8Array.of(1, 2, 3), + mediaType: 'image/png', bytes: 3, width: 1, height: 1, depth: 'uchar', space: 'srgb', hasAlpha: true, + } const input: RequestUserInput = { role: 'user', content: [ - { type: 'text', text: 'before' }, { type: 'image', attachment: ref }, { type: 'text', text: 'after' }, + { type: 'text', text: 'before' }, { type: 'image', attachment }, { type: 'text', text: 'after' }, ] } - const durable = createUserMessage({ content: input.content, source: { kind: 'test' } }) - const actual = await serializeRequestWithImages(request({ messages: [input] }), inlineImageOptions([ref])) - const expected = await serializeRequestWithImages(request({ messages: [durable] }), inlineImageOptions([ref])) - expect(actual).toEqual(expected) + const store = requestImageStore(async () => version) + const vision = resolveAdapterOptions({ models: [{ id: MODEL, inputModalities: ['text', 'image'] }] }) + const durable = createUserMessage({ content: input.content, source: { kind: 'user' } }) + const actual = await prepareImages([input], vision, MODEL, store, () => undefined, new AbortController().signal) + const expected = await prepareImages([durable], vision, MODEL, store, () => undefined, new AbortController().signal) + expect(serialize(options({ messages: [input] }), vision, actual.messages, actual.versions, () => undefined)) + .toEqual(serialize(options({ messages: [durable] }), vision, expected.messages, expected.versions, () => undefined)) + expect(actual.messages[0]).toBe(input) expect(input).not.toHaveProperty('id') expect(input).not.toHaveProperty('source') }) -}) -describe('serializeMessages', () => { - it('maps user text to string content', () => { - const wire = serializeMessages([ - createUserMessage({ - content: [{ type: 'text', text: 'hello ' }, { type: 'text', text: 'world' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ]) - expect(wire).toEqual([{ role: 'user', content: 'hello world' }]) + it('rejects developer history while provider serialization is unsupported', () => { + const message = createDeveloperMessage({ content: [{ type: 'tool-addition', toolName: 'search' }], source: { kind: 'tool-registry' } }) + expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) }) - it('maps system-role messages in history', () => { - const wire = serializeMessages([ - createMessage({ - role: 'system', content: [{ type: 'text', text: 'be brief' }], - source: { kind: 'system-prompt' }, - }), + it('keeps the original top-level prompt and cached prefix while appending native system updates', () => { + const head = createSystemMessage('original') + const first = [head, user('first')] + const update = createSystemMessage('updated') + const second = [...first, assistant([{ type: 'text', text: 'one' }]), update, user('second')] + const saved = JSON.stringify(second) + const before = nativeBody(first) + const after = nativeBody(second) + expect(after.system).toBe(before.system) + expect(after.messages.slice(0, before.messages.length)).toEqual(before.messages) + expect(after.messages.slice(-2)).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'second' }] }, + { role: 'system', content: [{ type: 'text', text: 'updated' }] }, ]) - expect(wire).toEqual([{ role: 'system', content: 'be brief' }]) + const third = nativeBody([...second, assistant([{ type: 'text', text: 'two' }]), user('third')]) + expect(third.messages.slice(0, after.messages.length)).toEqual(after.messages) + expect(JSON.stringify(second)).toBe(saved) }) - it('passes reasoning_content back on tool-call-free turns', () => { - const wire = serializeMessages([ - createMessage({ - role: 'assistant', - content: [ - { type: 'reasoning', text: 'thinking…' }, - { type: 'text', text: 'answer' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ]) - // A gateway that re-encodes the conversation for another vendor recovers - // the upstream thinking signature by hashing this exact text, and a turn - // that called no tool carries it nowhere else. - expect(wire).toEqual([{ role: 'assistant', content: 'answer', reasoning_content: 'thinking…' }]) - }) - - it('passes reasoning_content back on tool-call turns (official passback rule)', () => { - const wire = serializeMessages([ - createMessage({ - role: 'assistant', - content: [ - { type: 'reasoning', text: 'I should check the weather.' }, - { type: 'tool-call', id: ToolCallId('call-1'), name: 'get_weather', arguments: '{"city":"Paris"}' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ]) - expect(wire).toEqual([{ - role: 'assistant', - // "" (not null) on tool-call turns — mirrors the official samples' - // verbatim message replay; some gateways reject null. - content: '', - reasoning_content: 'I should check the weather.', - tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Paris"}' } }], - }]) - }) - - it('serializes parallel tool calls in order', () => { - const wire = serializeMessages([ - createMessage({ - role: 'assistant', - content: [ - { type: 'tool-call', id: ToolCallId('a'), name: 'one', arguments: '{}' }, - { type: 'tool-call', id: ToolCallId('b'), name: 'two', arguments: '{}' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ]) - const assistant = wire[0] as { tool_calls: { id: string }[] } - expect(assistant.tool_calls.map(call => call.id)).toEqual(['a', 'b']) - }) - - it('turns tool messages into role:tool wire messages', () => { - const wire = serializeMessages([ - createToolResultMessage({ - callId: ToolCallId('call-1'), - content: [{ type: 'text', text: 'Sunny 22C' }], - isError: false, - }), - ]) - expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: 'Sunny 22C' }]) - }) - - it('sends a sentinel for empty tool content', () => { - const wire = serializeMessages([ - createToolResultMessage({ callId: ToolCallId('call-1'), content: [], isError: false }), - ]) - expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: '(no output)' }]) - }) - - it('keeps user text and tool messages as separate wire messages', () => { - const wire = serializeMessages([ - createUserMessage({ - content: [{ type: 'text', text: 'context note' }], - source: { kind: 'test' }, - }), - createToolResultMessage({ - callId: ToolCallId('call-1'), - content: [{ type: 'text', text: 'ok' }], - isError: false, - }), - ]) - expect(wire).toEqual([ - { role: 'user', content: 'context note' }, - { role: 'tool', tool_call_id: 'call-1', content: 'ok' }, + it('places system updates after all parallel tool results and before the next assistant', () => { + const history = [createSystemMessage('original'), user(), assistant([call(), call('b')]), + createSystemMessage('first update'), result(), createSystemMessage('second update'), result('b'), + user('more input'), assistant([{ type: 'text', text: 'done' }])] + const request = nativeBody(history) + expect(request.messages.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'system', 'system', 'assistant']) + expect(request.messages[2]?.content.map(block => block.type)).toEqual(['tool_result', 'tool_result', 'text']) + expect(request.messages.slice(3, 5).map(message => message.content)).toEqual([ + [{ type: 'text', text: 'first update' }], [{ type: 'text', text: 'second update' }], ]) }) - it('skips plugin-added block types (merge-extensible ContentBlockMap)', () => { - const wire = serializeMessages([ - createUserMessage({ - content: [ - { type: 'chart', data: 'x' } as unknown as ContentBlock, - { type: 'text', text: 'see chart' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), + it('accepts native trailing updates without a top-level prompt and rejects unrepresentable positions', () => { + const update = createSystemMessage('update') + expect(nativeBody([user(), update])).toMatchObject({ messages: [ + { role: 'user' }, { role: 'system', content: [{ type: 'text', text: 'update' }] }, + ] }) + expect(nativeBody([user(), update]).system).toBeUndefined() + expect(() => nativeBody([user(), assistant([{ type: 'text', text: 'done' }]), update])).toThrow(/preceding user/) + expect(() => nativeBody([user(), createSystemMessage('')])).toThrow(/empty in-history/) + expect(() => nativeBody([user(), assistant([call(), call('b')]), update, result()])).toThrow(/immediate results/) + }) + + it.each([[], [{ type: 'reasoning', text: 'child reasoning' }], [call('child-call')]] satisfies ContentBlock[][])( + 'rejects native system updates when their user input is omitted %#', (...content) => { + const empty = createMessage({ role: 'user', source: { kind: 'user' }, content }) + const history = [user(), assistant([{ type: 'text', text: 'answer' }]), createSystemMessage('updated'), empty] + const saved = JSON.stringify(history) + for (const messages of [history, [...history, assistant([{ type: 'text', text: 'next answer' }])]]) { + expect(() => nativeBody(messages)).toThrow(expect.objectContaining({ + code: 'UNSUPPORTED_CONTENT', + message: 'DeepSeek Messages cannot represent system update without a preceding user or tool-result turn', + })) + } + expect(JSON.stringify(history)).toBe(saved) + }, + ) + + it('keeps native system updates after retained text or tool results beside omitted user input', () => { + const reasoning: ContentBlock = { type: 'reasoning', text: 'child reasoning' } + const empty = createMessage({ role: 'user', source: { kind: 'user' }, content: [reasoning] }) + const update = createSystemMessage('updated') + for (const [previous, retained, expected] of [ + [assistant([{ type: 'text', text: 'answer' }]), user('continue'), [{ type: 'text', text: 'continue' }]], + [assistant([call()]), result('a', [reasoning]), [{ type: 'tool_result', tool_use_id: 'a', content: [], is_error: false }]], + ] as const) { + const history = [user(), previous, update, empty, retained, assistant([{ type: 'text', text: 'done' }])] + const saved = JSON.stringify(history) + const request = nativeBody(history) + expect(request.messages.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'system', 'assistant']) + expect(request.messages[2]?.content).toEqual(expected) + expect(request.messages[3]?.content).toEqual([{ type: 'text', text: 'updated' }]) + expect(JSON.stringify(history)).toBe(saved) + } + }) + + it('groups parallel results before ordinary text and keeps tool failure content', () => { + const messages = [user(), assistant([call(), call('b')]), user('follow-up'), result(), createToolResultMessage({ callId: ToolCallId('b'), content: [{ type: 'text', text: 'permission denied' }], isError: true })] + expect(body(messages).messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: ['a', 'b'].map(id => ({ type: 'tool_use', id, name: 'read', input: { path: 'a' } })) }, + { role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: 'result' }], is_error: false }, + { type: 'tool_result', tool_use_id: 'b', content: [{ type: 'text', text: 'permission denied' }], is_error: true }, + { type: 'text', text: 'follow-up' }, + ] }, ]) - expect(wire).toEqual([{ role: 'user', content: 'see chart' }]) + expect(messages[2]?.content).toEqual([{ type: 'text', text: 'follow-up' }]) }) - it('rejects image blocks instead of silently flattening them away', () => { - expect(() => serializeMessages([createUserMessage({ - content: [{ - type: 'image', - attachment: { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), - mediaType: 'image/png', bytes: 68, width: 1, height: 1, - }, - }], - source: { kind: 'test' }, - })])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + it('preserves empty results without inventing model-visible output', () => { + const response = body([user(), assistant([call()]), result('a', [])]) + expect(response.messages[2]?.content[0]).toMatchObject({ content: [] }) + const { isError: _isError, ...minimal } = createToolResultMessage({ callId: ToolCallId('a'), content: [{ type: 'text', text: '' }], isError: false }) + expect(body([assistant([call()]), minimal]).messages[1]?.content[0]).toEqual({ type: 'tool_result', tool_use_id: 'a', content: [] }) }) - it('emits an empty user message rather than dropping block-less messages', () => { - const wire = serializeMessages([createUserMessage({ - content: [], - source: { kind: 'test' }, - })]) - expect(wire).toEqual([{ role: 'user', content: '' }]) + it('omits assistant-only blocks from user input while preserving text and durable content', () => { + const history = [createMessage({ role: 'user', source: { kind: 'user' }, content: [ + { type: 'text', text: 'Background subagent finished.\n' }, + { type: 'reasoning', text: 'child reasoning' }, + call('child-call'), + { type: 'text', text: ' child answer ' }, + ] })] + const saved = JSON.stringify(history) + + expect(body(history).messages).toEqual([{ role: 'user', content: [ + { type: 'text', text: 'Background subagent finished.\n' }, + { type: 'text', text: ' child answer ' }, + ] }]) + expect(JSON.stringify(history)).toBe(saved) + }) + + it('omits assistant-only blocks inside tool results while retaining calls, errors and empty results', () => { + const reasoning: ContentBlock = { type: 'reasoning', text: 'tool reasoning' } + const history = [assistant([reasoning, call(), call('b')]), + createToolResultMessage({ callId: ToolCallId('a'), content: [reasoning, call('nested-call'), { type: 'text', text: ' result\n' }], isError: true }), + result('b', [reasoning, call('another-nested-call')]), + ] + const saved = JSON.stringify(history) + + expect(body(history).messages).toEqual([ + { role: 'assistant', content: [ + { type: 'thinking', thinking: 'tool reasoning' }, + ...['a', 'b'].map(id => ({ type: 'tool_use', id, name: 'read', input: { path: 'a' } })), + ] }, + { role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: ' result\n' }], is_error: true }, + { type: 'tool_result', tool_use_id: 'b', content: [], is_error: false }, + ] }, + ]) + expect(JSON.stringify(history)).toBe(saved) + }) + + it.each([ + [], [{ type: 'reasoning', text: 'child reasoning' }], [call('child-call')], + ] satisfies ContentBlock[][])('omits empty user input after conversion %#', (...content) => { + const empty = createMessage({ role: 'user', source: { kind: 'user' }, content }) + expect(body([empty, user(), assistant([{ type: 'text', text: 'answer' }]), empty]).messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'answer' }] }, + ]) + }) + + it('collects leading system text and maps tools, stop sequences and explicit output cap', () => { + const system = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'text', text: 'instructions' }] }) + expect(body([system, user()], { system: 'top', maxTokens: 123, stop: ['END'], tools: [{ name: 'read', description: 'Read a file', parameters: { type: 'object' } }] })).toMatchObject({ + system: 'top\n\ninstructions', max_tokens: 123, stop_sequences: ['END'], tools: [{ name: 'read', description: 'Read a file', input_schema: { type: 'object' } }], + }) + expect(body([user(), system]).system).toBe('instructions') + }) + + it('uses the latest complete system snapshot without changing tool history or durable messages', () => { + const conversation = [user(), assistant([call()]), result(), assistant([{ type: 'text', text: 'done' }]), user('continue')] + const history = [createSystemMessage('obsolete'), ...conversation.slice(0, 2), + createSystemMessage('intermediate'), ...conversation.slice(2, 4), + createSystemMessage('current'), conversation[4]!] + const saved = JSON.stringify(history) + expect(body(history)).toEqual({ ...body(conversation), system: 'current' }) + expect(body(history, { system: 'one-shot prefix' }).system).toBe('one-shot prefix\n\ncurrent') + expect(JSON.stringify(history)).toBe(saved) + }) + + it('replaces adjacent system snapshots and joins blocks only within the current snapshot', () => { + const latest = createMessage({ role: 'system', source: { kind: 'system-prompt' }, + content: [{ type: 'text', text: 'part one' }, { type: 'text', text: ' and part two' }] }) + expect(body([createSystemMessage('old'), latest, user()]).system).toBe('part one and part two') + }) + + it.each([[], [{ type: 'text' as const, text: '' }]].map(content => ({ content })))('clears earlier prompt snapshots with empty content %#', ({ content }) => { + const cleared = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content }) + const history = [createSystemMessage('old'), user(), cleared] + expect(body(history).system).toBeUndefined() + expect(body(history, { system: 'one-shot prefix' }).system).toBe('one-shot prefix') + expect(body(history, { system: '' }).system).toBeUndefined() + }) + + it('rejects non-text system content even when a later snapshot supersedes it', () => { + const invalid = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'reasoning', text: 'bad' }] }) + expect(() => body([invalid, user(), createSystemMessage('current')])).toThrow(/non-text system/) + }) + + it.each(['off', 'low', 'high', 'max'])('maps reasoning effort %s', (effort) => { + const request = body([user()], { reasoningEffort: ReasoningEffortId(effort) }) + expect(request.thinking.type).toBe(effort === 'off' ? 'disabled' : 'enabled') + expect(request.output_config).toEqual(effort === 'off' ? undefined : { effort }) + }) + + it('disables thinking for titles, passes temperature with thinking and refuses unsupported effort', () => { + expect(body([user()], { purpose: 'session-title', temperature: 0 })).toMatchObject({ thinking: { type: 'disabled' }, temperature: 0 }) + expect(body([user()], { temperature: 0 })).toMatchObject({ thinking: { type: 'enabled' }, temperature: 0 }) + expect(() => body([user()], { reasoningEffort: ReasoningEffortId('medium') })).toThrow(/effort/) + const disabled = resolveAdapterOptions({ thinking: 'disabled' }) + expect(serialize(options(), disabled, [user()], new Map(), () => undefined).thinking).toEqual({ type: 'disabled' }) + expect(() => serialize(options({ reasoningEffort: ReasoningEffortId('high') }), disabled, [user()], new Map(), () => undefined)).toThrow(/effort/) + const capped = resolveAdapterOptions({ models: [{ id: MODEL, maxTokens: 321 }] }) + expect(serialize(options(), capped, [user()], new Map(), () => undefined).max_tokens).toBe(321) + }) + + it.each([ + [result()], [assistant([call()])], [assistant([call()]), user()], + [assistant([call(), call()]), result()], + [assistant([call()]), result(), result()], + ])('rejects unmatched or duplicated tool history %#', (...messages) => { + expect(() => body(messages)).toThrow(/tool/) + }) + + it.each(['{', '', '[]', 'null', '42', 'true', '"text"', '{"description":"最快,但"某个说法"没有证据。"}'])('uses empty input for malformed or non-object historical tool arguments %s', (arguments_) => { + const message = assistant([{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: arguments_ }]) + const history = [user(), message, createToolResultMessage({ callId: ToolCallId('a'), content: [{ type: 'text', text: 'Invalid arguments' }], isError: true }), user('Continue')] + const saved = JSON.stringify(history) + const restored = JSON.parse(saved) as Message[] + expect(body(restored).messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'tool_use', id: 'a', name: 'read', input: {} }] }, + { role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: 'Invalid arguments' }], is_error: true }, + { type: 'text', text: 'Continue' }, + ] }, + ]) + expect(JSON.stringify(restored)).toBe(saved) + }) + + it('preserves own signed thinking, omits absent signatures and validates durable metadata', () => { + const content: ContentBlock[] = [{ type: 'reasoning', text: '' }, { type: 'text', text: 'answer' }] + const source = { provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [{ type: 'reasoning', signature: 'signed' }, { type: 'text' }]) } + const message = createAssistantMessage({ content, source }) + expect(body([user(), message, user()]).messages[1]?.content).toEqual([{ type: 'thinking', thinking: '', signature: 'signed' }, { type: 'text', text: 'answer' }]) + expect(body([assistant([{ type: 'reasoning', text: 'foreign thought' }])]).messages[0]?.content).toEqual([{ type: 'thinking', thinking: 'foreign thought' }]) + expect(readReplay(message, 'different-model')).toBeUndefined() + expect(readReplay(user(), MODEL)).toBeUndefined() + }) + + it.each([ + null, + [], + { response: null, blocks: [] }, + { response: { kind: 'other', version: 1 }, blocks: [] }, + { response: { kind: 'deepseek-messages', version: 2 }, blocks: [] }, + { response: { kind: 'deepseek-messages', version: 1, model: 'wrong' }, blocks: [] }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [] }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: null }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [null] }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [{ type: 'tool-call' }] }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [{ type: 'reasoning', signature: 3 }] }, + ].map(state => ({ state })))('degrades unusable replay state with a diagnostic %#', ({ state }) => { + const message = createAssistantMessage({ content: [{ type: 'reasoning', text: 'think' }], source: { provider: 'deepseek-official', model: MODEL, replayState: state } }) + const onDegrade = vi.fn() + expect(readReplay(message, MODEL, onDegrade)).toBeUndefined() + expect(onDegrade).toHaveBeenCalledExactlyOnceWith(expect.any(String)) + expect(body([message]).messages[0]?.content).toEqual([{ type: 'thinking', thinking: 'think' }]) + }) + + it.each([MODEL, 'different-model'])('keeps durable content when replay degrades for %s', async (model) => { + const message = createAssistantMessage({ + content: [{ type: 'reasoning', text: 'Read the file.' }, { type: 'text', text: 'Checking a.' }, call()], + source: { provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [ + { type: 'reasoning', signature: 'do-not-send' }, { type: 'text', signature: 'invalid-for-text' }, { type: 'tool-call' }, + ]) }, + }) + const saved = JSON.stringify(message) + const restored = JSON.parse(saved) as Message + const messages = [user(), restored, result()] + const onDegrade = vi.fn() + const request = serialize(options({ model }), connection, messages, new Map(), () => undefined, onDegrade) + expect(onDegrade).toHaveBeenCalledExactlyOnceWith('DeepSeek Messages replay: invalid signature') + await expect(JSON.stringify(request.messages, null, 2) + '\n').toMatchFileSnapshot('expected/degraded-replay.json') + expect(JSON.stringify(restored)).toBe(saved) + }) + + it('keeps valid cross-model and foreign history quiet and propagates diagnostic failures', () => { + const onDegrade = vi.fn() + const message = createAssistantMessage({ content: [{ type: 'reasoning', text: 'think' }], source: { + provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [{ type: 'reasoning', signature: '' }]), + } }) + expect(readReplay(message, MODEL, onDegrade)).toEqual([{ type: 'reasoning', signature: '' }]) + expect(readReplay(message, 'different-model', onDegrade)).toBeUndefined() + expect(readReplay(assistant([{ type: 'text', text: 'foreign' }]), MODEL, onDegrade)).toBeUndefined() + expect(onDegrade).not.toHaveBeenCalled() + const damaged = { ...message, source: { ...message.source, replayState: { response: {}, blocks: [] } } } + const failure = new Error('diagnostic failed') + expect(() => readReplay(damaged, MODEL, () => { throw failure })).toThrow(failure) + }) + + it.each([1, 2])('uses empty historical tool input with replay version %s', (version) => { + const message = createAssistantMessage({ content: [{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: '{' }], source: { + provider: 'deepseek-official', model: MODEL, replayState: { response: { kind: 'deepseek-messages', version, model: MODEL }, blocks: [{ type: 'tool-call' }] }, + } }) + const saved = JSON.stringify(message) + const onDegrade = vi.fn() + const request = serialize(options(), connection, [message, result()], new Map(), () => undefined, onDegrade) + expect(request.messages[0]?.content).toEqual([{ type: 'tool_use', id: 'a', name: 'read', input: {} }]) + expect(onDegrade).toHaveBeenCalledTimes(version === 1 ? 0 : 1) + expect(JSON.stringify(message)).toBe(saved) }) }) -describe('serializeRequest', () => { - const history: Message[] = [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'test' }, - })] +describe('validated configuration', () => { + it('advertises exact model metadata and allows unlisted text models', () => { + expect(modelInfo(connection, 'deepseek-official', MODEL)).toMatchObject({ context: { contextWindow: 1_000_000 }, defaultMaxTokens: 256_000, reasoning: { defaultEffort: 'high' } }) + expect(modelInfo(connection, 'deepseek-official', 'custom').inputModalities).toEqual(['text']) + expect(modelInfo(connection, 'deepseek-official', MODEL).systemPromptUpdate).toBeUndefined() + expect(modelInfo(connection, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined() + expect(modelInfo(capable, 'deepseek-official', MODEL).systemPromptUpdate).toBe('in-history') + expect(modelInfo(capable, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined() + expect(modelInfo(resolveAdapterOptions({ thinking: 'disabled' }), 'deepseek-official', MODEL).reasoning?.efforts).toMatchObject([{ id: 'off', name: 'Off' }]) + expect(resolveAdapterOptions({ baseURL: 'https://example.com/anthropic///' }).baseURL).toBe('https://example.com/anthropic///') + }) + it.each([ + { thinking: 'disabled', reasoningEffort: 'high' }, { models: [{ id: '' }] }, + { models: [{ id: 'a' }, { id: 'a' }] }, { models: [{ id: 'a', name: '' }] }, + { maxInlineRequestImageBytes: 1 }, { maxImagesPerRequest: 1 }, + { baseURL: 'ftp://example.com' }, { baseURL: 'https://user:pass@example.com' }, + { baseURL: 'https://example.com/?key=x' }, { baseURL: 'https://example.com/#x' }, + { maxTokens: 0 }, { streamIdleTimeoutMs: 0 }, + { models: [{ id: MODEL, systemPromptUpdate: 'unsupported' }] }, + ])('rejects invalid composition input %#', (value) => { + expect(() => resolveAdapterOptions(value as Config)).toThrow() + }) +}) - it('always streams with usage and maps the basics', () => { - const wire = serializeRequest(request({ messages: history })) - expect(wire).toEqual({ - model: 'deepseek-v4-flash', - messages: [{ role: 'user', content: 'hi' }], - stream: true, - stream_options: { include_usage: true }, +describe('Messages images', () => { + const ref: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), mediaType: 'image/png', width: 1, height: 1, bytes: 3 } + const image: ImageBlock = { type: 'image', attachment: ref } + const version: RequestImageAttachment = { attachment: ref, variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), mediaType: 'image/png', bytes: 3, data: Uint8Array.of(1, 2, 3), width: 1, height: 1, depth: 'uchar', space: 'srgb', hasAlpha: false } + const access = () => ({ readonlyPath: '/workspace/image.png' }) + const model = 'deepseek-flash' + // Only the read operation is consumed by image preparation; the transport is mocked, not durable content. + const attachments = requestImageStore(async () => version) + const signal = new AbortController().signal + it('keeps image bytes inside tool results and deduplicates normalization', async () => { + const history = [assistant([call()]), result('a', [image, image])] + const prepared = await prepareImages(history, connection, model, attachments, access, signal) + expect(prepared.versions.size).toBe(1) + const request = serialize(options({ model }), connection, prepared.messages, prepared.versions, access) + expect(request.messages[1]?.content[0]).toMatchObject({ type: 'tool_result', content: [ + { type: 'text', text: expect.stringContaining('/workspace/image.png') as string }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AQID' } }, + { type: 'text' }, { type: 'image' }, + ] }) + expect(imagePricing(connection, model, access).priceImages([image])[0]?.visualTokens).toBeGreaterThan(0) + expect(imagePricing(connection, MODEL, access).priceImages([image])[0]?.visualTokens).toBe(0) + }) + it('requires logged offload at exact encoded bytes and preserves durable references', async () => { + const config = resolveAdapterOptions({ + maxInlineRequestImageBytes: 4, inlineImageOffloadByteQuantum: 1, maxImagesPerRequest: 2, imageOffloadCountQuantum: 1, }) - }) - - it('prepends the system prompt', () => { - const wire = serializeRequest(request({ messages: history, system: 'be helpful' })) - expect(wire.messages[0]).toEqual({ role: 'system', content: 'be helpful' }) - expect(wire.messages[1]).toEqual({ role: 'user', content: 'hi' }) - }) - - it('serializes a leading system message byte-for-byte like the same prompt passed as options.system', async () => { - const systemMessage = createMessage({ - role: 'system', - content: [{ type: 'text', text: 'be helpful' }], - source: { kind: 'system-prompt' }, - }) - const tools = [{ name: 'f', description: 'F', parameters: { type: 'object', properties: {} } }] - const fromHistory = serializeRequest(request({ messages: [systemMessage, ...history], tools })) - const fromOption = serializeRequest(request({ messages: history, system: 'be helpful', tools })) - expect(fromHistory.messages[0]).toEqual({ role: 'system', content: 'be helpful' }) - expect(JSON.stringify(fromHistory)).toBe(JSON.stringify(fromOption)) - const images = imageOptions([]) - const imageHistory = await serializeRequestWithImages(request({ messages: [systemMessage, ...history], tools }), images) - const imageOption = await serializeRequestWithImages(request({ messages: history, system: 'be helpful', tools }), images) - expect(JSON.stringify(imageHistory)).toBe(JSON.stringify(imageOption)) - }) - - it('maps sampling params and stop sequences', () => { - const wire = serializeRequest(request({ messages: history, temperature: 0.2, maxTokens: 100, stop: ['END'] })) - expect(wire.temperature).toBe(0.2) - expect(wire.max_tokens).toBe(100) - expect(wire.stop).toEqual(['END']) - }) - - it('maps tools to the wire function shape', () => { - const wire = serializeRequest(request({ - messages: history, - tools: [ - { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } }, - { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } }, - ], + const offloadFailure: unknown = expect.objectContaining({ code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }) + const history = [result('a', [image, image])] + const prepared = await prepareImages(history, config, model, attachments, access, signal) + expect(prepared.messages[0]?.content).toMatchObject([image, image]) + expect(() => inlineImages(prepared.messages, prepared.versions, config)).toThrow(expect.objectContaining({ + failure: offloadFailure, + })) + const offloaded: ImageBlock = { ...image, offloaded: true } + const retry = await prepareImages([result('a', [offloaded, image])], config, model, attachments, access, signal) + expect(inlineImages(retry.messages, retry.versions, config)[0]?.content).toMatchObject([{ type: 'text' }, { type: 'image' }]) + expect(history[0]?.content).toMatchObject([image, image]) + expect(imagePricing(config, model, access).priceImages([image, image]).map(entry => entry.visualTokens)) + .toEqual([expect.any(Number), expect.any(Number)]) + const large = requestImageStore(async () => ({ ...version, bytes: 30, data: new Uint8Array(30) })) + const exact = await prepareImages([result('a', [image])], config, model, large, access, signal) + expect(() => inlineImages(exact.messages, exact.versions, config)).toThrow(expect.objectContaining({ + failure: offloadFailure, })) - expect(wire.tools).toEqual([ - { type: 'function', function: { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } } }, - { type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } } }, - ]) }) - - it('omits an empty tools array', () => { - const wire = serializeRequest(request({ messages: history, tools: [] })) - expect(wire.tools).toBeUndefined() - }) - - it.each(['low', 'high', 'max'] as const)('maps adapter-default thinking and request effort %s', (effort) => { - const wire = serializeRequest( - request({ messages: history, reasoningEffort: ReasoningEffortId(effort) }), - { thinking: 'enabled', reasoningEffort: 'high' }, - ) - expect(wire.thinking).toEqual({ type: 'enabled' }) - expect(wire.reasoning_effort).toBe(effort) - }) - - it('maps off to disabled thinking without a wire reasoning effort', () => { - const wire = serializeRequest( - request({ messages: history, reasoningEffort: ReasoningEffortId('off') }), - { thinking: 'enabled', reasoningEffort: 'max' }, - ) - expect(wire.thinking).toEqual({ type: 'disabled' }) - expect(wire.reasoning_effort).toBeUndefined() - }) - - it('re-enables thinking when max overrides an off default', () => { - const wire = serializeRequest( - request({ messages: history, reasoningEffort: ReasoningEffortId('max') }), - { reasoningEffort: 'off' }, - ) - expect(wire.thinking).toEqual({ type: 'enabled' }) - expect(wire.reasoning_effort).toBe('max') - }) - - it('rejects enabling thinking when the deployment is locked to disabled', () => { - expect(() => serializeRequest( - request({ messages: history, reasoningEffort: ReasoningEffortId('high') }), - { thinking: 'disabled' }, - )).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' })) - }) - - it('disables thinking for session-title requests without changing adapter defaults', () => { - const wire = serializeRequest( - request({ - messages: history, - purpose: 'session-title', - reasoningEffort: ReasoningEffortId('max'), - }), - { thinking: 'enabled', reasoningEffort: 'max' }, - ) - expect(wire.thinking).toEqual({ type: 'disabled' }) - expect(wire.reasoning_effort).toBeUndefined() - }) - - it('omits thinking fields when unset (provider default applies)', () => { - const wire = serializeRequest(request({ messages: history })) - expect(wire.thinking).toBeUndefined() - expect(wire.reasoning_effort).toBeUndefined() - }) - - it('preserves an explicit enabled default without inventing a wire effort', () => { - const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled' }) - expect(wire.thinking).toEqual({ type: 'enabled' }) - expect(wire.reasoning_effort).toBeUndefined() - }) - - it('rejects an effort outside the DeepSeek capability', () => { - expect(() => serializeRequest(request({ - messages: history, - reasoningEffort: ReasoningEffortId('medium'), - }))).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' })) - }) -}) - -describe('image serialization', () => { - it.each([ - 'image/png', - 'image/jpeg', - 'image/webp', - 'image/gif', - ] as const)('preserves ordered text and %s image parts', async (mediaType) => { - const resolveFileId = fileResolver() - const ref = imageRef(mediaType) - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'text', text: 'before' }, - { type: 'image', attachment: ref }, - { type: 'text', text: 'after' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), imageOptions([ref], resolveFileId)) - - expect(wire.messages).toEqual([{ - role: 'user', - content: [ - { type: 'text', text: 'before' }, - { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request preview 1x1px`) as string }, - { type: 'file', file_id: 'file-api-image' }, - { type: 'text', text: 'after' }, - ], - }]) - }) - - it.each([ - ['image/png', 'data:image/png;base64,AAAA'], - ['image/jpeg', 'data:image/jpeg;base64,AAAA'], - ['image/webp', 'data:image/webp;base64,AAAA'], - ['image/gif', 'data:image/gif;base64,AAAA'], - ] as const)('serializes every retained %s request version as an inline data URL', async (mediaType, url) => { - const ref = imageRef(mediaType) - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), inlineImageOptions([ref])) - - expect(wire.messages).toEqual([{ - role: 'user', - content: [ - { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request preview 1x1px`) as string }, - { type: 'image_url', image_url: { url } }, - ], - }]) - }) - - it('gives image-only input a stable handle and request dimensions', async () => { - const ref = imageRef() - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), imageOptions([ref])) - - expect(wire.messages).toEqual([{ - role: 'user', - content: [ - { - type: 'text', - text: `Image ${ref.attachmentId}; request preview 1x1px. It may be resized or re-encoded; source dimensions, format, and byte size may differ.`, - }, - { type: 'file', file_id: 'file-api-image' }, - ], - }]) - }) - - it('includes provider-resolved normalized access in a retained image handle', async () => { - const ref = { ...imageRef(), name: 'diagram.png', width: 2048, height: 1024 } - const images = imageOptions([ref]) - const version = images.requestImages.get(ref.attachmentId) as RequestImageAttachment - version.width = 1130 - version.height = 565 - images.resolveImageAccess = () => ({ readonlyPath: '/tmp/dsh/objects/aa/object' }) - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), images) - - expect(wire.messages[0]).toMatchObject({ - role: 'user', - content: [{ - type: 'text', - text: expect.stringContaining('Image "diagram.png"') as string, - }, { type: 'file' }], - }) - expect(JSON.stringify(wire.messages[0])).toContain('/tmp/dsh/objects/aa/object') - expect(JSON.stringify(wire.messages[0])).toContain('request preview 1130x565px') - }) - - it('rejects an image whose prepared request version is absent', async () => { - const ref = imageRef() - await expect(serializeMessagesWithImages([createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'test' }, - })], imageOptions([]))).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) - }) - - it('keeps tool content textual and groups consecutive tool-result images afterward', async () => { - const messages = [ - createToolResultMessage({ - callId: ToolCallId('first'), - content: [{ type: 'image', attachment: imageRef() }], - isError: false, - }), - createToolResultMessage({ - callId: ToolCallId('second'), - content: [ - { type: 'text', text: 'caption' }, - { type: 'image', attachment: imageRef('image/jpeg') }, - ], - isError: false, - }), - ] - - const png = imageRef() - const jpeg = imageRef('image/jpeg') - await expect(serializeMessagesWithImages(messages, imageOptions( - [png, jpeg], - vi.fn((version: RequestImageAttachment) => Promise.resolve(`file-api-${version.mediaType}`)), - ))).resolves.toEqual([ - { - role: 'tool', - tool_call_id: 'first', - content: expect.stringContaining(`Image ${png.attachmentId}`) as string, - }, - { - role: 'tool', - tool_call_id: 'second', - content: expect.stringContaining(`caption\nImage ${jpeg.attachmentId}`) as string, - }, - { - role: 'user', - content: [ - { type: 'text', text: 'Attached image(s) from tool result:' }, - { type: 'file', file_id: 'file-api-image/png' }, - { type: 'file', file_id: 'file-api-image/jpeg' }, - ], - }, - ]) - }) - - it('keeps an empty user message for ignored content beside a tool message', async () => { - const messages = [ - createUserMessage({ - content: [ - { type: 'text', text: '' }, - { type: 'chart', data: 'ignored' } as unknown as ContentBlock, - ], - source: { kind: 'test' }, - }), - createToolResultMessage({ - callId: ToolCallId('result'), - content: [{ type: 'text', text: 'ok' }], - isError: false, - }), - ] - - await expect(serializeMessagesWithImages(messages, imageOptions([], fileResolver()))).resolves.toEqual([ - { role: 'user', content: '' }, - { role: 'tool', tool_call_id: 'result', content: 'ok' }, - ]) - }) - - it('converts consecutive tool messages and preserves the empty fallback', async () => { - const messages = [ - createToolResultMessage({ - callId: ToolCallId('nested'), - content: [{ type: 'text', text: 'inside' }], - isError: false, - }), - createToolResultMessage({ callId: ToolCallId('empty'), content: [], isError: false }), - ] - - await expect(serializeMessagesWithImages(messages, imageOptions([], fileResolver()))).resolves.toEqual([ - { role: 'tool', tool_call_id: 'nested', content: 'inside' }, - { role: 'tool', tool_call_id: 'empty', content: '(no output)' }, - ]) - }) - - it('flushes tool-result images before system and assistant history', async () => { - const imageResult = (id: string) => createToolResultMessage({ - callId: ToolCallId(id), - content: [{ type: 'image', attachment: imageRef() }], - isError: false, - }) - const messages = [ - imageResult('before-system'), - createMessage({ - role: 'system', - content: [{ type: 'text', text: 'system history' }], - source: { kind: 'system-prompt' }, - }), - imageResult('before-assistant'), - createMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'assistant history' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ] - - const wire = await serializeMessagesWithImages(messages, imageOptions([imageRef()], fileResolver())) - expect(wire).toEqual([ - { - role: 'tool', - tool_call_id: 'before-system', - content: expect.stringContaining('request preview 1x1px') as string, - }, - expect.objectContaining({ role: 'user' }), - { role: 'system', content: 'system history' }, - { - role: 'tool', - tool_call_id: 'before-assistant', - content: expect.stringContaining('request preview 1x1px') as string, - }, - expect.objectContaining({ role: 'user' }), - { role: 'assistant', content: 'assistant history' }, - ]) - }) - - it('projects surface-offloaded occurrences to placeholders without resolving them', async () => { - const resolveFileId = fileResolver() - const png = imageRef('image/png', 3) - const jpeg = imageRef('image/jpeg', 3) - const images = imageOptions([png, jpeg], resolveFileId, 4) - images.resolveImageAccess = ref => ref.mediaType === 'image/png' - ? { readonlyPath: '/tmp/dsh/objects/png' } - : undefined - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: png, offloaded: true }, - { type: 'image', attachment: jpeg }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), images) - - expect(wire.messages[0]).toMatchObject({ - role: 'user', - content: [ - { - type: 'text', - text: expect.stringContaining(`image omitted to fit request image limits; ${png.attachmentId}. Normalized copy (read-only; may be resized or re-encoded): "/tmp/dsh/objects/png"`) as string, - }, - { type: 'text', text: expect.stringContaining(`Image ${jpeg.attachmentId}`) as string }, - { type: 'file', file_id: 'file-api-image' }, - ], - }) - expect(resolveFileId).toHaveBeenCalledTimes(1) - expect(resolveFileId.mock.calls[0]?.[0]).toMatchObject({ attachment: { mediaType: 'image/jpeg' } }) - }) - it('rejects retained inline images beyond the bound with the quantized count to offload', async () => { - const ref = imageRef('image/png', 3) - // 21 retained 3-byte images encode to 84 base64 bytes against an 80-byte bound with a - // 40-byte quantum: the removal crosses the quantum at the eleventh oldest occurrence. - await expect(serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: Array.from({ length: 21 }, () => ({ type: 'image' as const, attachment: ref })), - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), inlineImageOptions([ref], 80, 40))).rejects.toMatchObject({ - code: 'IMAGE_OFFLOAD_REQUIRED', - failure: { offloadImages: 11 }, - }) - }) - - it('uses the configured image-count quantum before resolving files', async () => { - const ref = imageRef() - const resolveFileId = fileResolver() - const messages = [createUserMessage({ - content: Array.from({ length: 3 }, () => ({ type: 'image' as const, attachment: ref })), - source: { kind: 'test' }, - })] - await expect(serializeRequestWithImages(request({ messages }), { - ...imageOptions([ref], resolveFileId), maxImagesPerRequest: 2, countQuantum: 2, - })).rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', failure: { offloadImages: 2 } }) - expect(resolveFileId).not.toHaveBeenCalled() - }) - - it('counts only retained occurrences against the bound', async () => { - const ref = imageRef('image/png', 3) - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - ...Array.from({ length: 11 }, () => ({ type: 'image' as const, attachment: ref, offloaded: true as const })), - ...Array.from({ length: 10 }, () => ({ type: 'image' as const, attachment: ref })), - ], - source: { kind: 'test' }, - })], - }), inlineImageOptions([ref], 80, 40)) - const content = wire.messages[0]?.content - expect(JSON.stringify(content).match(/image omitted to fit request image limits/g)).toHaveLength(11) - expect(JSON.stringify(content).match(/"type":"image_url"/g)).toHaveLength(10) - }) - it('rejects an unprepared image while computing exact request bytes', async () => { - const ref = imageRef() - await expect(serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), imageOptions([]))).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) - }) - - it.each(['system', 'assistant'] as const)('rejects an image in %s history before reading attachments', async (role) => { - const resolveFileId = vi.fn() - const content: ContentBlock[] = [{ type: 'image', attachment: imageRef() }] - const message = role === 'system' - ? createMessage({ role, content, source: { kind: 'system-prompt' } }) - : createMessage({ role, content, source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) - await expect(serializeMessagesWithImages([message], imageOptions([imageRef()], resolveFileId))) - .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(resolveFileId).not.toHaveBeenCalled() - }) - - it('rejects developer history on text and image paths before reading attachments', async () => { - const resolveFileId = fileResolver() - const message = createDeveloperMessage({ - content: [{ type: 'tool-addition', toolName: 'search' }], - source: { kind: 'test' }, - }) - const failure = { code: 'UNSUPPORTED_CONTENT', message: 'Developer messages are not supported yet' } - expect(() => serializeMessages([message])).toThrow(expect.objectContaining(failure)) - await expect(serializeMessagesWithImages([message], imageOptions([], resolveFileId))) - .rejects.toMatchObject(failure) - await expect(serializeRequestWithImages(request({ messages: [message] }), imageOptions([], resolveFileId))) - .rejects.toMatchObject(failure) - expect(resolveFileId).not.toHaveBeenCalled() - }) - - it('rejects unsupported image history before request offloading can replace it', async () => { - const resolveFileId = vi.fn() - await expect(serializeRequestWithImages(request({ - messages: [createMessage({ - role: 'system', - content: [{ type: 'image', attachment: imageRef('image/png', 300) }], - source: { kind: 'system-prompt' }, - })], - }), imageOptions([imageRef('image/png', 300)], resolveFileId, 1))) - .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(resolveFileId).not.toHaveBeenCalled() - }) - - it('prepends the request system prompt on the image path', async () => { - const ref = imageRef() - const wire = await serializeRequestWithImages(request({ - system: 'system prompt', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), imageOptions([ref])) - expect(wire.messages[0]).toEqual({ role: 'system', content: 'system prompt' }) - }) - - it('preserves stable file-resolution failure codes', async () => { - const failure = new Error('Stored attachment bytes are corrupt.') as Error & { code: string } - failure.code = 'ATTACHMENT_CORRUPT' - const resolveFileId = vi.fn(() => Promise.reject(failure)) - await expect(serializeMessagesWithImages([createUserMessage({ - content: [{ type: 'image', attachment: imageRef() }], - source: { kind: 'test' }, - })], imageOptions([imageRef()], resolveFileId))) - .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) - }) - - it('preserves non-attachment resolver failures', async () => { - const failure = new Error('resolver failed') - const resolveFileId = vi.fn(() => Promise.reject(failure)) - await expect(serializeMessagesWithImages([createUserMessage({ - content: [{ type: 'image', attachment: imageRef() }], - source: { kind: 'test' }, - })], imageOptions([imageRef()], resolveFileId))).rejects.toBe(failure) - }) -}) - -describe('review fixes: assistant content shapes', () => { - it('serializes a content-less, tool-call-less assistant message as "" content, never null', () => { - // Aborted/empty assistant turns: no text, no calls → "". The earlier - // null shape was live-falsified: the API 400s a null-content assistant - // message without tool_calls ("content or tool_calls must be set"). - const wire = serializeMessages([createMessage({ - role: 'assistant', content: [], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })]) - expect(wire).toEqual([{ role: 'assistant', content: '' }]) - }) - - it('serializes a reasoning-ONLY assistant message as "" content beside its reasoning', () => { - // The model can answer entirely in the reasoning channel (a v4-flash - // greeting did, live). Content must still be SET — a null here poisoned - // the session log and bricked every later turn of that session. - const wire = serializeMessages([createMessage({ - role: 'assistant', content: [{ type: 'reasoning', text: '你好!有什么我可以帮你的吗?' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })]) - expect(wire).toEqual([{ - role: 'assistant', content: '', reasoning_content: '你好!有什么我可以帮你的吗?', - }]) - }) - - it('serializes tool-call turns with empty string content, not null', () => { - const wire = serializeMessages([createMessage({ - role: 'assistant', - content: [{ type: 'tool-call', id: ToolCallId('c'), name: 'f', arguments: '{}' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })]) - expect(wire[0]).toMatchObject({ content: '' }) + it('rejects unsupported roles and unavailable image capabilities before HTTP', async () => { + const history = [result('a', [image])] + await expect(prepareImages(history, connection, MODEL, attachments, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + await expect(prepareImages(history, connection, model, undefined, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + await expect(prepareImages([assistant([image])], connection, model, attachments, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(() => body([result('a', [image])])).toThrow(/image/) + expect(() => body([assistant([image])])).toThrow(/assistant/) + // @ts-expect-error -- malformed provider input can carry a retired nested result block. + expect(() => body([result('a', [{ type: 'tool-result', toolCallId: ToolCallId('nested'), content: [] }])])) + .toThrow(/user\/tool-result content tool-result/) + expect(() => serialize(options({ model }), connection, [result('a', [image])], new Map([[ref.attachmentId, version]]), access, undefined, new Map())) + .toThrow(/request file id is missing/) }) }) diff --git a/packages/llm/llm-deepseek/tests/sse.spec.ts b/packages/llm/llm-deepseek/tests/sse.spec.ts deleted file mode 100644 index bdf1508c60..0000000000 --- a/packages/llm/llm-deepseek/tests/sse.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { LlmError } from '@deepseek-ai/dsh-llm' -import { DONE, parseSse } from '../src/protocols/chat-completions/sse.ts' - -/** - * DeepSeek protocol contract only: the [DONE] sentinel and STREAM_CLOSED on - * EOF without it. SSE framing (chunk splits, CRLF, multi-data joins, comments) - * is eventsource-parser's contract, not re-proven here. - */ - -/** Build an SSE byte stream from string fragments (fragments = network reads). */ -function bytes(...fragments: string[]): ReadableStream> { - const encoder = new TextEncoder() - return new ReadableStream({ - start(controller) { - for (const fragment of fragments) controller.enqueue(encoder.encode(fragment)) - controller.close() - }, - }) -} - -async function collect(stream: AsyncIterable): Promise { - const out: string[] = [] - for await (const item of stream) out.push(item) - return out -} - -describe('parseSse', () => { - it('yields event payloads and the DONE sentinel', async () => { - const events = await collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('reports comments out of band without yielding them', async () => { - const comments: string[] = [] - const events = await collect(parseSse( - bytes(': keep-alive\n\ndata: {"a":1}\n\ndata: [DONE]\n\n'), - (comment) => { comments.push(comment) }, - )) - expect(comments).toEqual(['keep-alive']) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('stops yielding after DONE even when more data follows', async () => { - const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n'))) - expect(events).toEqual([DONE]) - }) - - it('throws STREAM_CLOSED when the stream ends without DONE', async () => { - await expect(collect(parseSse(bytes('data: {"a":1}\n\n')))).rejects.toThrow(LlmError) - await expect(collect(parseSse(bytes('data: {"a":1}\n\n')))).rejects.toThrow(/without \[DONE\]/) - }) - - it('throws STREAM_CLOSED for an empty stream', async () => { - await expect(collect(parseSse(bytes()))).rejects.toThrow(/without \[DONE\]/) - }) - - it('throws STREAM_CLOSED for a mid-event close', async () => { - await expect(collect(parseSse(bytes('data: {"a"')))).rejects.toThrow(/without \[DONE\]/) - }) - - it('treats a final DONE missing its blank-line terminator as truncation', async () => { - // Spec-strict framing: an event dispatches only on its blank-line - // terminator, so an unterminated tail at EOF is STREAM_CLOSED — real - // providers always terminate events, so a missing terminator is truncation. - await expect(collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]')))).rejects.toThrow(/without \[DONE\]/) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/messages/stream.spec.ts b/packages/llm/llm-deepseek/tests/stream.spec.ts similarity index 97% rename from packages/llm/llm-deepseek/tests/messages/stream.spec.ts rename to packages/llm/llm-deepseek/tests/stream.spec.ts index 51d54beef0..aa86d48404 100644 --- a/packages/llm/llm-deepseek/tests/messages/stream.spec.ts +++ b/packages/llm/llm-deepseek/tests/stream.spec.ts @@ -1,8 +1,8 @@ /** Protocol invariants at JSON/SSE boundaries, including partial and failed responses. */ import { describe, expect, it } from 'vitest' -import { translate } from '../../src/protocols/messages/translate.ts' -import { parseSse } from '../../src/protocols/messages/sse.ts' -import { providerError } from '../../src/protocols/messages/transport.ts' +import { translate } from '../src/translate.ts' +import { parseSse } from '../src/sse.ts' +import { providerError } from '../src/transport.ts' import { assemble, chunks, end, events, MODEL, sse, start, textEvents } from './helpers.ts' describe('Messages stream', () => { diff --git a/packages/llm/llm-deepseek/tests/translate.spec.ts b/packages/llm/llm-deepseek/tests/translate.spec.ts deleted file mode 100644 index 5e19f0c94f..0000000000 --- a/packages/llm/llm-deepseek/tests/translate.spec.ts +++ /dev/null @@ -1,446 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { BlockAssembler, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm' -import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import { DONE } from '../src/protocols/chat-completions/sse.ts' -import { mapFinishReason, mapUsage, translate } from '../src/protocols/chat-completions/translate.ts' - -async function* feed(...payloads: (string | object)[]): AsyncGenerator { - for (const payload of payloads) { - yield typeof payload === 'string' ? payload : JSON.stringify(payload) - } -} - -async function collect(stream: AsyncIterable): Promise { - const out: StreamChunk[] = [] - for await (const chunk of stream) out.push(chunk) - return out -} - -/** The live first-chunk signature: role + null content + EMPTY reasoning. */ -const firstChunk = { choices: [{ delta: { role: 'assistant', content: null, reasoning_content: '' } }] } - -describe('translate: text', () => { - it('streams a text block and defers finish to DONE', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'Hel' } }] }, - { choices: [{ delta: { content: 'lo' } }] }, - { choices: [{ delta: { content: '' }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 2 } }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'block-start', index: 0, blockType: 'text' }, - { type: 'text-delta', index: 0, text: 'Hel' }, - { type: 'text-delta', index: 0, text: 'lo' }, - { type: 'block-end', index: 0, block: { type: 'text', text: 'Hello' } }, - { type: 'usage', usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }, - { type: 'finish', reason: { kind: 'stop' } }, - ]) - }) - - it('assembles into the message BlockAssembler expects', async () => { - const assembler = new BlockAssembler() - for await (const chunk of translate(feed( - firstChunk, - { choices: [{ delta: { content: 'hi' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) { - assembler.push(chunk) - } - const result = { message: assembler.message({ provider: 'mock', model: 'mock' }), finish: assembler.finish } - expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }]) - expect(result.finish).toEqual({ kind: 'stop' }) - }) -}) - -describe('translate: reasoning', () => { - it('does NOT open a reasoning block for the empty first-chunk signature', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'plain' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) - expect(chunks.some(chunk => chunk.type === 'block-start' && chunk.blockType === 'reasoning')).toBe(false) - }) - - it('streams reasoning then text as separate blocks', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: null, reasoning_content: 'think' } }] }, - { choices: [{ delta: { content: null, reasoning_content: 'ing' } }] }, - { choices: [{ delta: { content: 'answer', reasoning_content: null } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'block-start', index: 0, blockType: 'reasoning' }, - { type: 'reasoning-delta', index: 0, text: 'think' }, - { type: 'reasoning-delta', index: 0, text: 'ing' }, - { type: 'block-start', index: 1, blockType: 'text' }, - { type: 'text-delta', index: 1, text: 'answer' }, - { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'thinking' } }, - { type: 'block-end', index: 1, block: { type: 'text', text: 'answer' } }, - { type: 'finish', reason: { kind: 'stop' } }, - ]) - }) - - it('treats an entirely absent reasoning_content field as non-thinking', async () => { - const chunks = await collect(translate(feed( - { choices: [{ delta: { role: 'assistant', content: 'x' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-start')).toEqual([ - { type: 'block-start', index: 0, blockType: 'text' }, - ]) - }) -}) - -describe('translate: tool calls', () => { - it('reassembles a tool call from fragmented argument deltas (live capture shape)', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_00_x', type: 'function', function: { name: 'get_weather', arguments: '' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"city"' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: ': "Paris"}' } }] } }] }, - { choices: [{ delta: { content: '' }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 28, completion_tokens: 6 } }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: '' }, - { type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: '{"city"' }, - { type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: ': "Paris"}' }, - { - type: 'block-end', - index: 0, - block: { type: 'tool-call', id: 'call_00_x', name: 'get_weather', arguments: '{"city": "Paris"}' }, - }, - { type: 'usage', usage: { inputTokens: 28, outputTokens: 6, totalTokens: 34 } }, - { type: 'finish', reason: { kind: 'tool-calls' } }, - ]) - }) - - it('disambiguates parallel tool calls by wire index', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { - choices: [{ - delta: { - tool_calls: [ - { index: 0, id: 'a', type: 'function', function: { name: 'one', arguments: '{}' } }, - { index: 1, id: 'b', type: 'function', function: { name: 'two', arguments: '' } }, - ], - }, - }], - }, - { choices: [{ delta: { tool_calls: [{ index: 1, function: { arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - const ends = chunks.filter(chunk => chunk.type === 'block-end') - expect(ends).toEqual([ - { type: 'block-end', index: 0, block: { type: 'tool-call', id: 'a', name: 'one', arguments: '{}' } }, - { type: 'block-end', index: 1, block: { type: 'tool-call', id: 'b', name: 'two', arguments: '{}' } }, - ]) - }) - - it('interleaves text and tool-call blocks with distinct indices', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'Checking.' } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'f', arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - const starts = chunks.filter(chunk => chunk.type === 'block-start') - expect(starts).toEqual([ - { type: 'block-start', index: 0, blockType: 'text' }, - { type: 'block-start', index: 1, blockType: 'tool-call' }, - ]) - }) -}) - -describe('translate: finish and usage handling', () => { - it('takes usage from a trailing usage-only chunk (docs shape)', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'x' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }], usage: null }, - { choices: [], usage: { prompt_tokens: 9, completion_tokens: 1 } }, - DONE, - ))) - expect(chunks.at(-2)).toEqual({ type: 'usage', usage: { inputTokens: 9, outputTokens: 1, totalTokens: 10 } }) - expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } }) - }) - - it('last usage wins when both attached and trailing arrive', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } }, - { choices: [], usage: { prompt_tokens: 2, completion_tokens: 2 } }, - DONE, - ))) - const usage = chunks.find(chunk => chunk.type === 'usage') - expect(usage).toEqual({ type: 'usage', usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 } }) - }) - - it('defaults to finish stop when no finish_reason ever arrives', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'x' } }] }, - DONE, - ))) - expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } }) - }) - - it('omits the usage chunk when none arrived', async () => { - const chunks = await collect(translate(feed(firstChunk, DONE))) - expect(chunks.some(chunk => chunk.type === 'usage')).toBe(false) - }) - - it('handles chunks with no choices at all', async () => { - const chunks = await collect(translate(feed({}, DONE))) - expect(chunks).toEqual([{ - type: 'finish', - reason: { - kind: 'error', - failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE }, - }, - }]) - }) - - it('classifies an explicit stop with no opened blocks as EMPTY_RESPONSE, after usage', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 7, completion_tokens: 0 } }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'usage', usage: { inputTokens: 7, outputTokens: 0, totalTokens: 7 } }, - { - type: 'finish', - reason: { - kind: 'error', - failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE }, - }, - }, - ]) - }) - - it('keeps a reasoning-only stream a successful stop (any opened block counts)', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: null, reasoning_content: 'mull' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) - expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } }) - }) - - it('leaves non-stop finishes unclassified even with no opened blocks', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: {}, finish_reason: 'length' }] }, - DONE, - ))) - expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'max-tokens' } }) - }) -}) - -describe('translate: errors', () => { - it('throws MALFORMED_RESPONSE for invalid JSON payloads', async () => { - await expect(collect(translate(feed('{bad json')))).rejects.toThrow(LlmError) - await expect(collect(translate(feed('{bad json')))).rejects.toThrow(/malformed SSE payload/) - }) - - it('throws STREAM_CLOSED when the payload source ends without DONE', async () => { - await expect(collect(translate(feed(firstChunk)))).rejects.toThrow(/without \[DONE\]/) - }) -}) - -describe('mapFinishReason', () => { - it.each([ - ['stop', { kind: 'stop' }], - ['tool_calls', { kind: 'tool-calls' }], - ['length', { kind: 'max-tokens' }], - ])('maps %s', (wire, expected) => { - expect(mapFinishReason(wire)).toEqual(expected) - }) - - it.each(['content_filter', 'insufficient_system_resource', 'mystery_reason'])( - 'maps %s to an error kind with the wire code', - (wire) => { - expect(mapFinishReason(wire)).toEqual({ - kind: 'error', - failure: { message: `model stopped: ${wire}`, code: wire.toUpperCase() }, - }) - }, - ) -}) - -describe('mapUsage', () => { - it('maps the full live-capture shape', () => { - expect(mapUsage({ - prompt_tokens: 283, - completion_tokens: 69, - total_tokens: 352, - prompt_cache_hit_tokens: 256, - prompt_cache_miss_tokens: 27, - prompt_tokens_details: { cached_tokens: 256 }, - completion_tokens_details: { reasoning_tokens: 24 }, - })).toEqual({ - // 283 wire prompt_tokens minus the 256 cached → 27 uncached input - // (TokenUsage counts are disjoint). - inputTokens: 27, - outputTokens: 69, - totalTokens: 352, - cacheReadTokens: 256, - reasoningTokens: 24, - }) - }) - - it('falls back to prompt_cache_hit_tokens when details are absent', () => { - expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2, prompt_cache_hit_tokens: 8 })) - .toEqual({ inputTokens: 2, outputTokens: 2, totalTokens: 12, cacheReadTokens: 8 }) - }) - - it('reconstructs an exact total when the wire omits it', () => { - expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2 })) - .toEqual({ inputTokens: 10, outputTokens: 2, totalTokens: 12 }) - }) - - it.each([ - ['contradictory total', { prompt_tokens: 10, completion_tokens: 2, total_tokens: 99 }], - ['negative prompt', { prompt_tokens: -1, completion_tokens: 2 }], - ['fractional prompt', { prompt_tokens: 1.5, completion_tokens: 2 }], - ['negative completion', { prompt_tokens: 2, completion_tokens: -1 }], - ['fractional completion', { prompt_tokens: 2, completion_tokens: 1.5 }], - ['unsafe aggregate', { prompt_tokens: Number.MAX_SAFE_INTEGER, completion_tokens: 1 }], - ])('omits the exact total for %s without changing existing buckets', (_name, wire) => { - expect(mapUsage(wire)).toEqual({ - inputTokens: wire.prompt_tokens, - outputTokens: wire.completion_tokens, - }) - }) -}) - -describe('translate: defensive tool-call branches', () => { - it('handles deltas that never carry id or name (empty-string fallbacks)', async () => { - const chunks = await collect(translate(feed( - firstChunk, - // Hypothetical lenient wire: argument fragments with no id/name at all. - { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' }, - { type: 'block-end', index: 0, block: { type: 'tool-call', id: '', name: '', arguments: '{}' } }, - { type: 'finish', reason: { kind: 'tool-calls' } }, - ]) - }) - - it('handles tool_call deltas with a function object but no arguments field', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'f' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', name: 'f', argumentsDelta: '' }) - }) - - it('handles tool_call deltas with no function object at all', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c' }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '' }) - }) -}) - -describe('translate: tool-call identity across deltas', () => { - it('keeps the established identity when continuation deltas re-send it empty', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_00_x', type: 'function', function: { name: 'get_weather', arguments: '' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: '', type: 'function', function: { name: '', arguments: '{"city"' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: '', type: 'function', function: { name: '', arguments: ': "Paris"}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([{ - type: 'block-end', - index: 0, - block: { type: 'tool-call', id: 'call_00_x', name: 'get_weather', arguments: '{"city": "Paris"}' }, - }]) - }) - - it('keeps the established identity when continuation deltas re-send it null', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'Glob', arguments: '' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: null, function: { name: null, arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([{ - type: 'block-end', - index: 0, - block: { type: 'tool-call', id: 'call_1', name: 'Glob', arguments: '{}' }, - }]) - }) - - it('re-sending the same non-empty identity does not duplicate it', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'Glob', arguments: '' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'Glob', arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([{ - type: 'block-end', - index: 0, - block: { type: 'tool-call', id: 'call_1', name: 'Glob', arguments: '{}' }, - }]) - }) - - it('maintains each parallel call identity separately under empty continuation deltas', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { - choices: [{ - delta: { - tool_calls: [ - { index: 0, id: 'a', type: 'function', function: { name: 'one', arguments: '' } }, - { index: 1, id: 'b', type: 'function', function: { name: 'two', arguments: '' } }, - ], - }, - }], - }, - { - choices: [{ - delta: { - tool_calls: [ - { index: 1, id: '', function: { name: '', arguments: '{"b":1}' } }, - { index: 0, id: '', function: { name: '', arguments: '{"a":1}' } }, - ], - }, - }], - }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([ - { type: 'block-end', index: 0, block: { type: 'tool-call', id: 'a', name: 'one', arguments: '{"a":1}' } }, - { type: 'block-end', index: 1, block: { type: 'tool-call', id: 'b', name: 'two', arguments: '{"b":1}' } }, - ]) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/upload-index.spec.ts b/packages/llm/llm-deepseek/tests/upload-index.spec.ts index ddff23e060..989a921e69 100644 --- a/packages/llm/llm-deepseek/tests/upload-index.spec.ts +++ b/packages/llm/llm-deepseek/tests/upload-index.spec.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import { DeepSeekFileId } from '../src/common/file-id.ts' -import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/common/upload-index.ts' +import { DeepSeekFileId } from '../src/file-id.ts' +import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts' const ATTACHMENT = AttachmentId(`sha256:${'a'.repeat(64)}`) const VARIANT = ImageVariantId(`sha256:${'b'.repeat(64)}`) diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index a502f07c50..6322a88f27 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -39,7 +39,6 @@ async function harness( const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', baseURL, streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000, retryPolicy: { @@ -196,7 +195,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', - data: { reason: { kind: 'error', error: { message: 'SSE stream ended without [DONE]', code: 'STREAM_CLOSED' } } }, + data: { reason: { kind: 'error', error: { message: 'DeepSeek Messages stream ended before message_stop', code: 'STREAM_CLOSED' } } }, }) }) @@ -242,7 +241,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { data: { reason: { kind: 'error', error: { code: 'TRANSPORT' } } }, }) if (end?.type === 'turn/end' && end.data.reason.kind === 'error') { - expect(end.data.reason.error.message).toContain('DeepSeek API request to') + expect(end.data.reason.error.message).toBe('DeepSeek Messages transport failed') } }) }) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 6fac0fb24a..dbc3bd15e7 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -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/llm/llm/README.md -README.md: cdda682cebc720d51692a1b0c4c8691fa84359a3 -README.zh.md: 8c679ecc6910376f7270450ae5be867d0da353fc +README.md: 04de24592379dcb0d815f3b033ca2f9f22e2ff6a +README.zh.md: bee35d4a0b62dd61a61b435dfc4eb27b62cdce69 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index cdda682ceb..04de245923 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -127,7 +127,7 @@ File detection reads current content, including tool-role result content, on eve Read these pages when the package-level contract is not enough. They move from the shared types to the concrete adapters, the retry executor, and the measurement service. - [LLM streaming subsystem](../../../docs/subsystems/llm-streaming.md) — the message and block types, compact Assistant stream records, the `StreamChunk` protocol, and the adapter contract. -- [llm-deepseek adapter](../llm-deepseek/README.md) — the direct DeepSeek chat-completions implementation. +- [llm-deepseek adapter](../llm-deepseek/README.md) — the direct DeepSeek Messages implementation. - [llm-pi-ai adapter](../llm-pi-ai/README.md) — the pi-ai-backed multi-provider implementation. - [llm-retry](../llm-retry/README.md) — the retry executor that re-runs failed model requests. - [Token meter](../token-meter/README.md) — replay-aware request and context pressure measurement. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 8c679ecc69..bee35d4a0b 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -127,7 +127,7 @@ for await (const chunk of ctx.llm.stream({ 当包级约定不够用时阅读以下页面。它们从共享类型逐步进入具体适配器、重试执行器与计量服务。 - [LLM(大语言模型)流式子系统](../../../docs/subsystems/llm-streaming.zh.md)——消息与块类型、紧凑的 Assistant 流记录、`StreamChunk` 协议与适配器约定。 -- [llm-deepseek 适配器](../llm-deepseek/README.zh.md)——DeepSeek chat-completions 直连实现。 +- [llm-deepseek 适配器](../llm-deepseek/README.zh.md)——DeepSeek Messages 直连实现。 - [llm-pi-ai 适配器](../llm-pi-ai/README.zh.md)——基于 pi-ai 的多提供方实现。 - [llm-retry](../llm-retry/README.zh.md)——重跑失败模型请求的重试执行器。 - [Token 计量](../token-meter/README.zh.md)——具备回放感知的请求与上下文压力测量。 diff --git a/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts b/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts index 7bf73bc369..2a91b33f33 100644 --- a/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts +++ b/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts @@ -53,7 +53,7 @@ it('uploads freeform feedback and message put/edit/delete through the unchanged : name === '@deepseek-ai/dsh-message-feedback' ? { config: { maxNoteBytes: 1024 } } : name === '@deepseek-ai/dsh-llm-deepseek' - ? { config: { protocol: 'chat-completions', baseURL: server!.baseURL } } + ? { config: { baseURL: server!.baseURL } } : name === '@deepseek-ai/dsh-session-log-deepseek' ? { config: { enabled: true } } : {}, @@ -128,11 +128,11 @@ it('uploads freeform feedback and message put/edit/delete through the unchanged expect(session.deriveMessages()).toEqual(messages) expect(await ctx.messageFeedback.list({ sessionId: session.id })).toEqual({ ok: true, value: { items: [] } }) for (const wire of server.requests) { - expect(wire.path).toBe('/chat/completions') + expect(wire.path).toBe('/v1/messages') expect(wire.body).not.toHaveProperty('dsh_feedback') expect(wire.body).toMatchObject({ model: 'deepseek-v4-flash', messages: [ - { role: 'user', content: 'Question' }, - { role: 'assistant', content: 'Answer' }, + { role: 'user', content: [{ type: 'text', text: 'Question' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'Answer' }] }, ] }) } await ctx.sessions.flush(session) diff --git a/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts b/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts index 0458b7696d..380a47fbda 100644 --- a/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts +++ b/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts @@ -11,7 +11,7 @@ import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-dee import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' -import { end, MODEL, server, sse, start } from '../../../llm/llm-deepseek/tests/messages/helpers.ts' +import { end, MODEL, server, sse, start } from '../../../llm/llm-deepseek/tests/helpers.ts' import SubagentRuntime, { type SubagentRunEndInfo } from '../src/index.ts' import { loadStoredSession } from './persistence-helpers.ts' diff --git a/packages/test-support/llm-mock-server/README.i18n.yaml b/packages/test-support/llm-mock-server/README.i18n.yaml index 10ca40b376..22fcf568b1 100644 --- a/packages/test-support/llm-mock-server/README.i18n.yaml +++ b/packages/test-support/llm-mock-server/README.i18n.yaml @@ -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/test-support/llm-mock-server/README.md -README.md: be311d9c83bcd35ad5bd0c003f6e95861b6d6976 -README.zh.md: 7a022131d9ef09d9ef89c91cae418f093f77aca1 +README.md: 2f83cf66c02728820a4f8aa6f14b1a091d092626 +README.zh.md: ad85853fb4a20c3ccdb444508da8032c18d7538a diff --git a/packages/test-support/llm-mock-server/README.md b/packages/test-support/llm-mock-server/README.md index be311d9c83..2f83cf66c0 100644 --- a/packages/test-support/llm-mock-server/README.md +++ b/packages/test-support/llm-mock-server/README.md @@ -1,5 +1,5 @@ --- -description: "Scriptable OpenAI-compatible fault server for testing LLM adapters and recovery policy without a provider key, for test authors and demos." +description: "Scriptable Messages-compatible fault server for testing LLM adapters and recovery policy without a provider key, for test authors and demos." kind: "package-library" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package gives tests and demos a scriptable OpenAI-compatible HTTP/SSE endpoint, so they can exercise model-provider failures and successes without a provider key. Each accepted `/chat/completions` request consumes the next scripted behavior, including resets, stalls, malformed chunks, rate limits, server errors, completions, and tool calls. Test authors can run it with `pnpm run mock:llm` or call `startMockLlmServer`, which returns captured requests for assertions. Seeded `random` behavior supports reproducible mixed-failure stress runs. +This package gives tests and demos a scriptable Messages-compatible HTTP/SSE endpoint, so they can exercise model-provider failures and successes without a provider key. Each accepted `/v1/messages` request consumes the next scripted behavior, including resets, stalls, malformed chunks, rate limits, server errors, completions, and tool calls. Test authors can run it with `pnpm run mock:llm` or call `startMockLlmServer`, which returns captured requests for assertions. Seeded `random` behavior supports reproducible mixed-failure stress runs. ## Table of Contents @@ -39,7 +39,7 @@ pnpm run mock:llm \ --partial-text "discard this half" ``` -Point the shipping DeepSeek adapter at the server; it appends `/chat/completions` to the configured base: +Point the shipping DeepSeek adapter at the server; it sends requests to `/messages` beneath the server's `/v1` root: ```sh DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \ @@ -88,13 +88,13 @@ Omitting `--seed` generates one and prints it in the `ready` record. `--random-w ### Timing and content controls -The CLI exposes `--success-text`, `--partial-text`, `--reasoning-text`, `--chunk-size`, `--chunk-delay-ms`, `--disconnect-delay-ms`, `--retry-after-ms`, `--request-id`, `--tool-name`, and `--tool-arguments`. Millisecond delays are bounded integers within Node's timer range; `retryAfterMs` must also be positive. The library accepts the same camel-case options. An optional exact `apiKey` validates `Authorization: Bearer `; omission accepts any token. +The CLI exposes `--success-text`, `--partial-text`, `--reasoning-text`, `--chunk-size`, `--chunk-delay-ms`, `--disconnect-delay-ms`, `--retry-after-ms`, `--request-id`, `--tool-name`, and `--tool-arguments`. Millisecond delays are bounded integers within Node's timer range; `retryAfterMs` must also be positive. The library accepts the same camel-case options. An optional exact `apiKey` validates `x-api-key: `; omission accepts any token. ### What can go wrong - **The script runs out** — exhaustion returns a structured HTTP 500; set `--repeat-last` or lengthen the sequence when a run needs more requests. - **Random weights without a positive concrete behavior are rejected** — every entry must name an existing behavior and at least one must carry positive weight. -- **Invalid requests do not consume the script** — wrong methods, paths, bearer tokens, and malformed JSON get ordinary 4xx responses, so a misconfigured client can burn retries without advancing the sequence. +- **Invalid requests do not consume the script** — wrong methods, paths, API keys, and malformed JSON get ordinary 4xx responses, so a misconfigured client can burn retries without advancing the sequence. ----- @@ -108,7 +108,7 @@ This section explains the design of the server; the observable behavior is fully ### Design -The server is built on one rule: each accepted chat-completions request consumes exactly one behavior from an arrival-ordered FIFO cursor, and the server never retries or interprets harness policy. Validation happens before the cursor advances — only a `POST` whose path ends in `/chat/completions`, with a valid bearer token when one is configured and a parseable JSON body, consumes the script; everything else receives an ordinary 4xx. `random` entries resolve at request time through a seeded PRNG over the configured weights, so a run is reproducible from its printed seed. +The server is built on one rule: each accepted Messages request consumes exactly one behavior from an arrival-ordered FIFO cursor, and the server never retries or interprets harness policy. Validation happens before the cursor advances — only a `POST` whose path ends in `/v1/messages`, with a valid API key when one is configured and a parseable JSON body, consumes the script; everything else receives an ordinary 4xx. `random` entries resolve at request time through a seeded PRNG over the configured weights, so a run is reproducible from its printed seed. ### Source map diff --git a/packages/test-support/llm-mock-server/README.zh.md b/packages/test-support/llm-mock-server/README.zh.md index 7a022131d9..ad85853fb4 100644 --- a/packages/test-support/llm-mock-server/README.zh.md +++ b/packages/test-support/llm-mock-server/README.zh.md @@ -1,5 +1,5 @@ --- -description: "用于在无提供方密钥的情况下测试 LLM(大语言模型)适配器与恢复策略的可通过脚本控制的 OpenAI 兼容故障服务器,面向测试作者与演示。" +description: "用于在无提供方密钥的情况下测试 LLM(大语言模型)适配器与恢复策略的可通过脚本控制的 Messages 兼容故障服务器,面向测试作者与演示。" kind: "package-library" --- @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -本包为测试与演示提供可编脚本的 OpenAI 兼容 HTTP/SSE(Server-Sent Events)端点,使其无需提供方密钥即可检验模型提供方的失败与成功。每个已接受的 `/chat/completions` 请求依次消费下一个脚本行为,包括重置、停滞、畸形分片、限流、服务器错误、补全与工具调用。测试作者可以通过 `pnpm run mock:llm` 运行服务器,也可以调用 `startMockLlmServer`,后者会返回捕获的请求供断言使用。带种子的 `random` 行为支持可复现的混合故障压力运行。 +本包为测试与演示提供可编脚本的 Messages 兼容 HTTP/SSE(Server-Sent Events)端点,使其无需提供方密钥即可检验模型提供方的失败与成功。每个已接受的 `/v1/messages` 请求依次消费下一个脚本行为,包括重置、停滞、畸形分片、限流、服务器错误、补全与工具调用。测试作者可以通过 `pnpm run mock:llm` 运行服务器,也可以调用 `startMockLlmServer`,后者会返回捕获的请求供断言使用。带种子的 `random` 行为支持可复现的混合故障压力运行。 ## 目录 @@ -39,7 +39,7 @@ pnpm run mock:llm \ --partial-text "discard this half" ``` -将发布的 DeepSeek 适配器指向服务器;它会将 `/chat/completions` 追加到已配置 base: +将发布的 DeepSeek 适配器指向服务器;它会向服务器 `/v1` 根路径下的 `/messages` 发送请求: ```sh DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \ @@ -88,13 +88,13 @@ pnpm run mock:llm \ ### 时序与内容控制 -CLI 公开 `--success-text`、`--partial-text`、`--reasoning-text`、`--chunk-size`、`--chunk-delay-ms`、`--disconnect-delay-ms`、`--retry-after-ms`、`--request-id`、`--tool-name` 与 `--tool-arguments`。毫秒延迟是 Node 定时器范围内的有界整数;`retryAfterMs` 还必须为正数。库接受相同的 camel-case 选项。可选的 `apiKey` 会精确验证 `Authorization: Bearer `;省略时接受任何 token。 +CLI 公开 `--success-text`、`--partial-text`、`--reasoning-text`、`--chunk-size`、`--chunk-delay-ms`、`--disconnect-delay-ms`、`--retry-after-ms`、`--request-id`、`--tool-name` 与 `--tool-arguments`。毫秒延迟是 Node 定时器范围内的有界整数;`retryAfterMs` 还必须为正数。库接受相同的 camel-case 选项。可选的 `apiKey` 会精确验证 `x-api-key: `;省略时接受任何 token。 ### 可能出什么问题 - **脚本耗尽**——耗尽时返回结构化 HTTP 500;当一次运行需要更多请求时设置 `--repeat-last` 或加长序列。 - **没有正权重具体行为的随机权重会被拒绝**——每个条目都必须命名现有行为,且至少一个条目带正权重。 -- **无效请求不消费脚本**——错误方法、路径、Bearer token 与畸形 JSON 会收到普通 4xx 响应,因此配置错误的客户端可能耗尽重试却不推进序列。 +- **无效请求不消费脚本**——错误方法、路径、API key 与畸形 JSON 会收到普通 4xx 响应,因此配置错误的客户端可能耗尽重试却不推进序列。 ----- @@ -108,7 +108,7 @@ CLI 公开 `--success-text`、`--partial-text`、`--reasoning-text`、`--chunk-s ### 设计 -服务器建立在一个规则之上:每个已接受的 chat-completions 请求从按到达顺序排列的 FIFO 游标消费恰好一个行为,服务器从不重试或解读 harness 策略。校验先于游标推进——只有 `POST` 且路径以 `/chat/completions` 结尾、配置密钥时携带有效 Bearer token、且 JSON 正文可解析的请求才消费脚本;其余请求都收到普通 4xx。`random` 条目在请求时通过带种子的 PRNG 按配置权重解析,因此一次运行可由其打印出的种子复现。 +服务器建立在一个规则之上:每个已接受的 Messages 请求从按到达顺序排列的 FIFO 游标消费恰好一个行为,服务器从不重试或解读 harness 策略。校验先于游标推进——只有 `POST` 且路径以 `/v1/messages` 结尾、配置密钥时携带有效 API key、且 JSON 正文可解析的请求才消费脚本;其余请求都收到普通 4xx。`random` 条目在请求时通过带种子的 PRNG 按配置权重解析,因此一次运行可由其打印出的种子复现。 ### 源码地图 diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index 6bed08aa80..225c056215 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", - "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", + "description": "Scriptable Messages HTTP/SSE fault server for LLM recovery tests", "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" diff --git a/packages/test-support/llm-mock-server/src/index.ts b/packages/test-support/llm-mock-server/src/index.ts index b07a2bb09b..029548b1e8 100644 --- a/packages/test-support/llm-mock-server/src/index.ts +++ b/packages/test-support/llm-mock-server/src/index.ts @@ -1,6 +1,6 @@ /** - * Scriptable OpenAI-compatible HTTP/SSE server for transport, protocol, and - * semantic-empty LLM recovery tests. Each accepted chat-completions request + * Scriptable Messages HTTP/SSE server for transport, protocol, and + * semantic-empty LLM recovery tests. Each accepted Messages request * consumes one behavior; the server never retries or interprets harness policy. * * @module @deepseek-ai/dsh-llm-mock-server @@ -95,7 +95,7 @@ export type MockLlmServerEvent = /** Captured wire request and its final server-side outcome. */ export interface MockLlmRequestRecord { - /** One-based accepted chat-completions request number. */ + /** One-based accepted Messages request number. */ readonly attempt: number /** Script entry consumed for this request before random resolution. */ readonly scriptBehavior: MockLlmBehavior | 'script_exhausted' @@ -119,7 +119,7 @@ export interface MockLlmServerOptions { readonly host?: string /** TCP port; zero requests an OS-assigned port. */ readonly port?: number - /** Optional exact bearer token; omission accepts any authorization header. */ + /** Optional exact API key; omission accepts any x-api-key header. */ readonly apiKey?: string /** Ordered request behaviors; exhaustion fails loud unless `repeatLast` is true. */ readonly sequence: readonly MockLlmBehavior[] @@ -155,7 +155,7 @@ export interface MockLlmServerOptions { /** Running mock server and captured request state. */ export interface MockLlmServer { - /** Base URL without `/v1`; both root and `/v1` chat-completions paths are accepted. */ + /** Base URL without `/v1`; the endpoint is `/v1/messages`. */ readonly baseURL: string /** Actual bound port, including an OS-assigned value. */ readonly port: number @@ -325,7 +325,7 @@ function writeSse(record: MockLlmRequestRecord, response: ServerResponse, payloa } function writeDone(record: MockLlmRequestRecord, response: ServerResponse): void { - writeSse(record, response, '[DONE]') + writeSse(record, response, { type: 'message_stop' }) } function finishRecord( @@ -364,10 +364,22 @@ function httpError( finishRecord(options, record, 'completed') } +function startMessage(record: MockLlmRequestRecord, response: ServerResponse): void { + writeSse(record, response, { + type: 'message_start', + message: { id: 'mock-message', type: 'message', role: 'assistant', model: 'mock-model', content: [], usage: { input_tokens: 3, output_tokens: 0 } }, + }) +} + +function startText(record: MockLlmRequestRecord, response: ServerResponse, index: number): void { + writeSse(record, response, { type: 'content_block_start', index, content_block: { type: 'text', text: '' } }) +} + function terminalChunk(reason: string, outputTokens: number): unknown { return { - choices: [{ index: 0, delta: { content: '' }, finish_reason: reason }], - usage: { prompt_tokens: 3, completion_tokens: outputTokens }, + type: 'message_delta', + delta: { stop_reason: reason, stop_sequence: null }, + usage: { output_tokens: outputTokens }, } } @@ -393,9 +405,10 @@ async function streamText( response: ServerResponse, text: string, delayMs: number, + index = 0, ): Promise { for (const chunk of splitText(text, options.chunkSize)) { - writeSse(record, response, { choices: [{ index: 0, delta: { content: chunk }, finish_reason: null }] }) + writeSse(record, response, { type: 'content_block_delta', index, delta: { type: 'text_delta', text: chunk } }) if (!await pause(delayMs, response)) return false } return true @@ -405,13 +418,16 @@ async function completeText( options: ResolvedOptions, record: MockLlmRequestRecord, response: ServerResponse, - reason: 'stop' | 'length', + reason: 'end_turn' | 'max_tokens', delayMs: number, + index = 0, ): Promise { - if (!await streamText(options, record, response, options.successText, delayMs)) { + startText(record, response, index) + if (!await streamText(options, record, response, options.successText, delayMs, index)) { finishRecord(options, record, 'client_closed') return } + writeSse(record, response, { type: 'content_block_stop', index }) writeSse(record, response, terminalChunk(reason, Array.from(options.successText).length)) writeDone(record, response) response.end() @@ -434,27 +450,11 @@ async function disconnect( function toolCallChunks(options: ResolvedOptions): readonly unknown[] { const midpoint = Math.max(1, Math.floor(options.toolArguments.length / 2)) return [ - { - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: 0, - id: 'mock-call-1', - type: 'function', - function: { name: options.toolName, arguments: options.toolArguments.slice(0, midpoint) }, - }], - }, - finish_reason: null, - }], - }, - { - choices: [{ - index: 0, - delta: { tool_calls: [{ index: 0, function: { arguments: options.toolArguments.slice(midpoint) } }] }, - finish_reason: null, - }], - }, + { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'mock-call-1', name: options.toolName, input: {} } }, + ...[options.toolArguments.slice(0, midpoint), options.toolArguments.slice(midpoint)].map(partial => ({ + type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: partial }, + })), + { type: 'content_block_stop', index: 0 }, ] } @@ -478,7 +478,8 @@ async function runBehavior( return case 'empty': openSse(response) - writeSse(record, response, terminalChunk('stop', 0)) + startMessage(record, response) + writeSse(record, response, terminalChunk('end_turn', 0)) writeDone(record, response) response.end() finishRecord(options, record, 'completed') @@ -490,18 +491,22 @@ async function runBehavior( return case 'stream_eof': openSse(response) - writeSse(record, response, { choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] }) + startMessage(record, response) response.end() finishRecord(options, record, 'completed') return case 'partial_eof': openSse(response) + startMessage(record, response) + startText(record, response, 0) await streamText(options, record, response, options.partialText, 0) response.end() finishRecord(options, record, 'completed') return case 'partial_disconnect': openSse(response) + startMessage(record, response) + startText(record, response, 0) if (!await streamText(options, record, response, options.partialText, options.chunkDelayMs)) return await disconnect(options, record, response) return @@ -518,14 +523,15 @@ async function runBehavior( return case 'malformed_event': openSse(response) - writeSse(record, response, { choices: [null] }) + writeSse(record, response, { type: 'content_block_start', index: 0, content_block: null }) writeDone(record, response) response.end() finishRecord(options, record, 'completed') return case 'wrong_content_type': openSse(response, 'application/json') - await completeText(options, record, response, 'stop', 0) + startMessage(record, response) + await completeText(options, record, response, 'end_turn', 0) return case 'rate_limit': httpError(options, record, response, 429, 'mock rate limit', 'rate_limit') @@ -558,32 +564,37 @@ async function runBehavior( return case 'success': openSse(response) - await completeText(options, record, response, 'stop', 0) + startMessage(record, response) + await completeText(options, record, response, 'end_turn', 0) return case 'reasoning_success': openSse(response) + startMessage(record, response) + writeSse(record, response, { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } }) for (const chunk of splitText(options.reasoningText, options.chunkSize)) { - writeSse(record, response, { - choices: [{ index: 0, delta: { reasoning_content: chunk }, finish_reason: null }], - }) + writeSse(record, response, { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: chunk } }) } - await completeText(options, record, response, 'stop', 0) + writeSse(record, response, { type: 'content_block_stop', index: 0 }) + await completeText(options, record, response, 'end_turn', 0, 1) return case 'tool_call_success': openSse(response) + startMessage(record, response) for (const chunk of toolCallChunks(options)) writeSse(record, response, chunk) - writeSse(record, response, terminalChunk('tool_calls', 2)) + writeSse(record, response, terminalChunk('tool_use', 2)) writeDone(record, response) response.end() finishRecord(options, record, 'completed') return case 'max_tokens': openSse(response) - await completeText(options, record, response, 'length', 0) + startMessage(record, response) + await completeText(options, record, response, 'max_tokens', 0) return case 'slow_success': openSse(response) - await completeText(options, record, response, 'stop', options.chunkDelayMs) + startMessage(record, response) + await completeText(options, record, response, 'end_turn', options.chunkDelayMs) return } } @@ -615,8 +626,8 @@ function chooseRandomBehavior( } /** - * Start a local chat-completions server that consumes one configured behavior - * per accepted request. Only a `POST` path ending in `/chat/completions` consumes the script; + * Start a local Messages server that consumes one configured behavior + * per accepted request. Only a `POST` path ending in `/v1/messages` consumes the script; * invalid routes, methods, authorization, and JSON receive ordinary 4xx * responses. Closing the handle terminates stalled connections. * @@ -652,13 +663,13 @@ export async function startMockLlmServer(options: MockLlmServerOptions): Promise response.writeHead(405, { allow: 'POST' }).end() return } - if (!path.endsWith('/chat/completions')) { + if (!path.endsWith('/v1/messages')) { response.writeHead(404).end() return } - if (resolved.apiKey !== undefined && request.headers.authorization !== `Bearer ${resolved.apiKey}`) { + if (resolved.apiKey !== undefined && request.headers['x-api-key'] !== resolved.apiKey) { response.writeHead(401, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ error: { message: 'invalid mock bearer token', code: 'invalid_api_key' } })) + response.end(JSON.stringify({ error: { message: 'invalid mock API key', code: 'invalid_api_key' } })) return } diff --git a/packages/test-support/llm-mock-server/tests/server.spec.ts b/packages/test-support/llm-mock-server/tests/server.spec.ts index a32e5cc8b2..5d7bbfaba6 100644 --- a/packages/test-support/llm-mock-server/tests/server.spec.ts +++ b/packages/test-support/llm-mock-server/tests/server.spec.ts @@ -18,24 +18,24 @@ async function start( return server } -function chat( +function messages( server: MockLlmServer, options: { path?: string; key?: string; body?: string; signal?: AbortSignal } = {}, ): Promise { - return fetch(`${server.baseURL}${options.path ?? '/v1/chat/completions'}`, { + return fetch(`${server.baseURL}${options.path ?? '/v1/messages'}`, { method: 'POST', headers: { 'content-type': 'application/json', - ...options.key === undefined ? {} : { authorization: `Bearer ${options.key}` }, + ...options.key === undefined ? {} : { 'x-api-key': options.key }, }, body: options.body ?? JSON.stringify({ model: 'mock', messages: [], stream: true }), ...options.signal === undefined ? {} : { signal: options.signal }, }) } -function rawChat(server: MockLlmServer, chunks: readonly Buffer[]): Promise { +function rawMessages(server: MockLlmServer, chunks: readonly Buffer[]): Promise { return new Promise((resolve, reject) => { - const outgoing = request(`${server.baseURL}/v1/chat/completions`, { + const outgoing = request(`${server.baseURL}/v1/messages`, { method: 'POST', headers: { 'content-type': 'application/json' }, }, (response) => { @@ -59,22 +59,22 @@ describe('mock LLM server wire behaviors', () => { onEvent: (event) => { events.push(event) }, }) - const response = await chat(server, { key: 'mock-key' }) + const response = await messages(server, { key: 'mock-key' }) const body = await response.text() expect(response.status).toBe(200) expect(response.headers.get('content-type')).toContain('text/event-stream') - expect(body).toContain('"content":"rec"') - expect(body).toContain('"content":"ove"') - expect(body).toContain('"content":"red"') - expect(body).toContain('"finish_reason":"stop"') - expect(body).toContain('data: [DONE]') + expect(body).toContain('"text":"rec"') + expect(body).toContain('"text":"ove"') + expect(body).toContain('"text":"red"') + expect(body).toContain('"stop_reason":"end_turn"') + expect(body).toContain('"type":"message_stop"') expect(server.requests).toEqual([expect.objectContaining({ attempt: 1, behavior: 'success', - path: '/v1/chat/completions', + path: '/v1/messages', body: { model: 'mock', messages: [], stream: true }, - chunksSent: 5, + chunksSent: 8, outcome: 'completed', })]) expect(events).toEqual([ @@ -83,7 +83,7 @@ describe('mock LLM server wire behaviors', () => { attempt: 1, scriptBehavior: 'success', behavior: 'success', - path: '/v1/chat/completions', + path: '/v1/messages', }, { type: 'result', @@ -91,39 +91,39 @@ describe('mock LLM server wire behaviors', () => { scriptBehavior: 'success', behavior: 'success', outcome: 'completed', - chunksSent: 5, + chunksSent: 8, }, ]) }) - it('supports root paths and intentionally ignores telemetry observer failures', async () => { + it('supports gateway prefixes and intentionally ignores telemetry observer failures', async () => { const server = await start(['empty'], { onEvent() { throw new Error('observer failed') }, }) - const response = await chat(server, { path: '/chat/completions' }) + const response = await messages(server, { path: '/anthropic/v1/messages' }) expect(response.status).toBe(200) - expect(await response.text()).toContain('data: [DONE]') - expect(server.requests[0]).toMatchObject({ path: '/chat/completions', outcome: 'completed' }) + expect(await response.text()).toContain('"type":"message_stop"') + expect(server.requests[0]).toMatchObject({ path: '/anthropic/v1/messages', outcome: 'completed' }) }) it.each([ ['empty_body', 0, ''] as const, ['stream_eof', 1, '"role":"assistant"'] as const, - ['partial_eof', 1, 'discarded partial response'] as const, + ['partial_eof', 3, 'discarded partial response'] as const, ['malformed_json', 2, 'data: {not-json'] as const, - ['malformed_event', 2, '"choices":[null]'] as const, + ['malformed_event', 2, '"content_block":null'] as const, ])('serves %s without inventing a terminal completion', async (behavior, chunks, marker) => { const server = await start([behavior], { chunkSize: 100 }) - const response = await chat(server) + const response = await messages(server) const body = await response.text() expect(response.status).toBe(200) expect(body).toContain(marker) if (behavior !== 'malformed_json' && behavior !== 'malformed_event') { - expect(body).not.toContain('[DONE]') + expect(body).not.toContain('message_stop') } expect(server.requests[0]).toMatchObject({ behavior, chunksSent: chunks, outcome: 'completed' }) }) @@ -137,7 +137,7 @@ describe('mock LLM server wire behaviors', () => { let headersReceived = false await expect((async () => { - const response = await chat(server) + const response = await messages(server) headersReceived = true await response.text() })()).rejects.toThrow() @@ -145,7 +145,7 @@ describe('mock LLM server wire behaviors', () => { expect(headersReceived).toBe(receivesHeaders) expect(server.requests[0]).toMatchObject({ behavior, - chunksSent: behavior === 'partial_disconnect' ? 1 : 0, + chunksSent: behavior === 'partial_disconnect' ? 3 : 0, outcome: 'reset', }) }) @@ -153,7 +153,7 @@ describe('mock LLM server wire behaviors', () => { it('holds a stalled stream until the client aborts and server close remains idempotent', async () => { const server = await start(['stall']) const controller = new AbortController() - const response = await chat(server, { signal: controller.signal }) + const response = await messages(server, { signal: controller.signal }) expect(response.status).toBe(200) expect(server.requests[0]).toMatchObject({ behavior: 'stall', outcome: 'stalled' }) @@ -180,7 +180,7 @@ describe('mock LLM server wire behaviors', () => { }, }) const controller = new AbortController() - const response = await chat(server, { signal: controller.signal }) + const response = await messages(server, { signal: controller.signal }) controller.abort() await expect(response.text()).rejects.toThrow() await result.promise @@ -197,7 +197,7 @@ describe('mock LLM server wire behaviors', () => { const characterOffset = encoded.indexOf(Buffer.from('你')) expect(characterOffset).toBeGreaterThanOrEqual(0) - await rawChat(server, [ + await rawMessages(server, [ encoded.subarray(0, characterOffset + 1), encoded.subarray(characterOffset + 1), ]) @@ -209,7 +209,7 @@ describe('mock LLM server wire behaviors', () => { const server = await start(['success'], { host: '::1' }) expect(server.baseURL).toMatch(/^http:\/\/\[::1\]:\d+$/) - expect((await chat(server)).status).toBe(200) + expect((await messages(server)).status).toBe(200) }) it('emits reasoning, tool calls, max-token finishes, slow chunks, and a wrong content type', async () => { @@ -231,17 +231,17 @@ describe('mock LLM server wire behaviors', () => { const bodies: string[] = [] const contentTypes: Array = [] for (let index = 0; index < 5; index += 1) { - const response = await chat(server) + const response = await messages(server) contentTypes.push(response.headers.get('content-type')) bodies.push(await response.text()) } - expect(bodies[0]).toContain('"reasoning_content":"th"') + expect(bodies[0]).toContain('"thinking":"th"') expect(bodies[1]).toContain('"name":"lookup"') - expect(bodies[1]).toContain('"arguments":"{\\"id"') - expect(bodies[1]).toContain('"finish_reason":"tool_calls"') - expect(bodies[2]).toContain('"finish_reason":"length"') - expect(bodies[3]).toContain('"finish_reason":"stop"') + expect(bodies[1]).toContain('"partial_json":"{\\"id"') + expect(bodies[1]).toContain('"stop_reason":"tool_use"') + expect(bodies[2]).toContain('"stop_reason":"max_tokens"') + expect(bodies[3]).toContain('"stop_reason":"end_turn"') expect(contentTypes[4]).toBe('application/json') expect(server.requests).toHaveLength(5) expect(server.requests.every(record => record.outcome === 'completed')).toBe(true) @@ -257,7 +257,7 @@ describe('mock LLM server wire behaviors', () => { ['quota_exceeded', 429, 'insufficient_quota'] as const, ])('serves %s as a structured HTTP error', async (behavior, status, marker) => { const server = await start([behavior], { retryAfterMs: 1_001, requestId: 'mock-request-1' }) - const response = await chat(server) + const response = await messages(server) const body = await response.text() expect(response.status).toBe(status) @@ -270,15 +270,15 @@ describe('mock LLM server wire behaviors', () => { it('fails loud on script exhaustion and can explicitly repeat the final behavior', async () => { const exhausted = await start(['success'], { successText: 'once' }) - await (await chat(exhausted)).text() - const exhaustedResponse = await chat(exhausted) + await (await messages(exhausted)).text() + const exhaustedResponse = await messages(exhausted) expect(exhaustedResponse.status).toBe(500) expect(await exhaustedResponse.text()).toContain('mock script exhausted') expect(exhausted.requests.map(record => record.behavior)).toEqual(['success', 'script_exhausted']) const repeating = await start(['empty'], { repeatLast: true }) - await (await chat(repeating)).text() - await (await chat(repeating)).text() + await (await messages(repeating)).text() + await (await messages(repeating)).text() expect(repeating.requests.map(record => record.behavior)).toEqual(['empty', 'empty']) }) @@ -295,8 +295,8 @@ describe('mock LLM server wire behaviors', () => { running.push(first, second) for (let attempt = 0; attempt < 12; attempt += 1) { - await (await chat(first)).text() - await (await chat(second)).text() + await (await messages(first)).text() + await (await messages(second)).text() } const firstChoices = first.requests.map(record => record.behavior) @@ -307,12 +307,12 @@ describe('mock LLM server wire behaviors', () => { expect(first.requests.every(record => record.scriptBehavior === 'random')).toBe(true) }) - it('rejects invalid method, route, bearer token, and JSON without consuming the script', async () => { + it('rejects invalid method, route, API key, and JSON without consuming the script', async () => { const server = await start(['success'], { apiKey: 'expected' }) - const method = await fetch(`${server.baseURL}/v1/chat/completions`) - const route = await fetch(`${server.baseURL}/v1/other`, { method: 'POST', body: '{}' }) - const auth = await chat(server, { key: 'wrong' }) - const json = await chat(server, { key: 'expected', body: '{' }) + const method = await fetch(`${server.baseURL}/v1/messages`) + const route = await fetch(`${server.baseURL}/chat/completions`, { method: 'POST', body: '{}' }) + const auth = await messages(server, { key: 'wrong' }) + const json = await messages(server, { key: 'expected', body: '{' }) expect(method.status).toBe(405) expect(method.headers.get('allow')).toBe('POST') @@ -321,9 +321,9 @@ describe('mock LLM server wire behaviors', () => { expect(json.status).toBe(400) expect(server.requests).toHaveLength(0) - const emptyRequest = await fetch(`${server.baseURL}/v1/chat/completions`, { + const emptyRequest = await fetch(`${server.baseURL}/v1/messages`, { method: 'POST', - headers: { authorization: 'Bearer expected' }, + headers: { 'x-api-key': 'expected' }, }) expect(emptyRequest.status).toBe(200) expect(server.requests[0]?.behavior).toBe('success') diff --git a/python/sdk/tests/manual_sdk_agent_smoke.py b/python/sdk/tests/manual_sdk_agent_smoke.py index acadb495be..34c8f5b1f0 100644 --- a/python/sdk/tests/manual_sdk_agent_smoke.py +++ b/python/sdk/tests/manual_sdk_agent_smoke.py @@ -26,16 +26,22 @@ class MockCompletionHandler(BaseHTTPRequestHandler): body = self.rfile.read(length).decode("utf-8") self.requests.append({ "path": self.path, - "authorization": self.headers.get("authorization"), + "api_key": self.headers.get("x-api-key"), "body": json.loads(body), }) self.send_response(200) self.send_header("content-type", "text/event-stream") self.end_headers() - self.wfile.write(b'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n') - self.wfile.write(b'data: {"choices":[{"delta":{"content":"SDK runtime reached the configured HTTP model endpoint."}}]}\n\n') - self.wfile.write(b'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":7,"completion_tokens":9}}\n\n') - self.wfile.write(b"data: [DONE]\n\n") + events = [ + {"type": "message_start", "message": {"id": "sdk-smoke", "model": "sdk-smoke-model", "usage": {"input_tokens": 7, "output_tokens": 0}}}, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "SDK runtime reached the configured HTTP model endpoint."}}, + {"type": "content_block_stop", "index": 0}, + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 9}}, + {"type": "message_stop"}, + ] + for event in events: + self.wfile.write(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n".encode()) def log_message(self, _format: str, *_args: object) -> None: return @@ -46,7 +52,7 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None: session_root = dsh_home / "sessions" runtime_entry = repo_root / "apps/cli/src/bin.ts" server = ThreadingHTTPServer(("127.0.0.1", 0), MockCompletionHandler) - thread = threading.Thread(target=server.serve_forever, name="mock-openai-compatible-server", daemon=True) + thread = threading.Thread(target=server.serve_forever, name="mock-messages-server", daemon=True) thread.start() base_url = f"http://127.0.0.1:{server.server_address[1]}" @@ -86,7 +92,8 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None: assert len(MockCompletionHandler.requests) == 1 request = MockCompletionHandler.requests[0] print(json.dumps(request, ensure_ascii=False, indent=2)[:4000]) - assert request["authorization"] == "Bearer sdk-smoke-key" + assert request["path"] == "/v1/messages" + assert request["api_key"] == "sdk-smoke-key" assert request["body"]["model"] == "sdk-smoke-model" jsonl_files = sorted(session_root.rglob("*.jsonl.zstd")) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index f5ecfea159..04e9f9c404 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -1513,48 +1513,6 @@ "packages/llm/deepseek-llm-api-extensions/src/index.ts": { "1f519aabb00553384bc9c1c57064a1ed6fcd757032061e1b14e0718d91dd4817": 1 }, - "packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts": { - "598014dcfe77101781e3c6a02709097e3b0151a335e909c44719fac08fdea433": 1 - }, - "packages/llm/llm-deepseek/src/protocols/messages/adapter.ts": { - "598014dcfe77101781e3c6a02709097e3b0151a335e909c44719fac08fdea433": 1 - }, - "packages/llm/llm-deepseek/src/protocols/messages/replay.ts": { - "9ebdedf03d9d83739a3d01d1c579242aceca09ea5be1be17f96eacf29e07cdea": 1 - }, - "packages/llm/llm-deepseek/tests/adapter.spec.ts": { - "34a631902f39d3a40e1baabacdccb986c3a2205cf2a6c8c1880061b87f39609d": 1, - "3f65e71f59e11feed30a6ca5bf870cad688d0bd161f6d52fe263a7aaf01bdb19": 2, - "b744a3b1ea1021e620ae97ba95bab540b731d7df9634e06fa3830ea7d9e5ebac": 1, - "cf2695bf7b16a8d315df73d326e926645d03499422d39f6b97bc569dda04137e": 1, - "d09a59b309ad22650abede4b87a64dc6afaf18c08e4313635a22f0121c0a6a92": 1, - "eea7d67f2cd0210a9a2d81bce690d485033ea7f120396b8a8b7f2016ea7ae396": 1 - }, - "packages/llm/llm-deepseek/tests/loader-composition.spec.ts": { - "59c63f734e8bba862325f776e3fcbebd78a84cfb4161f8df41c216950d9bf351": 1 - }, - "packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts": { - "0dd149237441659d6dc629eb4e30914ac604236cbe023d90693e512b7e300a49": 1 - }, - "packages/llm/llm-deepseek/tests/messages/adapter.spec.ts": { - "ae6cc7d948e6e78f72fcd1764acabc731e7498c880b7d709710f8bfeee113ef8": 1, - "f1f72ca0a6b4cc00365db8c58c0477975b87b4293f0751815e1e971a7191c34d": 1 - }, - "packages/llm/llm-deepseek/tests/messages/files.spec.ts": { - "65121d4bf51b358c794748d5895b388149f6883d4ae88f6c16b39802813c223f": 1, - "eea7d67f2cd0210a9a2d81bce690d485033ea7f120396b8a8b7f2016ea7ae396": 1 - }, - "packages/llm/llm-deepseek/tests/messages/serialize.spec.ts": { - "02285954262e9d3346a33f080c4aeb89b7f6349014fd23d64e5aeb28a52f1ced": 1, - "8c7e2bcfcb534160c7df81de8490cfe4e9891e913c7c429a3f47f603d1129da3": 2, - "a394f4cff3c3d3755ffce930afc5cbee43f92de4ae2112a2325b64bdceb74ad2": 1, - "ab74fe25dbefba4c45ffa5bab2729b4bb372df0b946711e5d463686e4a0edc02": 2 - }, - "packages/llm/llm-deepseek/tests/serialize.spec.ts": { - "12712253c43410ab721190981c8b815d102f799ce6942bee3975b035c203dfa8": 1, - "98bef9260e144cee077af05112a07d3e9626caa43f06c66ad08f9c024f5469b1": 1, - "a394f4cff3c3d3755ffce930afc5cbee43f92de4ae2112a2325b64bdceb74ad2": 1 - }, "packages/llm/llm-pi-ai/src/catalog.ts": { "5b1064d0195802d24eaa5155c66eb381da6046a8ccd0cacc3d852336475da7ef": 1 }, diff --git a/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml b/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml index 93ac7431eb..23c2d736c9 100644 --- a/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml +++ b/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml @@ -44,4 +44,4 @@ - id: deepseek-v4-flash - id: deepseek-v4-pro - id: messages-input-history-snapshot - name: '../../../packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts' + name: '../../../packages/llm/llm-deepseek/tests/fixtures/input-history.ts' diff --git a/snapshots/session/deepseek-messages-input-history/cordis.yml b/snapshots/session/deepseek-messages-input-history/cordis.yml index d143031868..019b755c26 100644 --- a/snapshots/session/deepseek-messages-input-history/cordis.yml +++ b/snapshots/session/deepseek-messages-input-history/cordis.yml @@ -1,3 +1,3 @@ - insert: - id: messages-input-history-snapshot - name: '../../../packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts' + name: '../../../packages/llm/llm-deepseek/tests/fixtures/input-history.ts' diff --git a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml index 92bac46628..0ae3966b64 100644 --- a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml +++ b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml @@ -44,4 +44,4 @@ - id: deepseek-v4-flash - id: deepseek-v4-pro - id: messages-history-snapshot - name: '../../../packages/llm/llm-deepseek/tests/messages/fixtures/history.ts' + name: '../../../packages/llm/llm-deepseek/tests/fixtures/history.ts' diff --git a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml index 55b26d1e56..6a4ad74520 100644 --- a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml +++ b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml @@ -1,3 +1,3 @@ - insert: - id: messages-history-snapshot - name: '../../../packages/llm/llm-deepseek/tests/messages/fixtures/history.ts' + name: '../../../packages/llm/llm-deepseek/tests/fixtures/history.ts' diff --git a/snapshots/session/office-skills-no-renderer/cordis.yml b/snapshots/session/office-skills-no-renderer/cordis.yml index 4c963015fe..c4d3987921 100644 --- a/snapshots/session/office-skills-no-renderer/cordis.yml +++ b/snapshots/session/office-skills-no-renderer/cordis.yml @@ -6,7 +6,6 @@ - id: llm-deepseek config: - protocol: chat-completions thinking: enabled reasoningEffort: high maxTokens: 8192 From 2b29f31dc66f8fa31b83d4c65c3893e943a035d0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:41:42 +0800 Subject: [PATCH 136/284] test(llm): assert native Messages file metadata --- packages/llm/llm-deepseek/tests/adapter.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index f8971f33e7..6aafb2b04d 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -111,7 +111,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () expect(bodies.every(body => body.includes(`"file_id":"${uploads[0]}"`) && !body.includes('"type":"base64"'))).toBe(true) const fileId = Messages.DeepSeekFileId(uploads[0]!) const retrieved = await files.retrieve(fileId) - expect(retrieved).toMatchObject({ id: fileId, bytes: attachment.bytes, purpose: 'user_data' }) + expect(retrieved).toMatchObject({ id: fileId, bytes: attachment.bytes }) expect(retrieved.expiresAt).toBeUndefined() let page = await files.list({ limit: 1_000 }) const cursors = new Set() From 385a540d7e87f724342719df942e598f67126d74 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:22:28 +0800 Subject: [PATCH 137/284] Fix Messages-only consumer validation and document recovery --- ...-09-07-deepseek-messages-adapter.i18n.yaml | 4 ++-- .../2026-09-07-deepseek-messages-adapter.md | 4 ++-- ...2026-09-07-deepseek-messages-adapter.zh.md | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 4 +++- packages/llm/llm-deepseek/README.zh.md | 4 +++- packages/llm/llm-deepseek/src/index.ts | 1 - .../llm-deepseek/tests/dynamic-config.spec.ts | 22 +++++++++++++++++++ .../llm/llm-deepseek/tests/runtime.e2e.ts | 2 +- packages/llm/llm-pi-ai/tests/adapter.e2e.ts | 1 - .../test-support/llm-mock-server/src/index.ts | 2 +- 11 files changed, 37 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml index 59be879b5b..13e41b76da 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md -2026-09-07-deepseek-messages-adapter.md: e8c84066e2ede5bdaf9e490b4ac0c9562cc8a557 -2026-09-07-deepseek-messages-adapter.zh.md: bde7fea769fbc5b4c2e622067b89ff8f29e91fb4 +2026-09-07-deepseek-messages-adapter.md: 29f712e57c8048ddb0c05b5f56fd4793c52bec5f +2026-09-07-deepseek-messages-adapter.zh.md: 3cf60fc2101fb835423b74a6532e5edcc5bee911 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md index e8c84066e2..29f712e57c 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md @@ -6,7 +6,7 @@ English | [中文](2026-09-07-deepseek-messages-adapter.zh.md) ## Problem -Deployments expose DeepSeek through Anthropic Messages gateways as well as chat-completions. Messages represents thinking, signatures, tool calls, tool results, and cumulative usage differently. Translating only the endpoint or flattening assistant history loses information needed by subsequent tool turns. +Messages represents thinking, signatures, tool calls, tool results, and cumulative usage as native protocol fields. Translating only the endpoint or flattening assistant history loses information needed by subsequent tool turns. ## Decision @@ -24,7 +24,7 @@ System updates use the existing [route capability](2026-09-02-in-history-system- Web displays DeepSeek with an endpoint and credential reference. Without an endpoint override, resolution uses `https://api.deepseek.com/anthropic`. Overrides must support Messages. The adapter follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog advertises the capabilities declared by its entries. -The adapter uses the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accept captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings. +The adapter uses the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accepts captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md index bde7fea769..3cf60fc210 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -部署可以通过 Anthropic Messages 网关或 chat-completions 调用 DeepSeek。Messages 对思考、签名、工具调用、工具结果和累计用量的表示不同。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。 +Messages 以原生协议字段表示思考、签名、工具调用、工具结果和累计用量。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。 ## 决策 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index b77dcd559e..ac07d1a93c 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -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/llm/llm-deepseek/README.md -README.md: 696db921f266ea0a5187aa9b98cf316343b6c74c -README.zh.md: 8e152a93de50dea7fc62cd3638957e6b1a879d23 +README.md: e03b1024bf83fa39603c3c2413c3270df70b7bda +README.zh.md: 9f14e175d95ff0cac3e77c972a9bbc940c9d9e9f diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 696db921f2..e03b1024bf 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -104,6 +104,8 @@ When `ctx.deepseekLlmApiExtensions` is present, the adapter prepares its registe ### Failures and recovery +Configuration accepts Messages only and has no `protocol` field. If resolution reports `protocol is not configurable`, remove `protocol` from the `llm-deepseek` section in `/settings.yaml` and from any matching Cordis entry or overlay. Keep the intended `baseURL`, `apiKeyEnv`, and `models` fields. An invalid live section keeps the complete last good configuration; saving other fields in the Models card does not remove an unknown property. Edit the configuration file, then let settings reload or restart the profile; composition edits require a profile restart. + Successful Files responses must contain valid JSON. JSON decoding failures from upload, list, retrieve, and delete throw `INVALID_RESPONSE` with the operation and HTTP status in the message, the status in `LlmError.failure`, and the original parser error as `cause`. Body-read transport and cancellation errors retain their identity. Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection invalidates the named mappings (or every mapping used by the attempt) and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request with no key anywhere fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key. @@ -196,7 +198,7 @@ These limits define where the adapter stops and future work begins. They are cur - **Messages in-history system updates require a retained user or tool-result turn** — if all user input after an update is omitted and the preceding wire turn is assistant, serialization fails with `UNSUPPORTED_CONTENT` before the next assistant or at the end of the request. Text or an empty tool result can retain that turn. Moving the update to an earlier turn is not supported; the [input-history decision](../../../.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md) records the ordering constraint. - **Images are input-only durable attachments** — direct external URLs and assistant image output are not supported; DeepSeek input normally uses the Files API and uses inline base64 only for per-request recovery. - The default catalog advertises `deepseek-flash` and its text/image and in-history capabilities without probing gateway availability. Requests can fail with `INVALID_REQUEST` until the gateway enables the id. -- The [Messages system-update e2e checks](tests/adapter.e2e.ts) require `DEEPSEEK_IN_HISTORY_MODEL` to name a supported model, such as `deepseek-flash`, and run with `high` effort. They skip when that variable is unset or empty; ordinary `off` text checks remain enabled with credentials. Known instruction-following instability with thinking disabled makes these system-update checks unsuitable for `off`. +- Real API checks in [adapter.e2e.ts](tests/adapter.e2e.ts) and [runtime.e2e.ts](tests/runtime.e2e.ts) require `DEEPSEEK_API_KEY`. Set `DEEPSEEK_IN_HISTORY_MODEL` to a supported nonempty model id to run system-update checks: the adapter suite uses `high` effort, while the runtime suite compares cache reuse with thinking disabled and can be sensitive to instruction-following instability. The runtime image cases additionally require `DEEPSEEK_FLASH_E2E=1` or `DEEPSEEK_VISION_E2E=1`. ### Dev Note diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 8e152a93de..9f14e175d9 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -104,6 +104,8 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 ### 失败与恢复 +配置仅接受 Messages,不提供 `protocol` 字段。若解析报告 `protocol is not configurable`,请从 `/settings.yaml` 的 `llm-deepseek` 分节以及对应 Cordis 条目或 overlay 中删除 `protocol`,保留需要的 `baseURL`、`apiKeyEnv` 和 `models` 字段。无效的实时分节会继续使用完整的最后有效配置;在模型设置卡中保存其他字段不会移除未知属性。请编辑配置文件,等待设置重新加载或重启 profile;组装配置变更需要重启 profile。 + 成功的 Files 响应必须包含有效 JSON。上传、列举、获取和删除操作的 JSON 解码失败抛出 `INVALID_RESPONSE`,消息包含操作名称与 HTTP 状态,`LlmError.failure` 保留该状态,`cause` 保留原始解析错误。读取响应体时的传输和取消错误保留其原有身份。 非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会使点名映射(或该次尝试使用的全部映射)失效,并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。任何位置都没有密钥的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。 @@ -196,7 +198,7 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 - **Messages 历史内 system 更新需要保留用户或工具结果轮次**——若更新后的全部用户输入都被省略,且前一个协议轮次是 assistant,序列化会在下一个 assistant 之前或请求结束处以 `UNSUPPORTED_CONTENT` 失败。文本或空工具结果可以保留该轮次。不支持将更新移到更早的轮次;[输入历史决策](../../../.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md)记录了排序约束。 - **图片是仅用于输入的持久附件**——不支持直接外部 URL 与 assistant 图片输出;DeepSeek 输入通常使用 Files API,仅在单次请求恢复时使用内联 base64。 - 默认目录公布 `deepseek-flash` 及其文本、图片和历史内更新能力,不探测网关可用性。网关开放该 ID 前,请求可能以 `INVALID_REQUEST` 失败。 -- [Messages system 更新 e2e](tests/adapter.e2e.ts) 要求通过 `DEEPSEEK_IN_HISTORY_MODEL` 指定支持该能力的模型,例如 `deepseek-flash`,并使用 `high` 思考强度。该变量未设置或为空时跳过;普通 `off` 文本检查仍在有凭据时运行。关闭思考时已知的指令遵循不稳定,使这些 system 更新检查不适合使用 `off`。 +- [adapter.e2e.ts](tests/adapter.e2e.ts) 与 [runtime.e2e.ts](tests/runtime.e2e.ts) 中的真实 API 检查需要 `DEEPSEEK_API_KEY`。将 `DEEPSEEK_IN_HISTORY_MODEL` 设为受支持的非空模型 ID 可运行 system 更新检查:适配器套件使用 `high` 思考强度,运行时套件则在关闭思考时比较缓存复用,可能受到指令遵循不稳定的影响。运行时图片用例还需要 `DEEPSEEK_FLASH_E2E=1` 或 `DEEPSEEK_VISION_E2E=1`。 ### 开发备注 diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 579e977a9c..cc5cfdc52f 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -47,7 +47,6 @@ export type { DeepSeekFileId as DeepSeekFileIdType } from './file-id.ts' export { DeepSeekUploadIndex, deepSeekFileScope } from './upload-index.ts' export type { DeepSeekUploadRecord } from './upload-index.ts' export type { RequestDefaults } from './types.ts' -export type * from './wire-types.ts' export const name = 'llm-deepseek' export const inject = ['llm'] diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index a1f55b5113..9872b90f89 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -312,6 +312,28 @@ describe('request-level dynamic configuration', () => { expect(good.headers[0]?.['x-api-key']).toBe('good-key') }) + it.each(['messages', 'chat-completions'])('rejects a persisted protocol=%s section and recovers after removing the field', async (protocol) => { + const dir = await home() + const composition = await mockServer([{ kind: 'sse', events: textEvents }]) + const repaired = await mockServer([{ kind: 'sse', events: textEvents }]) + vi.stubEnv('DEEPSEEK_API_KEY', 'composition-key') + vi.stubEnv('REPAIRED_DEEPSEEK_KEY', 'repaired-key') + const section = { baseURL: repaired.url, apiKeyEnv: 'REPAIRED_DEEPSEEK_KEY' } + await writeFile(join(dir, 'settings.yaml'), JSON.stringify({ [NS]: { ...section, protocol } })) + const { ctx } = await boot(dir, { baseURL: composition.url }) + + await prompt(ctx) + expect(composition.requests).toHaveLength(1) + expect(composition.headers[0]?.['x-api-key']).toBe('composition-key') + expect(repaired.requests).toHaveLength(0) + + await ctx.settings.replace(NS, section) + await prompt(ctx) + expect(repaired.requests).toHaveLength(1) + expect(repaired.headers[0]?.['x-api-key']).toBe('repaired-key') + expect(ctx.settings.get(NS)).not.toHaveProperty('protocol') + }) + it('falls back to the composition entry when settings detach', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() diff --git a/packages/llm/llm-deepseek/tests/runtime.e2e.ts b/packages/llm/llm-deepseek/tests/runtime.e2e.ts index 62c727a719..ee49eb04e9 100644 --- a/packages/llm/llm-deepseek/tests/runtime.e2e.ts +++ b/packages/llm/llm-deepseek/tests/runtime.e2e.ts @@ -34,7 +34,7 @@ import { assemble, type AssembledResult } from './assemble.ts' * and a max-effort tool round trip with reasoning passback. The suite skips * entirely without $DEEPSEEK_API_KEY; the pre-release vision smoke additionally * requires $DEEPSEEK_VISION_E2E=1, and the Flash image/system-update smoke - * requires $DEEPSEEK_FLASH_E2E=1 (see vitest.e2e.config.ts). + * requires $DEEPSEEK_FLASH_E2E=1. */ const FLASH = 'deepseek-v4-flash' diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index de8cd86532..2787a1ab7d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -24,7 +24,6 @@ async function harness(_model: string, config: Partial = {} providers: { deepseek: { ...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY }, - baseURL: LlmDeepSeek.PUBLIC_BASE_URL, ...config, }, }, diff --git a/packages/test-support/llm-mock-server/src/index.ts b/packages/test-support/llm-mock-server/src/index.ts index 029548b1e8..9ddab9a2cc 100644 --- a/packages/test-support/llm-mock-server/src/index.ts +++ b/packages/test-support/llm-mock-server/src/index.ts @@ -628,7 +628,7 @@ function chooseRandomBehavior( /** * Start a local Messages server that consumes one configured behavior * per accepted request. Only a `POST` path ending in `/v1/messages` consumes the script; - * invalid routes, methods, authorization, and JSON receive ordinary 4xx + * invalid routes, methods, API keys, and JSON receive ordinary 4xx * responses. Closing the handle terminates stalled connections. * * @param options - listener, script, response content, timing, and telemetry options. From 9ab5c8e672173be7340f2370b0e85753e3820bf3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:30:03 +0800 Subject: [PATCH 138/284] Fix Messages real-API package resolution and usage assertions --- packages/llm/llm-deepseek/tests/adapter.e2e.ts | 4 +++- packages/llm/llm-deepseek/tests/runtime.e2e.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 6aafb2b04d..bffec7e6ce 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { createRequire } from 'node:module' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, LoggerLevel } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' @@ -151,7 +152,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () }) await ctx.loader.create({ name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' }) await ctx.loader.await() - const packageIdentity = JSON.parse(await readFile(new URL('../../../plugin-package-inventory-deepseek/package.json', import.meta.url), 'utf8')) as { name: string; version: string } + const packagePath = createRequire(import.meta.url).resolve('@deepseek-ai/dsh-plugin-package-inventory-deepseek/package.json') + const packageIdentity = JSON.parse(await readFile(packagePath, 'utf8')) as { name: string; version: string } const session = ctx.sessions.create(SessionId(`real-messages-extensions-${randomUUID()}`)) session.append('turn/start', { turn: 1 }) const fetchImpl = globalThis.fetch diff --git a/packages/llm/llm-deepseek/tests/runtime.e2e.ts b/packages/llm/llm-deepseek/tests/runtime.e2e.ts index ee49eb04e9..e2634f8d64 100644 --- a/packages/llm/llm-deepseek/tests/runtime.e2e.ts +++ b/packages/llm/llm-deepseek/tests/runtime.e2e.ts @@ -298,9 +298,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () maxTokens: 2000, }) expect(withThinking.finish.kind).toBe('stop') - expect(withThinking.message.content.some(block => block.type === 'reasoning')).toBe(true) + expect(withThinking.message.content.some(block => block.type === 'reasoning' && block.text.trim().length > 0)).toBe(true) expect(textOf(withThinking)).toContain('9.8') - expect(withThinking.usage?.reasoningTokens).toBeGreaterThan(0) + expect(withThinking.usage?.outputTokens).toBeGreaterThan(0) }) it( From 0959c5be45e21e30dc84123b457c935c6746a646 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:35:34 +0800 Subject: [PATCH 139/284] Align provider guides with Messages-only configuration --- .../implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml | 4 ++-- .../notes/implemented/testing/2026-06-19-real-api-e2e-ci.md | 2 +- .../implemented/testing/2026-06-19-real-api-e2e-ci.zh.md | 2 +- docs/user/develop/practice/llm-adapter.i18n.yaml | 4 ++-- docs/user/develop/practice/llm-adapter.md | 2 +- docs/user/develop/practice/llm-adapter.zh.md | 2 +- packages/client/ui-settings-models/README.i18n.yaml | 4 ++-- packages/client/ui-settings-models/README.md | 2 +- packages/client/ui-settings-models/README.zh.md | 2 +- packages/web/web-search-deepseek/README.i18n.yaml | 4 ++-- packages/web/web-search-deepseek/README.md | 2 +- packages/web/web-search-deepseek/README.zh.md | 2 +- 12 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml index e50f4af442..b55464949e 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml @@ -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 .agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md -2026-06-19-real-api-e2e-ci.md: b218d77f0817416b01459fbb70f5cacffe048ee8 -2026-06-19-real-api-e2e-ci.zh.md: 52c988dad00ddb3ee7f0cd2d23da9771f0900808 +2026-06-19-real-api-e2e-ci.md: bdb20b3d5413077a4ddf1c1d9293b3b808cd6c8c +2026-06-19-real-api-e2e-ci.zh.md: b95f7df62e0a57d4c58fc5c12a83905dd848c55e diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md index b218d77f08..bdb20b3d54 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -49,7 +49,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS - **Step-scoped secret.** `DEEPSEEK_API_KEY` is set in the `env:` of only the preflight and e2e steps, never job-level — so checkout/setup-node/install never see it. A compromised install-time lifecycle script in a dependency cannot read a secret that isn't in its environment. - **`permissions: contents: read`.** The job only reads the repo to run tests; it needs no write scopes (no PR comments, no status writes), so the `GITHUB_TOKEN` is dropped to least privilege. -- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint. +- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com/anthropic` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint. - **No secret echoed.** The preflight prints only `DEEPSEEK_API_KEY present.` — not the value or its length. ### Scope, runtime shape diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md index 52c988dad0..b95f7df62e 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md @@ -49,7 +49,7 @@ repo secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;映射到适配器和测试 - **步骤级 secret。** `DEEPSEEK_API_KEY` 仅在 preflight 和 e2e 步骤的 `env:` 中设置,从不在 job 级设置——因此 checkout/setup-node/install 永远看不到它。依赖中被入侵的安装时生命周期脚本无法读取不在其环境中的 secret。 - **`permissions: contents: read`。** job 仅读取仓库以运行测试;不需要写权限(无 PR 评论、无 status 写入),因此 `GITHUB_TOKEN` 降至最小权限。 -- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(如果存在,`vitest.e2e.config.ts` 会加载它)无法静默地将运行重定向到其他端点。 +- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com/anthropic`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(如果存在,`vitest.e2e.config.ts` 会加载它)无法静默地将运行重定向到其他端点。 - **不回显 secret。** preflight 仅打印 `DEEPSEEK_API_KEY present.`——不打印值或长度。 ### 范围与运行时形态 diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 452a429302..a77a7ed049 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -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 docs/user/develop/practice/llm-adapter.md -llm-adapter.md: 7e30847cdf265bca8f55aac40f73686101ec185d -llm-adapter.zh.md: 22a3c54f45088a41362c1a8e903d2441ed4fc93b +llm-adapter.md: 32e412b10ade376e88d23be4de8cb2de976706f0 +llm-adapter.zh.md: bfa9842ce583f0486aff6269ad78c1f465f43671 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 7e30847cdf..32e412b10a 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -146,7 +146,7 @@ The first argument lists provider routes handled by the adapter. `GenerateOption The repository contains complete implementations: -- `packages/llm/llm-deepseek/` — DeepSeek API adapter using the OpenAI-compatible format +- `packages/llm/llm-deepseek/` — DeepSeek adapter using the Messages API - `packages/llm/llm-pi-ai/` — Pi AI adapter using a different API format Compare the two shipped adapters to see the same harness contract implemented over different provider SDKs. diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index 22a3c54f45..bfa9842ce5 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -146,7 +146,7 @@ ctx.llm.registerAdapter(['my-provider'], adapter) 仓库中包含以下两个完整实现: -- `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式) +- `packages/llm/llm-deepseek/` — 使用 Messages API 的 DeepSeek 适配器 - `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式) 对比这两个已交付的适配器,可以看到同一套 harness 契约如何在不同提供方 SDK 之上实现。 diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index c3c33c6f90..a0ed3e4fbe 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -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/ui-settings-models/README.md -README.md: c1567814b5de39f9b8819fcc8848e36c0a6ff842 -README.zh.md: baa0f02e8274a7146f9eade36d3ad464ac944b27 +README.md: a47f9a6dfa1faa9257c9b726140838e4858d33bb +README.zh.md: 925f8e976559ef7e975fcd164406f8c42f0b0098 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index c1567814b5..a47f9a6dfa 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -37,7 +37,7 @@ The primary field on an editor card is a single **API key** input — the page n The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `settings.yaml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, and input types; unrelated model fields survive edits. -The DeepSeek card edits the shared `llm-deepseek` endpoint, credentials, and model catalog without a protocol selector. When Cordis YAML selects Messages, the public endpoint placeholder is `https://api.deepseek.com/anthropic`. Saving the card preserves protocol configuration. +The DeepSeek card edits the `llm-deepseek` endpoint, credentials, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`. Expand **Customized settings → Model options** to edit each model. Both provider families share the same row layout, labels, and icons: context window and max output tokens occupy two columns, and **Input types** occupies a separate row with **Text** and **Image** checkboxes. A row without an input declaration displays the installed model’s input types, then the provider default, then Text. Known pi-ai providers load their installed catalog without endpoint I/O; opening a row does not write an override. Explicit input selections take precedence, including text-only overrides of vision models. Checkbox edits save the selected types, with at least one type required. DeepSeek writes `inputModalities`; pi-ai writes `input`. Unchecking Image for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, because the adapter rejects those limits without image input. Clearing the input field in `settings.yaml` restores adapter inheritance; **Restore default models** resets the entire catalog override. Declare only input types the upstream model can actually process. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index baa0f02e82..925f8e9765 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" 收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。 -`llm-deepseek` 的 DeepSeek 卡片编辑共用的端点、凭据和模型目录,不提供协议选择器。Cordis YAML 选择 Messages 时,官方端点占位符为 `https://api.deepseek.com/anthropic`;保存卡片不会改写协议配置。 +`llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。 展开**自定义设置 → 模型选项**编辑模型。两个提供方家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供方默认值,最后回退为文本。已知 pi-ai 提供方会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `settings.yaml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。 diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index 713c6c7851..f0d546d923 100644 --- a/packages/web/web-search-deepseek/README.i18n.yaml +++ b/packages/web/web-search-deepseek/README.i18n.yaml @@ -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/web/web-search-deepseek/README.md -README.md: 1fa8153b55e40edc869d6d332e3c35a46de0cd1b -README.zh.md: a38c1e126a23033cd40ef468f98b35d7cc56d869 +README.md: 0617204fdddb9273f75bd9ad4028026c7ffc8060 +README.zh.md: b45b449f2d379f730a6a4f341e8359ed28aaeb0b diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index 1fa8153b55..0617204fdd 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -33,7 +33,7 @@ Choose this backend when a deployment wants DeepSeek's native server-side web se ### Minimal configuration -Load the web service and the provider; the key resolves from `ctx.credentials` when that service is mounted, otherwise from the process environment. The auxiliary search call has its own endpoint setting and uses the Anthropic-compatible base `https://api.deepseek.com/anthropic/v1`, with `/messages` appended. It reads `$DEEPSEEK_SEARCH_BASE_URL`, independently of the conversation adapter’s `$DEEPSEEK_BASE_URL` and protocol. +Load the web service and the provider; the key resolves from `ctx.credentials` when that service is mounted, otherwise from the process environment. The auxiliary search call has its own endpoint setting and uses the Anthropic-compatible base `https://api.deepseek.com/anthropic/v1`, with `/messages` appended. It reads `$DEEPSEEK_SEARCH_BASE_URL`, independently of the conversation adapter’s `$DEEPSEEK_BASE_URL`. ```yaml - name: '@deepseek-ai/dsh-web' diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index a38c1e126a..b45b449f2d 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 最小配置 -加载 web 服务与本提供方;密钥在已挂载 `ctx.credentials` 服务时从其解析,否则从进程环境解析。辅助搜索调用有独立的端点设置,使用 Anthropic 兼容基址 `https://api.deepseek.com/anthropic/v1`,并追加 `/messages`。它读取 `$DEEPSEEK_SEARCH_BASE_URL`,与会话适配器的 `$DEEPSEEK_BASE_URL` 和协议相互独立。 +加载 web 服务与本提供方;密钥在已挂载 `ctx.credentials` 服务时从其解析,否则从进程环境解析。辅助搜索调用有独立的端点设置,使用 Anthropic 兼容基址 `https://api.deepseek.com/anthropic/v1`,并追加 `/messages`。它读取 `$DEEPSEEK_SEARCH_BASE_URL`,与会话适配器的 `$DEEPSEEK_BASE_URL` 相互独立。 ```yaml - name: '@deepseek-ai/dsh-web' From ddf11a4f0e68ee643bb8ef150b4dfebdd64694f3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:33:40 +0800 Subject: [PATCH 140/284] fix: name Anthropic Messages in DeepSeek endpoint guidance --- .../expected/deepseek-messages-settings/cards.expected.md | 2 +- .../onboarding-deepseek-config/default-models.expected.md | 2 +- .../expected/onboarding-deepseek-config/models.expected.md | 2 +- packages/client/ui-settings-models/src/client/locales.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 7df4c7ce8a..0fd9f06075 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -21,7 +21,7 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic - - text: 请填写与当前连接配置兼容的 API 地址。 + - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 正在使用适配器默认模型 - textbox "模型 ID 1": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index be77e20835..b11ef1f610 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -21,7 +21,7 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic - - text: 请填写与当前连接配置兼容的 API 地址。 + - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 正在使用适配器默认模型 - textbox "模型 ID 1": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 9e344cc148..a6f4185486 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -21,7 +21,7 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic - - text: 请填写与当前连接配置兼容的 API 地址。 + - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 64cb2c73f8..81f1c011c9 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -36,7 +36,7 @@ export const en = { baseUrl: 'Base URL', baseUrlDefault: 'Provider default', deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', - deepSeekEndpointHint: 'Use an endpoint compatible with the configured connection.', + deepSeekEndpointHint: 'Use an API endpoint compatible with Anthropic Messages.', models: 'Models', modelsInherited: 'Using the adapter defaults', modelsCustomized: 'Customized model catalog', @@ -147,7 +147,7 @@ export const zh: { [Key in keyof typeof en]: string } = { baseUrl: 'API 地址', baseUrlDefault: '提供方默认', deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', - deepSeekEndpointHint: '请填写与当前连接配置兼容的 API 地址。', + deepSeekEndpointHint: '请填写兼容 Anthropic Messages 协议的 API 地址。', models: '模型目录', modelsInherited: '正在使用适配器默认模型', modelsCustomized: '已自定义模型目录', From e3f0d3fb6e2a4acb12f7904f2e5267e2f4630abe Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Wed, 16 Sep 2026 16:28:28 +0800 Subject: [PATCH 141/284] fix(web): preserve session engagement across blank list refreshes --- ...ession-scope-and-provide-channel.i18n.yaml | 4 +- ...lient-session-scope-and-provide-channel.md | 19 +- ...nt-session-scope-and-provide-channel.zh.md | 19 +- ...ent-session-blank-reconciliation.i18n.yaml | 6 + ...-15-client-session-blank-reconciliation.md | 43 +++ ...-client-session-blank-reconciliation.zh.md | 43 +++ apps/web/tests/replay-round-trip.e2e.ts | 140 +++++-- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + .../src/client/sessions/lineage.ts | 5 +- .../src/client/sessions/manager.ts | 59 ++- .../src/client/sessions/service.ts | 3 +- .../src/client/sessions/session.ts | 34 +- .../tests/manager.client.spec.ts | 341 +++++++++++++++++- .../tests/session.client.spec.ts | 24 ++ .../accepted-reconnect.expected.md | 6 + 17 files changed, 675 insertions(+), 79 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md create mode 100644 snapshots/web/fresh-round-trip/accepted-reconnect.expected.md diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index e891710dea..51b99eb8e4 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 990a9b6eef862a7ac4a549358beae7f9081eaa0b -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 5c90611fab36611766bb407138c4f0c8a6b3a01b +2026-07-25-web-client-session-scope-and-provide-channel.md: 32be87f3a0a4f8da92100ec7351af788862532f7 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: b2e7efae98efb69d5f34d6719a382221ffc7a00d diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 990a9b6eef..32be87f3a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -28,7 +28,7 @@ Host-side `session.create(workspaceId)` produces Session + Agent + cwd in one pi - Session identity is the host's true form from birth: the sessionId arrives via the `session.create` response / the `host/session-added` frame, and every client-side address (the scope tag, slot store keys, RPC addressing) uses that same id. - The materialization moment = the instant the user picks a Workspace (cwd settled): the client calls `session.create({workspaceId})` on the spot and receives the complete entity. - "New Session with no workspace picked" is a **pure view state** (a navigation position) corresponding to no session/scope entity; until the pick, the composer is locked whole (no slash, no plain text). -- A "blank session" is just an ordinary materialized session whose log is still empty; to every Agent-scope plugin on the host (goal/plan/skill/…) it is indistinguishable from any session, so slash/plan are all naturally live. +- A "blank session" is an ordinary materialized session with no turn yet; to Agent-scope plugins on the host (goal/plan/skill/…) it remains an ordinary Session, so slash/plan are naturally live. ### Agent scope: the actx is the sole session carrier in the client-side cordis world @@ -63,14 +63,14 @@ Session instances share the scope's lifecycle, while the catalog reports discove A session "materialized but with no first prompt" is governed by the summary-derived bit `blank` (a derived column, not a header field; SessionHeader stays immutable): -- The Host criterion is absence of `turn/start`; slash-command and configuration events do not clear `blank`. Live summaries use the Session projection. Cold summaries use cached metadata, leaving cache misses visible as unknown. Reuse acquires the writer under [blank Session acquisition](2026-09-17-process-local-blank-sessions.md). +- The Host criterion is absence of `turn/start`: the `sessionListMetadata.blank` projection starts true and only that event clears it, so slash-command, configuration, and creation-checkpoint events leave it true even after they reach disk — blank does not mean unpersisted. Missing metadata falls back to `session.seq === 0` for live Sessions and false for cold ones; cold summaries otherwise read cached metadata, leaving cache misses visible as unknown. Reuse acquires the writer under [blank Session acquisition](2026-09-17-process-local-blank-sessions.md). - The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors). -- The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals: - - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member. - - Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank; - - Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank. +- Client display also reflects accepted/running observations, whose retention the [blank rollback decision](../bug-fix/2026-09-15-client-session-blank-reconciliation.md) owns: + - The sender's own tab: the **successful response** to the first `prompt()` converts the current `New Session` row in place, adding no list row — acceptance is display memory, not proof that a turn or user message reached durable history. A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member. + - Other tabs: the `host/session-status (running:true)` frame converts it — a blank session never runs, so the first running necessarily means no longer blank; + - Reconnect alignment: a list pull updates Host summaries while the Manager keeps those observations, and a `sessionListMetadata` hint of existing history prevents re-blanking on top of that; an empty-history response cannot mark a converted session back to blank. - List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the row retained by the `mainView` source, with its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's main blank shows; the user-visible surface therefore holds at most one blank row globally. -- Blank Sessions can persist through creation checkpoints and command events. Browser reloads and Host restarts reuse eligible blanks after acquiring their writer; occupied blanks are skipped. Old logs remain on disk without automatic cleanup. +- Blank Sessions can persist through creation checkpoints and command events, and have no automatic garbage collection: browser reloads, Host restarts, and Workspace connections reuse eligible blanks after acquiring their writer and skip occupied candidates, while list-loading and multi-tab races can still create additional blank Sessions, whose old logs remain on disk. ### connectWorkspace: the sole entry point of New Session @@ -129,12 +129,13 @@ Blank Sessions retain the header's leading and corner slots so navigation contro | Components receiving wiring-callback bundles (two-layer inject→props pass-down) | The standard-kit channel lets components fetch their own; the public API converges to hooks + stable props | | Swapping the no-session Hero view for the entire session Conversation | Even with the outer layout unchanged, the Hero, picker, and composer subtrees would remount together, making the whole UI region jump | | Making InputBar itself `session-maybe` | The input state machine, keyboard command surface, and actions would all have to accept absent values; replacing only the disabled input body keeps optionality at the shell boundary | -| A dedicated conversion frame | `session-status(running:true)` semantically implies conversion (a blank session never runs); adding a frame buys zero information for one more wire type | +| A dedicated conversion frame | Existing acceptance and running signals provide the observations used by the display rule; a new frame is unnecessary for that rule | ## Consequences - Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter. - The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly. -- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse. +- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse. The accepted/running display memory that guards the conversion is Client-local. + - The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) routes activation to the Workspace picker during that interval. - Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 5c90611fab..b2e7efae98 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -28,7 +28,7 @@ host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(作 - 会话身份自出生即为 host 真身:sessionId 由 `session.create` 响应 / `host/session-added` 帧带来,client 侧一切寻址(scope tag、slot store 键、RPC 地址)用的都是同一个 id。 - 实体化时点 = 用户选定 Workspace(cwd 确定)的瞬间:client 当场调 `session.create({workspaceId})`,拿到完整实体。 - 「New Session 且未选 workspace」是**纯视图态**(一个导航位置),不对应任何会话/scope 实体;选定之前 composer 整体锁死(无 slash、无纯文本)。 -- 「空会话」就是一个日志还空着的普通实体化会话;对 host 上所有 Agent-scope 插件(goal/plan/skill(技能)/…)它与任何会话无异,slash/plan 天然全活。 +- 「空会话」是尚无轮次的普通实体化会话;对 host 上的 Agent-scope 插件(goal/plan/skill(技能)/…)而言,它仍是普通 Session,因此 slash/plan 自然可用。 ### Agent scope:actx 是 client 侧 cordis 世界的唯一会话载体 @@ -63,14 +63,14 @@ Session 实例与 scope 同生命周期;catalog 只报告可发现性,不持 「实体化但无首条提示词」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变): -- Host 判据是没有 `turn/start`;slash 命令与配置事件不会清除 `blank`。Live summary 使用 Session 投影。Cold summary 使用缓存元数据,缓存缺失时以未知状态保留在列表中。复用按[空白 Session 获取](2026-09-17-process-local-blank-sessions.zh.md)规则取得写锁。 +- Host 判据是没有 `turn/start`:`sessionListMetadata.blank` 投影初始为 true,且只有该事件会清除它,因此 slash 命令、配置事件与创建检查点即使已落盘也不会清除——blank 不代表未持久化。metadata 缺失时,live Session 回退到 `session.seq === 0`,cold Session 回退到 false;cold summary 其余情况读缓存元数据,缓存缺失时以未知状态保留在列表中。复用按[空白 Session 获取](2026-09-17-process-local-blank-sessions.zh.md)规则取得写锁。 - wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。 -- client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号: - - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 - - 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank; - - 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。 +- 客户端展示还受已观察到的受理/运行影响,其保留期限由 [blank 回退修复决策](../bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 拥有: + - 发送方本地:首次 `prompt()` 的**成功响应**把当前 `New Session` 行原地转正,不新增列表行——受理是展示记忆,不证明轮次或用户消息已写入持久历史。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 + - 其他端:`host/session-status (running:true)` 帧转正——blank 会话从不 running,首次 running 必然已非 blank; + - 重连对齐:列表拉取更新 Host 摘要,Manager 则保留这些观察;在此基础上,`sessionListMetadata` 提示已有历史也会阻止回退 blank,因此空历史响应不能把已转正的会话标回 blank。 - 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示由 `mainView` 来源持有的一行,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的主 blank 显示;因此用户可见面全局至多一条 blank 行。 -- 空白 Session 可以通过创建检查点与命令事件持久化。浏览器刷新与 Host 重启会在取得写锁后复用符合条件的空白会话,并跳过被占用的候选。旧日志保留在磁盘上,不自动清理。 +- blank Session 可以通过创建检查点与命令事件持久化,且没有自动垃圾回收:浏览器刷新、Host 重启与工作区连接会在取得写锁后复用符合条件的空白会话并跳过被占用的候选;但列表加载与多 tab 竞态仍可能创建额外的 blank Session,其旧日志保留在磁盘上。 ### connectWorkspace:New Session 的唯一入口 @@ -129,12 +129,13 @@ blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入 | 组件收 wiring 回调包(inject→props 两层下传) | 标准件通道让组件自取;公共 API 收敛为 hooks + 稳定 props | | Hero 无会话视图与会话 Conversation 整支互换 | 即使外层 layout 不变,Hero、picker 与 composer 子树仍会一起重建,界面产生整块抖动 | | 让 InputBar 自身变成 `session-maybe` | 输入状态机、键盘命令面与动作都被迫接受缺省值;只替换 disabled 输入体能把可选性留在外壳边界 | -| 专用「转正」帧 | `session-status(running:true)` 语义蕴含转正(blank 会话从不 running),加帧是 wire 多一型换零信息 | +| 专用「转正」帧 | 既有受理和运行信号已经提供展示规则所需的观察;该规则不需要新增帧 | ## 后果 - 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。 - client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。 -- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。 +- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。转正所依赖的受理/运行展示记忆仅保留在客户端。 + - 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)会把激活操作转到 Workspace picker。 - 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml new file mode 100644 index 0000000000..6060e1c4b6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md +2026-09-15-client-session-blank-reconciliation.md: c9fe88f82f088f03539f8487740c83437bc53ade +2026-09-15-client-session-blank-reconciliation.zh.md: 70e144b552d1dabfecb428db04894ad4007011c5 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md new file mode 100644 index 0000000000..c9fe88f82f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md @@ -0,0 +1,43 @@ +# Agent Note: Keep accepted Session presentation across list refreshes + +Status: implemented + +English | [中文](2026-09-15-client-session-blank-reconciliation.zh.md) + +## Problem + +A successful prompt response converts the Client's `New Session` row before a turn necessarily starts. A later `session.list` response can still report `blank: true` and undo that conversion. Reconnect triggers the same refresh. Keeping the conversion only inside a Session object also loses it when that object is replaced. + +## Decision + +The [Manager](../../../../packages/api/session-controller/src/client/sessions/manager.ts) retains accepted/running observations in a private set keyed by Session id. List rows and the existing Session blank updates use `summary.blank && !engagedSessions.has(sessionId)`. The existing summary mutations, Session snapshot fields, constructor, and projection stores retain their responsibilities. + +Every successful prompt response invokes the existing `onEngaged` callback, including from a replaced Session object. The Manager updates its retained observation, the list row, and the currently resident Session by id. Rejection records no engagement. A `running: true` status, addition, or list baseline also records engagement; a baseline predating removal cannot restore that observation, even if the row is re-added during the pull. + +The observation survives refresh, reconnect, and object replacement. Removal, drop, and successful list refresh forget it only when no list row, Session object, or child address retains the identity. Refresh evaluates retention after replaying in-flight mutations; failed and superseded pulls do not prune observations. Late acceptance cannot recreate a removed row or a Session object. Manager disposal clears the set and prevents late acceptance and outstanding list responses from publishing. + +Engagement is display memory, not proof that a turn started, work remains queued, or a message reached durable storage. It is not persisted across page reload. Historical `sessionListMetadata.blank` and the Host summary defaults are unchanged. + +## Alternatives considered + +**Preserve every previously false summary bit.** A previous Host summary is not evidence of local acceptance or running. Making all false values permanent would also suppress later Host corrections without either observation. + +**Introduce a shared Session record.** Combining this memory with projection stores requires constructor and cache-lifetime changes. Those changes are independent of preventing a blank rollback and are excluded. + +**Wait for history before converting the row.** This changes the existing acceptance-time display rule and is a separate UI decision. + +## Consequences + +Accepted or observed-running Sessions retain their existing display conversion, including when accepted input never starts a turn. Drafts, submission-error recovery, sidebar filtering and title rules, creation/reuse, UUIDs, Host protocol, persistence, and projection-cache lifetimes are unchanged. Reverting this Client-only fix needs no data migration. + +The Manager owns one additional per-id display flag. It does not replace Session-local presentation fields or implement a history/draft/presentation redesign. + +For an identity with neither subagent origin nor a retained child address, a removal notification discards its projection store, while a retained Session object keeps the store it was constructed with — for example a current row removed while its Session is still staged. If that identity returns to the list before its Session object is dropped, the Manager creates and seeds a second store that the resident object never reads, so list rows and that object's projection readers can disagree until a replacement instance adopts the current store. Repairing that projection ownership is deferred to its own change; blank display does not depend on it. + +## Verification + +Manager tests cover refresh without a turn, running-to-idle updates, object replacement, late acceptance, removal through notifications or list reconciliation, retained instances and child addresses, failed and superseded pulls, and disposal. They check both list and Session state where an instance exists. Session tests retain first-send rejection and later-send behavior. Browser verification uses the recorded first-turn scenario with acceptance held before `turn/start` and a same-page reconnect. A timestamp substituted only in the reconnect list response provides a visible synchronization marker; the Host's blank/running values and durable log remain unchanged. + +## Related decisions + +The [scope and provisioning decision](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) owns Session scope, creation/reuse, and the visible blank row. The [Session/Conversation ownership decision](../architecture/2026-08-20-client-session-conversation-ownership.md) owns transport, assembly, and snapshot contents. diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md new file mode 100644 index 0000000000..70e144b552 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 在列表刷新后保留已受理会话的展示状态 + +Status: implemented + +[English](2026-09-15-client-session-blank-reconciliation.md) | 中文 + +## 问题 + +提示词请求成功后,客户端会把 `New Session` 行转换为普通会话,但此时轮次不一定已经开始。后续 `session.list` 响应仍可能返回 `blank: true`,从而撤销该转换。重连会触发同样的刷新。若只在 Session 对象内保留转换状态,对象被替换时也会丢失。 + +## 决策 + +[Manager](../../../../packages/api/session-controller/src/client/sessions/manager.ts) 使用按 Session id 索引的私有集合,保留已观察到的受理/运行。列表行与既有 Session blank 更新使用 `summary.blank && !engagedSessions.has(sessionId)`。既有摘要变更、Session 快照字段、构造函数和投影存储保留原有职责。 + +每个成功的提示词响应都会调用既有 `onEngaged` 回调,包括来自已被替换的 Session 对象的响应。Manager 按 id 更新其保留的观察、列表行和当前驻留 Session。拒绝不会记录受理/运行。`running: true` 状态、添加事件或列表基线也会记录运行;早于移除的基线不能恢复该观察,即使该行在拉取期间重新加入。 + +该观察跨刷新、重连和对象替换保留。移除、drop 和成功的列表刷新仅在列表行、Session 对象及子会话地址均不再保留该身份时清除它。刷新在重放请求期间的变更后判断保留资格;失败及被替代的请求不会清理观察。迟到的受理不能重建已移除的行或 Session 对象。Manager 销毁会清空集合,并阻止迟到的受理和未完成的列表响应发布。 + +受理/运行是展示记忆,不证明轮次已经开始、工作仍在排队,或消息已经写入持久存储。它不会跨页面重载持久化。历史 `sessionListMetadata.blank` 与 Host 摘要默认值保持不变。 + +## 考虑过的替代方案 + +**保留所有曾为 false 的摘要位。** 先前的 Host 摘要不能证明客户端观察过受理或运行。将所有 false 永久保留,也会在两种观察均不存在时阻止后续 Host 校正。 + +**引入共享 Session 记录。** 将这份记忆与投影存储合并,需要修改构造参数及缓存生命周期。这些改动独立于防止 blank 回退,因此不纳入。 + +**等待历史后再转换行。** 这会改变现有的受理时展示规则,属于独立的 UI 决策。 + +## 后果 + +已受理或观察过运行的 Session 保持既有展示转换,包括已受理输入始终没有开始轮次的情况。草稿、发送错误恢复、侧边栏筛选与标题规则、创建/复用、UUID、Host 协议、持久化及投影缓存生命周期保持不变。回退这项纯客户端修复不需要数据迁移。 + +Manager 额外维护一个按 id 保存的展示标记。它不替代 Session 本地展示字段,也不实施历史/草稿/展示状态重构。 + +对于来源不是 subagent、也没有保留子会话地址的身份,移除通知会丢弃它的投影存储,而仍被保留的 Session 对象继续持有构造时采用的存储,例如当前行被移除而它的 Session 仍留在舞台上。若该身份在其 Session 对象被销毁前回到列表,Manager 会新建并写入第二个存储,而常驻对象从不读取它,因此在替换实例采用当前存储之前,列表行与该对象的投影读取方可能不一致。该投影归属的修复延期到独立的改动;blank 展示不依赖它。 + +## 验证 + +Manager 测试覆盖无轮次刷新、运行转空闲、对象替换、迟到受理、通过通知或列表对账移除、保留实例与子会话地址、失败及被替代的请求,以及销毁。在存在实例的场景中,测试同时检查列表和 Session 状态。Session 测试保留首发拒绝及后续发送行为。浏览器验证使用已有首轮录制场景,在受理后、`turn/start` 前暂停并进行同页重连。测试仅替换重连列表响应中的时间戳,用其显示结果作为同步标记;Host 的 blank/running 值及持久日志保持不变。 + +## 相关决策 + +[作用域与供给决策](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md) 拥有 Session 作用域、创建/复用及可见 blank 行。[Session/Conversation 归属决策](../architecture/2026-08-20-client-session-conversation-ownership.zh.md) 拥有传输、装配及快照内容。 diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 8bddaee2d7..632d198e38 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -4,20 +4,22 @@ // or the live adapter (record). Drive steps run in every mode and wait only // on generic completion (whenTurnSettled — never model-content selectors, so // record cannot hang on a live model answering differently); assertion steps -// run in replay/refresh only. Settled states only — streaming fidelity is -// asserted from the durable embedded Assistant stream, not transient DOM. +// run in replay/refresh only. Streaming fidelity is asserted from the durable +// embedded Assistant stream, not transient DOM. // Record: DSH_SNAPSHOT=record writes session.v3.jsonl, then a keyless // DSH_SNAPSHOT=refresh regenerates ui.expected.md. import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' +import type { Browser, Page, Route, WebSocketRoute } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { ToolCallId, expandAssistantStream } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionListValue } from '@deepseek-ai/dsh-api-session-controller/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { - assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' @@ -29,6 +31,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-t const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui.expected.md', import.meta.url)) const ECHO_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/submission-echo.expected.md', import.meta.url)) +const ACCEPTED_RECONNECT_EXPECTED = join(SNAPSHOT_DIR, 'accepted-reconnect.expected.md') const BLANK_EXPECTED = join(SNAPSHOT_DIR, 'blank-reload.expected.md') const UI_EXPANDED_EXPECTED = fileURLToPath( new URL('../../../snapshots/web/fresh-round-trip/ui-expanded.expected.md', import.meta.url), @@ -41,6 +44,8 @@ const MODE = webSnapshotMode() // drift apart. const PROMPT = 'Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop.' +type SessionListResponse = { result: RemoteResult } + /** Rendered text of the system prompt surface node, or undefined when the surface carries none. */ function systemPromptText(session: Session): string | undefined { const message = session.deriveMessages().find(candidate => candidate.role === 'system') @@ -53,7 +58,9 @@ describe('web e2e: fresh round trip through the real assembly', () => { let page: Page let tripwire: ReturnType let settledSessionId: SessionId | undefined + let remoteSocket: WebSocketRoute | undefined const sessionEvents: SessionEvent[] = [] + const browserNow = Date.now() beforeAll(async () => { scaffold = await launchWebScaffold({ @@ -63,6 +70,11 @@ describe('web e2e: fresh round trip through the real assembly', () => { scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) + await page.clock.setFixedTime(browserNow) + await page.routeWebSocket('**/api/remote.mux', (route) => { + remoteSocket = route + route.connectToServer() + }) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -94,26 +106,103 @@ describe('web e2e: fresh round trip through the real assembly', () => { } const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) - // Arm the host-side settled barrier BEFORE the send click. - const settled = scaffold.whenTurnSettled() - await input.fill(PROMPT) - const echoSnapshot = await input.evaluate(async (element, prompt) => { - element.dispatchEvent(new KeyboardEvent('keydown', { - key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, - })) - // sendSession registered its paint yield first. This frame observes the - // committed echo while admission remains queued on the following task. - await new Promise((resolve) => { requestAnimationFrame(() => { resolve() }) }) - const echo = document.querySelector('[data-submission-echo]') - return [ - `echo: ${echo?.textContent?.includes(prompt) === true ? prompt : '(missing)'}`, - `composer: ${JSON.stringify(element.textContent ?? '')}`, - `contenteditable: ${element.getAttribute('contenteditable')}`, - ].join('\n') - }, PROMPT) - await compareOrRefreshGolden(ECHO_EXPECTED, echoSnapshot, MODE) - const sessionId = await settled - settledSessionId = sessionId + const sessionId = await page.evaluate(() => { + const selected = JSON.parse(localStorage.getItem('dsh.sessions.current')!) as { sessionId: string } + return selected.sessionId + }) as SessionId + const agent = scaffold.ctx.agents.get(sessionId) + if (agent === undefined) throw new Error('the selected Web Session has no live Agent') + await agent.whenIdle() + const release = Promise.withResolvers() + // Maintenance leaves status idle and accepts inbox input without starting a turn. + const maintenance = agent.runMaintenance(() => release.promise) + let accepted = false + try { + await input.fill(PROMPT) + const admitted = page.waitForResponse('**/api/session/prompt') + const [echoSnapshot, response] = await Promise.all([ + input.evaluate(async (element, prompt) => { + element.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, + })) + // sendSession registered its paint yield first. This frame observes the + // committed echo while admission remains queued on the following task. + await new Promise((resolve) => { requestAnimationFrame(() => { resolve() }) }) + const echo = document.querySelector('[data-submission-echo]') + return [ + `echo: ${echo?.textContent?.includes(prompt) === true ? prompt : '(missing)'}`, + `composer: ${JSON.stringify(element.textContent ?? '')}`, + `contenteditable: ${element.getAttribute('contenteditable')}`, + ].join('\n') + }, PROMPT), + admitted, + ]) + expect(await response.json()).toMatchObject({ result: { ok: true, value: { accepted: true } } }) + accepted = true + await compareOrRefreshGolden(ECHO_EXPECTED, echoSnapshot, MODE) + const row = page.getByRole('tree', { name: 'Sessions', exact: true }) + .locator('[role="treeitem"][aria-selected="true"]') + await expect.poll(() => row.getAttribute('draggable')).toBe('true') + expect(sessionEvents.some(event => event.type === 'turn/start')).toBe(false) + + const disconnected = remoteSocket + if (disconnected === undefined) throw new Error('the Web Remote socket never connected') + const warningStart = tripwire.warnings.length + // Only this list response carries the 2d timestamp. Its rendered label + // confirms the refresh reached the row, without altering blank/running. + const listUrl = new URL('/api/session/list', scaffold.baseUrl).href + const markListResponse = async (route: Route): Promise => { + const response = await route.fetch() + try { + const body = await response.json() as SessionListResponse + if (body.result.ok) { + body.result = { ...body.result, value: { + ...body.result.value, + items: body.result.value.items.map(item => item.sessionId === sessionId + ? { ...item, updatedAt: browserNow - 2 * 86_400_000 } + : item), + } } + } + await route.fulfill({ response, json: body }) + } finally { + await response.dispose() + } + } + await page.route(listUrl, markListResponse, { times: 1 }) + try { + const refreshing = page.waitForResponse(listUrl) + const [, refreshed] = await Promise.all([disconnected.close(), refreshing]) + await expect.poll(() => remoteSocket !== disconnected).toBe(true) + const baseline = await refreshed.json() as SessionListResponse + expect(baseline.result.ok).toBe(true) + if (!baseline.result.ok) throw new Error('the reconnect list request failed') + const hostSummary = baseline.result.value.items.find(item => item.sessionId === sessionId) + expect(hostSummary).toMatchObject({ blank: true, running: false }) + await row.getByText('2d', { exact: true }).waitFor() + expect(await row.getAttribute('draggable')).toBe('true') + expect(await row.getByText('New Session', { exact: true }).count()).toBe(0) + expect(sessionEvents.some(event => event.type === 'turn/start')).toBe(false) + const checkpoint = [ + `selected sidebar rows: ${await row.count()}`, + `new-session labels: ${await row.getByText('New Session', { exact: true }).count()}`, + `row draggable: ${await row.getAttribute('draggable')}`, + `host history blank: ${hostSummary?.blank}`, + `composer: ${JSON.stringify(await input.textContent())}`, + `contenteditable: ${await input.getAttribute('contenteditable')}`, + ].join('\n') + await compareOrRefreshGolden(ACCEPTED_RECONNECT_EXPECTED, checkpoint, MODE) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + } finally { + await page.unroute(listUrl, markListResponse) + } + } finally { + // No turn can settle before maintenance releases the accepted input. + const settled = accepted ? scaffold.whenTurnSettled() : undefined + release.resolve(undefined) + await maintenance + if (settled !== undefined) settledSessionId = await settled + await agent.whenIdle() + } if (MODE === 'record') { await recordFixture(scaffold, sessionId, FIXTURE) } @@ -237,12 +326,13 @@ describe('web e2e: fresh round trip through the real assembly', () => { await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false') }) - it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => { + it.skipIf(MODE === 'record')('stayed clean after the intentional reconnect: no pageerrors or server errors', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'blank-reload.expected.md', 'session.v3.jsonl', + 'accepted-reconnect.expected.md', 'submission-echo.expected.md', 'system-prompt.expected.md', 'tool-schemas.expected.json', diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index f0c39d8dbb..bab878d24d 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: c9f63d462c6e291abda7312c66c8fffcd7109c57 -README.zh.md: f6d03b45b6bf9d3e809e70154adacd59ce341721 +README.md: 4bbb98b0ee9224f72a571b10e8ae40d32377a747 +README.zh.md: 820dd4c3f56e964cf2b34cb50d5e5f38e35eff68 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index c9f63d462c..4bbb98b0ee 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -39,6 +39,8 @@ Client list refreshes retain unchanged row objects and reuse the items array whe Projection reads retain loading and failure state independently of their values. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes. +Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling. + The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces jobs and projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index f6d03b45b6..820dd4c3f5 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -39,6 +39,8 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时 投影读取独立于其值保留加载与失败状态。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。 +已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。 + Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的提示词失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。 diff --git a/packages/api/session-controller/src/client/sessions/lineage.ts b/packages/api/session-controller/src/client/sessions/lineage.ts index 56d06d3e43..b0b49311f3 100644 --- a/packages/api/session-controller/src/client/sessions/lineage.ts +++ b/packages/api/session-controller/src/client/sessions/lineage.ts @@ -19,7 +19,10 @@ export interface SessionListEntry { title?: string updatedAt: number running: boolean - /** Summary blankness reconciled with `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). */ + /** + * New Session presentation and reuse eligibility, reconciled with + * `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). + */ blank: boolean parentSessionId?: SessionId /** Coarse durable origin for navigation filtering; not a continuation capability. */ diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index d3564d763b..4a7d1d685d 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -87,6 +87,9 @@ export class SessionManager { private readonly sessions = new Map() /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */ private readonly sessionDisposals = new Set>() + /** Accepted/running presentation must survive a later empty-history list response. */ + private readonly engagedSessions = new Set() + private disposed = false /** Per-session projection value stores, retained independently of instance arrival (the * title-snapshot precedent, generalized): push frames land here whether or not the Session * is instantiated (list rows read the 'title' key), and an instantiated Session adopts the @@ -184,6 +187,7 @@ export class SessionManager { if (session !== expected) return Promise.resolve() this.sessions.delete(sessionId) this.addresses.delete(sessionId) + this.pruneEngagement(sessionId) return this.startSessionDisposal(session) } @@ -192,6 +196,10 @@ export class SessionManager { * @returns once catalog requests and every Session stream have stopped. */ async dispose(): Promise { + this.disposed = true + this.listMutations = null + this.listInflight = null + this.engagedSessions.clear() const reads = [...this.projectionInflight.values()] for (const { controller } of reads) controller.abort() this.projectionInflight.clear() @@ -235,7 +243,7 @@ export class SessionManager { // instance (consistency when the list precedes open). const summary = this.summaries.find(s => s.sessionId === sessionId) if (summary !== undefined) { - session.handleBlank(summary.blank) + session.handleBlank(this.effectiveBlank(summary)) session.handleRunning(summary.running) } else { const address = this.addresses.get(sessionId) @@ -268,12 +276,28 @@ export class SessionManager { // The sender's local first-send flip mirrors into the list row so the // session surfaces (lists filter on blank) before any host frame lands. onEngaged: (engaged) => { + if (this.disposed || !this.retainsSession(engaged.sessionId)) return + this.engagedSessions.add(engaged.sessionId) this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) + this.sessions.get(engaged.sessionId)?.handleBlank(false) }, projections: this.projectionStore(sessionId), }) } + private effectiveBlank(summary: SessionSummary): boolean { + return summary.blank && !this.engagedSessions.has(summary.sessionId) + } + + private retainsSession(sessionId: SessionId): boolean { + return this.sessions.has(sessionId) || this.addresses.has(sessionId) + || this.summaries.some(summary => summary.sessionId === sessionId) + } + + private pruneEngagement(sessionId: SessionId): void { + if (!this.retainsSession(sessionId)) this.engagedSessions.delete(sessionId) + } + /** Resident per-session projection store (create-on-demand; outlives instantiation). */ private projectionStore(sessionId: SessionId): ProjectionValueStore { let store = this.projectionStores.get(sessionId) @@ -367,15 +391,31 @@ export class SessionManager { const baseline: SessionSummary[] = this.listPhase === 'pending' ? [...result.value.items] : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId) - this.summaries = mutations.reduce(applyMutation, baseline) + // A removal supersedes running observed in the pull, including after re-addition. + const removedSincePull = new Set() + for (const mutation of mutations) { + if (mutation.kind === 'remove') removedSincePull.add(mutation.sessionId) + } + for (const s of baseline) { + if (s.running && !removedSincePull.has(s.sessionId)) this.engagedSessions.add(s.sessionId) + } + const summaries = mutations.reduce(applyMutation, baseline) + this.summaries = summaries + // A full list can remove identities without a removal frame. + const listedIds = new Set(summaries.map(summary => summary.sessionId)) + for (const sessionId of this.engagedSessions) { + if (!listedIds.has(sessionId) && !this.sessions.has(sessionId) && !this.addresses.has(sessionId)) { + this.engagedSessions.delete(sessionId) + } + } this.listState = 'idle' this.listPhase = 'ready' this.updateParentAvailability() - // Sessions reconcile list blank hints with their current metadata projection. + // Resident Sessions and list rows share one display blank, reconciled with the metadata projection. for (const s of this.summaries) { const session = this.sessions.get(s.sessionId) if (session === undefined) continue - session.handleBlank(s.blank) + session.handleBlank(this.effectiveBlank(s)) session.handleRunning(s.running) } // Land each row's projection block in the per-session value store @@ -524,6 +564,7 @@ export class SessionManager { /** Apply immediately and retain for replay when a list response is in flight. */ private recordMutation(mutation: SessionListMutation): void { + if (this.disposed) return this.listMutations?.push(mutation) this.summaries = applyMutation(this.summaries, mutation) this.notifier.markDirty() @@ -599,7 +640,8 @@ export class SessionManager { */ handleSessionAdded(summary: SessionSummary): void { this.mergeSummary(summary) - this.sessions.get(summary.sessionId)?.handleBlank(summary.blank) + if (!this.disposed && summary.running) this.engagedSessions.add(summary.sessionId) + this.sessions.get(summary.sessionId)?.handleBlank(this.effectiveBlank(summary)) if (summary.projections !== undefined) this.applyListBlock(summary.sessionId, summary.projections) } @@ -644,6 +686,7 @@ export class SessionManager { if (!durableSubagent && (catalog === undefined || catalog.length === 0)) { this.projectionStores.delete(sessionId) } + this.pruneEngagement(sessionId) this.projectionInflight.get(sessionId)?.controller.abort() this.projectionInflight.delete(sessionId) this.projectionLoads.delete(sessionId) @@ -660,6 +703,7 @@ export class SessionManager { * @param running - current Agent running state. */ handleSessionStatus(sessionId: SessionId, running: boolean): void { + if (!this.disposed && running) this.engagedSessions.add(sessionId) this.recordMutation({ kind: 'status', sessionId, running, agentAvailable: true }) this.updateParentAvailability() this.sessions.get(sessionId)?.handleRunning(running) @@ -716,7 +760,7 @@ export class SessionManager { return { ...summary, // Cached list hints can precede a history opening or control update. - blank: summary.blank && metadata?.blank !== false, + blank: this.effectiveBlank(summary) && metadata?.blank !== false, updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0), ...(typeof title === 'string' && title !== '' ? { title } : {}), ...(projectionValues === undefined ? {} : { projectionValues }), @@ -786,8 +830,7 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi case 'remove': return summaries.filter(summary => summary.sessionId !== mutation.sessionId) case 'status': - // running:true doubles as the cross-client blank flip (a blank session - // never runs, so the first running frame proves a message landed). + // Running converts display state; it does not establish durable history. return summaries.map(summary => summary.sessionId === mutation.sessionId && (summary.running !== mutation.running || summary.agentAvailable !== mutation.agentAvailable || (mutation.running && summary.blank)) diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 28b22c796a..2d5cea16f0 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -39,7 +39,8 @@ export interface SessionSummary { /** Local ownership counts; Host metadata refreshes cannot overwrite them. */ readonly retainedBy: SessionRetainInfo['retainedBy'] /** - * Blankness from the Host summary reconciled with `sessionListMetadata`. + * New Session presentation and reuse eligibility, derived from the Host + * summary, `sessionListMetadata`, and client acceptance/running observations. * New Session reuses a blank one targeting the same workspace. Filtering * stays with the consumer: the store carries every row, while the Workspace * browser shows only the selected blank entry. diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index fb1eb7a01d..08d935233c 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -66,12 +66,9 @@ export interface SessionOptions { /** Whether the exact direct parent Agent is available in Host summaries; absent until known. */ parentAvailable?: boolean /** - * First ACCEPTED prompt on a blank session (fires at most once, on the - * prompt RPC's success response): the manager mirrors the blank→false flip - * into its list row so the session surfaces without waiting for a host - * frame. Acceptance is the flip point because it proves the user message - * is in the host log; a rejected first prompt keeps the session blank - * (hidden, still reusable by connectWorkspace). + * Publish each accepted prompt to the Manager, including after this Session + * object is replaced. Acceptance converts display state, but does not + * establish that a turn started or reached durable history. */ onEngaged?(session: Session): void /** @@ -117,7 +114,7 @@ export class Session implements SessionFace { private promptAttempted = false /** A first accepted prompt stays in the engaging phase until its turn is observable. */ private firstPromptPendingTurn = false - /** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */ + /** New Session display state; unknown bare sessions begin conservatively blank. */ private blankBit = true private removed = false private promptError: PromptError | null = null @@ -291,19 +288,12 @@ export class Session implements SessionFace { this.notifier.markDirty() return result } - // Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the - // conversation's first turn on the host (the host criterion — a logged - // turn/start — is fact, not optimism; standalone command and projection - // events never flip it), while a rejected first prompt must keep the - // session blank — the client-side blank mirror only ever lowers, so - // flipping early on a failure would surface the session forever and - // strip its connectWorkspace reuse eligibility against the host's - // authority. + // Rejection must leave a first prompt blank and eligible for workspace reuse. if (this.blankBit) { this.blankBit = false - this.options.onEngaged?.(this) this.notifier.markDirty() } + this.options.onEngaged?.(this) return result } @@ -514,8 +504,7 @@ export class Session implements SessionFace { * @param running - the new running state. */ handleRunning(running: boolean): void { - // Turn-start conversion: a blank session never runs, so the first - // running:true proves another side's first message landed. + // Running converts display state without establishing durable turn history. if (running && this.blankBit) { this.blankBit = false this.notifier.markDirty() @@ -553,9 +542,12 @@ export class Session implements SessionFace { } /** - * Relay list blankness without overriding a started conversation established - * by the current projection, a local prompt, or running state. - * @param blank - whether the list or projection reports an unstarted conversation. + * Apply the Manager's effective display blank, further reconciled with the + * current `sessionListMetadata` projection. Local send attempts and current + * running state prevent re-blanking; an earlier false summary alone does not. + * The Manager retains acceptance and earlier running observations across + * Session-object replacement. + * @param blank - New Session display state after Manager reconciliation. */ handleBlank(blank: boolean): void { blank = blank && this.projections.values().sessionListMetadata?.blank !== false diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index b41ee673c8..aed5009fdd 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -3,8 +3,9 @@ * frame routing, and control baselines for uninstantiated sessions. */ -import { describe, expect, vi } from 'vitest' +import { describe, expect, onTestFinished, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import { SessionSeq } from '@deepseek-ai/dsh-session/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' @@ -48,6 +49,318 @@ function makeManager( } describe('SessionManager instances', () => { + it.for(['event', 'list'] as const)( + 'forgets running when an unretained identity disappears through a %s', + async (source, { mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] })) + await manager.refreshList() + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + + if (source === 'event') manager.handleSessionRemoved(S1) + else { + remote.session.list.mockResolvedValueOnce(ok({ items: [] })) + await manager.refreshList() + } + expect(manager.getListSnapshot().items).toEqual([]) + manager.handleSessionAdded(summary(S1, { blank: true })) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + }, + ) + + it('retains acceptance while an unlisted Session remains resident, then forgets it on drop', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const session = manager.get(S1) + await session.prompt([{ type: 'text', text: 'first' }], 'queue') + remote.session.list.mockResolvedValue(ok({ items: [] })) + + await manager.refreshList() + expect(manager.getListSnapshot().items).toEqual([]) + expect(session.getSnapshot().blank).toBe(false) + manager.handleSessionAdded(summary(S1, { blank: true })) + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + + await manager.refreshList() + await manager.drop(S1, session) + manager.handleSessionAdded(summary(S1, { blank: true })) + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + }) + + it('retains running for an unlisted child with only a retained address', async ({ mock, remote }) => { + const address: SubagentAddress = { parentSessionId: S1, childSessionId: S2, mode: 'continuable' } + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.resolveTarget(address) + manager.handleSessionAdded(summary(S2, { blank: true })) + manager.handleSessionStatus(S2, true) + manager.handleSessionStatus(S2, false) + remote.session.list.mockResolvedValueOnce(ok({ items: [] })) + + await manager.refreshList() + expect(manager.getListSnapshot().items).toEqual([]) + manager.handleSessionAdded(summary(S2, { blank: true })) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + }) + + it('retains running for a row added during a pull whose baseline omits it', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + const response = Promise.withResolvers>>() + remote.session.list.mockReturnValueOnce(response.promise) + const refreshing = manager.refreshList() + manager.handleSessionAdded(summary(S1, { blank: true })) + response.resolve(ok({ items: [] })) + + await refreshing + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + }) + + it.for(['result', 'transport'] as const)( + 'retains running observed before listing when the pull fails through %s', + async (source, { mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + const error = new RemoteError('gateway/internal', 'list unavailable', {}) + if (source === 'result') remote.session.list.mockResolvedValueOnce(err(error)) + else remote.session.list.mockRejectedValueOnce(error) + + await manager.refreshList() + expect(manager.getListSnapshot().state).toBe('error') + manager.handleSessionAdded(summary(S1, { blank: true })) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + }, + ) + + it('accepts a later blank baseline without an acceptance or running observation', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: false })) + const session = manager.get(S1) + expect(session.getSnapshot().blank).toBe(false) + remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] })) + + await manager.refreshList() + + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + expect(session.getSnapshot().blank).toBe(true) + }) + + it('retains acceptance received before the first list response', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + const response = Promise.withResolvers>>() + remote.session.list.mockReturnValueOnce(response.promise) + const refreshing = manager.refreshList() + const session = manager.get(S1) + await session.prompt([{ type: 'text', text: 'first' }], 'queue') + expect(manager.getListSnapshot().items).toEqual([]) + response.resolve(ok({ items: [summary(S1, { blank: true })] })) + + await refreshing + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(session.getSnapshot().blank).toBe(false) + }) + + it('keeps a rejected first prompt blank across refresh and object replacement', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + remote.session.prompt.mockResolvedValueOnce(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' }))) + const session = manager.get(S1) + expect((await session.prompt([{ type: 'text', text: 'first' }], 'queue')).ok).toBe(false) + remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] })) + await manager.refreshList() + await manager.drop(S1, session) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + expect(manager.get(S1).getSnapshot().blank).toBe(true) + }) + + it('ignores running from a list response that predates removal, including after re-addition', async ({ mock, remote }) => { + for (const readdBeforeResponse of [false, true]) { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const response = Promise.withResolvers>>() + remote.session.list.mockReturnValueOnce(response.promise) + const refreshing = manager.refreshList() + manager.handleSessionRemoved(S1) + if (readdBeforeResponse) manager.handleSessionAdded(summary(S1, { blank: true })) + + response.resolve(ok({ items: [summary(S1, { blank: true, running: true })] })) + await refreshing + if (!readdBeforeResponse) { + expect(manager.getListSnapshot().items).toEqual([]) + manager.handleSessionAdded(summary(S1, { blank: true })) + } + + expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: true, running: false }) + } + }) + + it('retains running observations after re-addition while an older list response is pending', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const response = Promise.withResolvers>>() + remote.session.list.mockReturnValue(response.promise) + const refreshing = manager.refreshList() + manager.handleSessionRemoved(S1) + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + + response.resolve(ok({ items: [summary(S1, { blank: true })] })) + await refreshing + + expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, running: false }) + }) + + it('applies late acceptance to a re-added list row without materializing its Session', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const acceptance = Promise.withResolvers>>() + remote.session.prompt.mockReturnValue(acceptance.promise) + const session = manager.get(S1) + const sending = session.prompt([{ type: 'text', text: 'first' }], 'queue') + manager.handleSessionRemoved(S1) + await manager.drop(S1, session) + manager.handleSessionAdded(summary(S1, { blank: true })) + const getSession = vi.spyOn(manager, 'get') + onTestFinished(() => { getSession.mockRestore() }) + acceptance.resolve(ok({ accepted: true })) + await sending + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(getSession).not.toHaveBeenCalled() + }) + + it('publishes a late acceptance to the replacement Session with the same id', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const acceptance = Promise.withResolvers>>() + remote.session.prompt.mockReturnValue(acceptance.promise) + const session = manager.get(S1) + const sending = session.prompt([{ type: 'text', text: 'first' }], 'queue') + await manager.drop(S1, session) + const replacement = manager.get(S1) + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + expect(replacement.getSnapshot().blank).toBe(true) + acceptance.resolve(ok({ accepted: true })) + await sending + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(replacement.getSnapshot().blank).toBe(false) + await manager.drop(S1, session) + expect(manager.get(S1)).toBe(replacement) + }) + + it('keeps accepted presentation when its Session is rebuilt', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const session = manager.get(S1) + await session.prompt([{ type: 'text', text: 'first' }], 'queue') + await manager.drop(S1, session) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(manager.get(S1).getSnapshot().blank).toBe(false) + }) + + it('publishes acceptance from a nonblank sender after its list row is replaced', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const sender = manager.get(S1) + await sender.prompt([{ type: 'text', text: 'first' }], 'queue') + const acceptance = Promise.withResolvers>>() + remote.session.prompt.mockReturnValueOnce(acceptance.promise) + const sending = sender.prompt([{ type: 'text', text: 'next' }], 'queue') + manager.handleSessionRemoved(S1) + await manager.drop(S1, sender) + manager.handleSessionAdded(summary(S1, { blank: true })) + const replacement = manager.get(S1) + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + expect(replacement.getSnapshot().blank).toBe(true) + + acceptance.resolve(ok({ accepted: true })) + await sending + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(replacement.getSnapshot().blank).toBe(false) + }) + + it('keeps observed running presentation across idle empty-history summaries', async ({ mock, remote }) => { + for (const source of ['addition', 'refresh'] as const) { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const session = manager.get(S1) + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + expect(session.getSnapshot().blank).toBe(false) + + if (source === 'addition') manager.handleSessionAdded(summary(S1, { blank: true })) + else { + remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] })) + await manager.refreshList() + } + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(session.getSnapshot().blank).toBe(false) + } + }) + + it('does not restore a removed row or publish after disposal when acceptance arrives late', async ({ mock, remote }) => { + for (const dispose of [false, true]) { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const acceptance = Promise.withResolvers>>() + remote.session.prompt.mockReturnValue(acceptance.promise) + const session = manager.get(S1) + const sending = session.prompt([{ type: 'text', text: 'first' }], 'queue') + manager.handleSessionRemoved(S1) + if (dispose) await manager.dispose() + else await manager.drop(S1, session) + const notify = vi.fn() + const unsubscribe = manager.subscribe(notify) + onTestFinished(unsubscribe) + acceptance.resolve(ok({ accepted: true })) + await sending + expect(manager.getListSnapshot().items).toEqual([]) + expect(notify).not.toHaveBeenCalled() + } + }) + + it('keeps accepted presentation across a later list refresh without a turn', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { blank: true })] as never[] })) + await manager.refreshList() + await manager.get(S1).prompt([{ type: 'text', text: 'first' }], 'queue') + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + + await manager.refreshList() + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + }) + it('lazily builds one resident instance per id and syncs the running bit from the list', async ({ mock, remote }) => { remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { running: true })] as never[] })) const manager = makeManager(mock, remote) @@ -876,6 +1189,32 @@ describe('remaining branches', () => { }) describe('connected generation', () => { + it('does not prune running observations from a superseded empty list response', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + const oldList = Promise.withResolvers>>() + const newList = Promise.withResolvers>>() + remote.session.list.mockReturnValueOnce(oldList.promise).mockReturnValueOnce(newList.promise) + const oldPull = manager.refreshList() + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + manager.handleConnected() + const newPull = manager.refreshList() + try { + oldList.resolve(ok({ items: [] })) + await oldPull + expect(manager.getListSnapshot().state).toBe('loading') + newList.resolve(ok({ items: [summary(S1, { blank: true })] })) + await newPull + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + } finally { + oldList.resolve(ok({ items: [] })) + newList.resolve(ok({ items: [] })) + await Promise.all([oldPull, newPull]) + } + }) + it.for(['old-first', 'new-first'] as const)( 'ignores a previous generation list response (%s)', async (order, { mock, remote }) => { diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index fd49737fb1..0c93b71af4 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -625,6 +625,30 @@ describe('prompt and cancel errors', () => { expect(session.getSnapshot()).toMatchObject({ running: true, awaitingFirstTurn: false }) }) + it('does not await a first turn when the history already contains one', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + session.handleBlank(false) + const inFlight = session.prompt([{ type: 'text', text: '继续' }], 'queue') + expect(session.getSnapshot()).toMatchObject({ blank: false, promptAttempted: true, awaitingFirstTurn: false }) + expect((await inFlight).ok).toBe(true) + expect(session.getSnapshot()).toMatchObject({ blank: false, awaitingFirstTurn: false }) + }) + + it('keeps an accepted first prompt converted when a later prompt is rejected', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + session.handleBlank(true) + expect((await session.prompt([{ type: 'text', text: '第一次' }], 'queue')).ok).toBe(true) + session.handleRunning(true) + session.handleRunning(false) + mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' }))) + + expect((await session.prompt([{ type: 'text', text: '第二次' }], 'queue')).ok).toBe(false) + expect(session.getSnapshot()).toMatchObject({ + blank: false, running: false, awaitingFirstTurn: false, + promptError: { op: 'send', error: { code: 'session/agent-busy' } }, + }) + }) + it('keeps the attempted-first-prompt state when the Host rejects the prompt', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) session.handleBlank(true) diff --git a/snapshots/web/fresh-round-trip/accepted-reconnect.expected.md b/snapshots/web/fresh-round-trip/accepted-reconnect.expected.md new file mode 100644 index 0000000000..dca0e7be05 --- /dev/null +++ b/snapshots/web/fresh-round-trip/accepted-reconnect.expected.md @@ -0,0 +1,6 @@ +selected sidebar rows: 1 +new-session labels: 0 +row draggable: true +host history blank: true +composer: "" +contenteditable: true From 7aa2d4b5cc3b16393d43feb40618f48a21582ca0 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Sun, 20 Sep 2026 12:44:55 +0800 Subject: [PATCH 142/284] docs(web): note the retained subagent catalog on session removal --- .../2026-09-15-client-session-blank-reconciliation.i18n.yaml | 4 ++-- .../bug-fix/2026-09-15-client-session-blank-reconciliation.md | 2 +- .../2026-09-15-client-session-blank-reconciliation.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml index 6060e1c4b6..a8864244b1 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md -2026-09-15-client-session-blank-reconciliation.md: c9fe88f82f088f03539f8487740c83437bc53ade -2026-09-15-client-session-blank-reconciliation.zh.md: 70e144b552d1dabfecb428db04894ad4007011c5 +2026-09-15-client-session-blank-reconciliation.md: 6303132ff61519c43383e44c1ee4ef6a6c74664d +2026-09-15-client-session-blank-reconciliation.zh.md: 51ddd2d4cc73f5b585a79f62690847242631414d diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md index c9fe88f82f..6303132ff6 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md @@ -32,7 +32,7 @@ Accepted or observed-running Sessions retain their existing display conversion, The Manager owns one additional per-id display flag. It does not replace Session-local presentation fields or implement a history/draft/presentation redesign. -For an identity with neither subagent origin nor a retained child address, a removal notification discards its projection store, while a retained Session object keeps the store it was constructed with — for example a current row removed while its Session is still staged. If that identity returns to the list before its Session object is dropped, the Manager creates and seeds a second store that the resident object never reads, so list rows and that object's projection readers can disagree until a replacement instance adopts the current store. Repairing that projection ownership is deferred to its own change; blank display does not depend on it. +For an identity with neither subagent origin nor a retained child address, a removal notification discards its projection store unless that store already carries a non-empty `subagentCatalog`; a retained Session object keeps the store it was constructed with — for example a current row removed while its Session is still staged. If that identity returns to the list before its Session object is dropped, the Manager creates and seeds a second store that the resident object never reads, so list rows and that object's projection readers can disagree until a replacement instance adopts the current store. Repairing that projection ownership is deferred to its own change; blank display does not depend on it. ## Verification diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md index 70e144b552..51ddd2d4cc 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md @@ -32,7 +32,7 @@ Status: implemented Manager 额外维护一个按 id 保存的展示标记。它不替代 Session 本地展示字段,也不实施历史/草稿/展示状态重构。 -对于来源不是 subagent、也没有保留子会话地址的身份,移除通知会丢弃它的投影存储,而仍被保留的 Session 对象继续持有构造时采用的存储,例如当前行被移除而它的 Session 仍留在舞台上。若该身份在其 Session 对象被销毁前回到列表,Manager 会新建并写入第二个存储,而常驻对象从不读取它,因此在替换实例采用当前存储之前,列表行与该对象的投影读取方可能不一致。该投影归属的修复延期到独立的改动;blank 展示不依赖它。 +对于来源不是 subagent、也没有保留子会话地址的身份,移除通知会丢弃它的投影存储——除非该存储已带有非空的 `subagentCatalog`;而仍被保留的 Session 对象继续持有构造时采用的存储,例如当前行被移除而它的 Session 仍留在舞台上。若该身份在其 Session 对象被销毁前回到列表,Manager 会新建并写入第二个存储,而常驻对象从不读取它,因此在替换实例采用当前存储之前,列表行与该对象的投影读取方可能不一致。该投影归属的修复延期到独立的改动;blank 展示不依赖它。 ## 验证 From ae58d210eb45f1abc4254704100f8e56fbc0afd4 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Sun, 20 Sep 2026 12:48:14 +0800 Subject: [PATCH 143/284] docs(web): scope the second projection store to a discarded store --- .../2026-09-15-client-session-blank-reconciliation.i18n.yaml | 4 ++-- .../bug-fix/2026-09-15-client-session-blank-reconciliation.md | 2 +- .../2026-09-15-client-session-blank-reconciliation.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml index a8864244b1..9e26577ff2 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md -2026-09-15-client-session-blank-reconciliation.md: 6303132ff61519c43383e44c1ee4ef6a6c74664d -2026-09-15-client-session-blank-reconciliation.zh.md: 51ddd2d4cc73f5b585a79f62690847242631414d +2026-09-15-client-session-blank-reconciliation.md: ef4734663783c8989173295119b680515be482f7 +2026-09-15-client-session-blank-reconciliation.zh.md: 1a8effe5a3a8caaf45f6941978590697fc563e76 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md index 6303132ff6..ef47346637 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md @@ -32,7 +32,7 @@ Accepted or observed-running Sessions retain their existing display conversion, The Manager owns one additional per-id display flag. It does not replace Session-local presentation fields or implement a history/draft/presentation redesign. -For an identity with neither subagent origin nor a retained child address, a removal notification discards its projection store unless that store already carries a non-empty `subagentCatalog`; a retained Session object keeps the store it was constructed with — for example a current row removed while its Session is still staged. If that identity returns to the list before its Session object is dropped, the Manager creates and seeds a second store that the resident object never reads, so list rows and that object's projection readers can disagree until a replacement instance adopts the current store. Repairing that projection ownership is deferred to its own change; blank display does not depend on it. +For an identity with neither subagent origin nor a retained child address, a removal notification discards its projection store unless that store already carries a non-empty `subagentCatalog`; a retained Session object keeps the store it was constructed with — for example a current row removed while its Session is still staged. If its projection store was discarded and that identity returns to the list before its Session object is dropped, the Manager creates and seeds a second store that the resident object never reads, so list rows and that object's projection readers can disagree until a replacement instance adopts the current store. Repairing that projection ownership is deferred to its own change; blank display does not depend on it. ## Verification diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md index 51ddd2d4cc..1a8effe5a3 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md @@ -32,7 +32,7 @@ Status: implemented Manager 额外维护一个按 id 保存的展示标记。它不替代 Session 本地展示字段,也不实施历史/草稿/展示状态重构。 -对于来源不是 subagent、也没有保留子会话地址的身份,移除通知会丢弃它的投影存储——除非该存储已带有非空的 `subagentCatalog`;而仍被保留的 Session 对象继续持有构造时采用的存储,例如当前行被移除而它的 Session 仍留在舞台上。若该身份在其 Session 对象被销毁前回到列表,Manager 会新建并写入第二个存储,而常驻对象从不读取它,因此在替换实例采用当前存储之前,列表行与该对象的投影读取方可能不一致。该投影归属的修复延期到独立的改动;blank 展示不依赖它。 +对于来源不是 subagent、也没有保留子会话地址的身份,移除通知会丢弃它的投影存储——除非该存储已带有非空的 `subagentCatalog`;而仍被保留的 Session 对象继续持有构造时采用的存储,例如当前行被移除而它的 Session 仍留在舞台上。若投影存储已被丢弃,且该身份在其 Session 对象被销毁前回到列表,Manager 会新建并写入第二个存储,而常驻对象从不读取它,因此在替换实例采用当前存储之前,列表行与该对象的投影读取方可能不一致。该投影归属的修复延期到独立的改动;blank 展示不依赖它。 ## 验证 From 4bff618b17c7c40e95a5223e11f7db91ca617951 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Sun, 20 Sep 2026 14:44:25 +0800 Subject: [PATCH 144/284] docs(web): note the metadata hint applied over the engagement rule --- .../2026-09-15-client-session-blank-reconciliation.i18n.yaml | 4 ++-- .../bug-fix/2026-09-15-client-session-blank-reconciliation.md | 2 +- .../2026-09-15-client-session-blank-reconciliation.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml index 9e26577ff2..154afe129e 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md -2026-09-15-client-session-blank-reconciliation.md: ef4734663783c8989173295119b680515be482f7 -2026-09-15-client-session-blank-reconciliation.zh.md: 1a8effe5a3a8caaf45f6941978590697fc563e76 +2026-09-15-client-session-blank-reconciliation.md: 7213636e32702c4c51887adba3da019f49b8703c +2026-09-15-client-session-blank-reconciliation.zh.md: 4b50340fea668b6d850c1d00787dda3874b61e0c diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md index ef47346637..7213636e32 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md @@ -10,7 +10,7 @@ A successful prompt response converts the Client's `New Session` row before a tu ## Decision -The [Manager](../../../../packages/api/session-controller/src/client/sessions/manager.ts) retains accepted/running observations in a private set keyed by Session id. List rows and the existing Session blank updates use `summary.blank && !engagedSessions.has(sessionId)`. The existing summary mutations, Session snapshot fields, constructor, and projection stores retain their responsibilities. +The [Manager](../../../../packages/api/session-controller/src/client/sessions/manager.ts) retains accepted/running observations in a private set keyed by Session id. List rows and the existing Session blank updates use `summary.blank && !engagedSessions.has(sessionId)`, with `sessionListMetadata` hints still applied on top. The existing summary mutations, Session snapshot fields, constructor, and projection stores retain their responsibilities. Every successful prompt response invokes the existing `onEngaged` callback, including from a replaced Session object. The Manager updates its retained observation, the list row, and the currently resident Session by id. Rejection records no engagement. A `running: true` status, addition, or list baseline also records engagement; a baseline predating removal cannot restore that observation, even if the row is re-added during the pull. diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md index 1a8effe5a3..4b50340fea 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -[Manager](../../../../packages/api/session-controller/src/client/sessions/manager.ts) 使用按 Session id 索引的私有集合,保留已观察到的受理/运行。列表行与既有 Session blank 更新使用 `summary.blank && !engagedSessions.has(sessionId)`。既有摘要变更、Session 快照字段、构造函数和投影存储保留原有职责。 +[Manager](../../../../packages/api/session-controller/src/client/sessions/manager.ts) 使用按 Session id 索引的私有集合,保留已观察到的受理/运行。列表行与既有 Session blank 更新使用 `summary.blank && !engagedSessions.has(sessionId)`,其上仍叠加 `sessionListMetadata` 提示。既有摘要变更、Session 快照字段、构造函数和投影存储保留原有职责。 每个成功的提示词响应都会调用既有 `onEngaged` 回调,包括来自已被替换的 Session 对象的响应。Manager 按 id 更新其保留的观察、列表行和当前驻留 Session。拒绝不会记录受理/运行。`running: true` 状态、添加事件或列表基线也会记录运行;早于移除的基线不能恢复该观察,即使该行在拉取期间重新加入。 From 8e5073ee617532d0e526118d94aa13cbbf4b8d15 Mon Sep 17 00:00:00 2001 From: winewill Date: Sun, 20 Sep 2026 14:44:36 +0800 Subject: [PATCH 145/284] docs(desktop): clarify test release ID reuse and qualification --- ...08-25-electron-desktop-packaging-and-updates.i18n.yaml | 4 ++-- .../2026-08-25-electron-desktop-packaging-and-updates.md | 2 ++ ...026-08-25-electron-desktop-packaging-and-updates.zh.md | 2 ++ apps/desktop/.env.macos.example | 2 +- apps/desktop/.env.windows.example | 2 +- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 8 +++++--- apps/desktop/README.zh.md | 8 +++++--- apps/desktop/tests/installed-update/README.i18n.yaml | 4 ++-- apps/desktop/tests/installed-update/README.md | 2 +- apps/desktop/tests/installed-update/README.zh.md | 2 +- 11 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index 6fa3c9e31b..6a9b597434 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: f58e8dc471faeb717dd79d32df4afc1b5cc3458f -2026-08-25-electron-desktop-packaging-and-updates.zh.md: 2d2d5c68f28974b606693d5b6727c864c92a0763 +2026-08-25-electron-desktop-packaging-and-updates.md: a985e1c91a1ccdb9c4aab0bc519974f9b6fafd4a +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 17ace865477d4fae3650b56d6725be07c7bd0e0f diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index f58e8dc471..a985e1c91a 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -75,6 +75,8 @@ The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-s `DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm package operation remain separate operations. +Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file. Their feeds and binaries share `dsh-desk//`; production retains its fixed directories. The ID is part of the packaged update URL, so upload rejects a different ID. Internal distribution can supply a new installer after rotation; installed clients retain their existing feed. Random directories reduce guessing but provide no authorization for link holders. The [release configuration](../../../../apps/desktop/README.md#upload-updates) defines generation and reuse. + ## Security and release policy Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation forwards package specs to pnpm, including local and remote sources, but never accepts raw pnpm commands. pnpm owns dependency resolution and the profile’s `allowBuilds` policy; the Host loads activated bundles. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 2d2d5c68f2..17ace86547 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -75,6 +75,8 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b `DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。 +测试发布必须从平台 dotenv 文件提供 `DOWNLOAD_TEST_RELEASE_ID`。清单和安装包共用 `dsh-desk//`,生产环境保留固定目录。ID 是打包更新 URL 的一部分,因此上传会拒绝不同的 ID。轮换后可通过内部分发提供新安装包,已安装客户端保留原清单地址。随机目录降低被猜中的概率,但不对持有链接者进行鉴权。[发布配置](../../../../apps/desktop/README.zh.md#upload-updates)定义生成与复用方式。 + ## 安全与发布策略 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。 diff --git a/apps/desktop/.env.macos.example b/apps/desktop/.env.macos.example index e4bfc40676..960e1195c5 100644 --- a/apps/desktop/.env.macos.example +++ b/apps/desktop/.env.macos.example @@ -4,7 +4,7 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= # Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. -# Generate once per release batch; keep the same value through packaging and upload retries. +# Generate once per release batch; retain it for retries and versions testing upgrades within that batch. DOWNLOAD_TEST_RELEASE_ID= # Workspace tarball workers; omit to use 4. Must be a positive integer. diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index 7a8e0dd16b..b1001c6477 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -4,7 +4,7 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= # Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. -# Generate once per release batch; keep the same value through packaging and upload retries. +# Generate once per release batch; retain it for retries and versions testing upgrades within that batch. DOWNLOAD_TEST_RELEASE_ID= # The selected mandatory-update origin is required, including unsigned and preparation builds. diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 480b62fd0f..b7937d5003 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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 apps/desktop/README.md -README.md: 410c0539caa129ede9a4055cd098da13e5f9371c -README.zh.md: e9367d57088447e580b9ef58c7e35c89d69d8d6e +README.md: cd559119d80efbc66988063a947878d43775d5c8 +README.zh.md: 48fc783501f1d44a7a0d62152be5baaaa83357e7 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 410c0539ca..cd559119d8 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -158,6 +158,8 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis The Windows installer extracts the new version beside the installation directory, stops the old application, and replaces directories through same-volume renames. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow. + + ### Upload updates Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/--*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result. @@ -173,13 +175,13 @@ The update destination and upload credentials follow the selected deployment: | `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` | | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` | -Generate a fresh ID for each test release batch with the command below and copy its output into `DOWNLOAD_TEST_RELEASE_ID` in `.env.macos` or `.env.windows`. These Git-ignored platform files own the value; shell variables do not override it, and dotenv values are not shell-expanded. Keep the ID through packaging, upload, and retries; upload rejects a completion record with a different update URL. Production does not use this field. +Generate a fresh ID for each test release batch with the command below and copy its output into `DOWNLOAD_TEST_RELEASE_ID` in `.env.macos` or `.env.windows`. These Git-ignored platform files own the value; shell variables do not override it, and dotenv values are not shell-expanded. Keep the ID through packaging, upload, and retries; upload rejects a completion record with a different update URL. To test an upgrade between versions, reuse the installed client’s ID for the later version. Production does not use this field. ```sh -node --input-type=module -e 'import { randomBytes } from "node:crypto"; console.log(randomBytes(16).toString("hex"))' +node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" ``` -Distribute each test batch through its complete download links. Installed test clients retain their batch feed and do not discover a new ID automatically. Random paths reduce guessing, not access by link holders; withdrawing a batch requires deleting its COS objects and purging its CDN directory. +Distribute each test batch through its complete download links. Installed test clients retain their batch feed and do not discover a new ID automatically. Format validation cannot establish randomness; use the generator output. Random paths reduce guessing, not access by link holders; withdrawing a batch requires deleting its COS objects and purging its CDN directory. Configure the update origin and selected COS bucket, SecretId, and SecretKey in the target dotenv file, then package and upload the same target: diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index e9367d5708..48fc783501 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -158,6 +158,8 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用并通过同卷目录改名完成替换。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 + + ### 上传更新 test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/--*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。 @@ -173,13 +175,13 @@ Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` | `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` | | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` | -每个测试发布批次用下方命令生成新 ID,将输出填入 `.env.macos` 或 `.env.windows` 的 `DOWNLOAD_TEST_RELEASE_ID`。这两个被 Git 忽略的平台文件管理该值,shell 变量不能覆盖它,dotenv 值也不会进行 shell 展开。打包、上传和重试必须沿用同一个 ID;上传会拒绝更新 URL 不一致的完成记录。生产环境不使用该字段。 +每个测试发布批次用下方命令生成新 ID,将输出填入 `.env.macos` 或 `.env.windows` 的 `DOWNLOAD_TEST_RELEASE_ID`。这两个被 Git 忽略的平台文件管理该值,shell 变量不能覆盖它,dotenv 值也不会进行 shell 展开。打包、上传和重试必须沿用同一个 ID;上传会拒绝更新 URL 不一致的完成记录。需要验证跨版本升级时,后续版本沿用已安装客户端的 ID。生产环境不使用该字段。 ```sh -node --input-type=module -e 'import { randomBytes } from "node:crypto"; console.log(randomBytes(16).toString("hex"))' +node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" ``` -通过完整下载链接分发每个测试批次。已安装的测试客户端保留当前批次的清单地址,不会自动发现新 ID。随机路径降低被猜中的概率,不限制持有链接者访问;撤下批次需要删除其 COS 对象并清除对应 CDN 目录缓存。 +通过完整下载链接分发每个测试批次。已安装的测试客户端保留当前批次的清单地址,不会自动发现新 ID。格式校验无法判断随机性,请使用生成器的输出。随机路径降低被猜中的概率,不限制持有链接者访问;撤下批次需要删除其 COS 对象并清除对应 CDN 目录缓存。 在目标 `.env` 中配置更新地址与所选 COS bucket、SecretId、SecretKey,再打包并上传同一个目标: diff --git a/apps/desktop/tests/installed-update/README.i18n.yaml b/apps/desktop/tests/installed-update/README.i18n.yaml index 8ea70c5cbc..036b9a3a62 100644 --- a/apps/desktop/tests/installed-update/README.i18n.yaml +++ b/apps/desktop/tests/installed-update/README.i18n.yaml @@ -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 apps/desktop/tests/installed-update/README.md -README.md: 139a80246d6539898cc870129fe55ad726b2673d -README.zh.md: 2b140cceb37ac104c1984c35322da0814ac365b7 +README.md: b847960862931a8c7ef769908d046cfb0f893d41 +README.zh.md: ac2bed4e2a0304f0362c36504128ff17fc5ba693 diff --git a/apps/desktop/tests/installed-update/README.md b/apps/desktop/tests/installed-update/README.md index 139a80246d..b847960862 100644 --- a/apps/desktop/tests/installed-update/README.md +++ b/apps/desktop/tests/installed-update/README.md @@ -44,7 +44,7 @@ Package both files from the generated `bootstrap` directory beside `lib/`, and s The `application` command copies built main/preload modules, renderer files, and the prepared bootstrap into `application/files`, with SHA-256 values in `application/result.json`. It does not copy the app's root `.env.windows` or freeze `node_modules` and build tools. The [qualification builder configuration](../../scripts/installed-update-builder.ts) verifies this inventory and the selected private runtime, selects the shared frozen files and isolated package metadata, and preserves ordinary installer and signing hooks. Its tests substitute the signer and pass the pinned builder's configuration validator; they do not produce an installer. A separately authorized supervised build must still record dependency/tool inputs and verify final package contents and signatures. -The [packaging entry](../../scripts/package-installed-update.ts) defaults to checks. A retained signing interlock rejects before credentials are loaded; it never clears that interlock. With no interlock, checks load `.env.windows`, validate prepared inputs, and launch no child. Run this from the repository root with one exact version: +The [packaging entry](../../scripts/package-installed-update.ts) defaults to checks. A retained signing interlock rejects before credentials are loaded; it never clears that interlock. With no interlock, checks load `.env.windows`, validate prepared inputs, and launch no child. The shared builder requires a valid `DOWNLOAD_TEST_RELEASE_ID` from the [test release settings](../../README.md#upload-updates); qualification still publishes through the manifest’s separate `qualification/` paths. Run this from the repository root with one exact version: ```powershell node --import tsx apps/desktop/scripts/package-installed-update.ts "" 0.1.6-alpha.1.20260916.1 --check diff --git a/apps/desktop/tests/installed-update/README.zh.md b/apps/desktop/tests/installed-update/README.zh.md index 2b140cceb3..ac2bed4e2a 100644 --- a/apps/desktop/tests/installed-update/README.zh.md +++ b/apps/desktop/tests/installed-update/README.zh.md @@ -44,7 +44,7 @@ node --import tsx apps/desktop/scripts/prepare-installed-update.ts application " `application` 命令将已构建的主程序/预加载模块、界面文件和准备好的启动入口复制到 `application/files`,并在 `application/result.json` 中记录 SHA-256。它不复制应用根目录的 `.env.windows`,也不冻结 `node_modules` 与构建工具。[验收打包配置](../../scripts/installed-update-builder.ts)校验该清单与选定的独立运行时,选用共享冻结文件和隔离包元数据,并保留常规安装器与签名钩子。其测试替换签名器,并通过固定版本构建器的配置校验;测试不生成安装器。另行授权的受监督构建仍须记录依赖/工具输入,并验证最终包内容及签名。 -[打包入口](../../scripts/package-installed-update.ts)默认只检查。保留的签名保护锁会在读取凭据前使其拒绝;入口绝不清除此锁。没有保护锁时,检查会加载 `.env.windows`、校验准备输入,不启动子进程。在仓库根目录使用一个准确版本执行: +[打包入口](../../scripts/package-installed-update.ts)默认只检查。保留的签名保护锁会在读取凭据前使其拒绝;入口绝不清除此锁。没有保护锁时,检查会加载 `.env.windows`、校验准备输入,不启动子进程。共享构建器要求[测试发布配置](../../README.zh.md#upload-updates)中有效的 `DOWNLOAD_TEST_RELEASE_ID`;资格验收仍使用清单中独立的 `qualification/` 路径发布。在仓库根目录使用一个准确版本执行: ```powershell node --import tsx apps/desktop/scripts/package-installed-update.ts "" 0.1.6-alpha.1.20260916.1 --check From 4f0ee6b1059806531608516bf27cca0cde65d406 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:42:39 +0800 Subject: [PATCH 146/284] fix(settings): defer developer features until host preference resolves --- ...6-09-17-developer-tools-settings.i18n.yaml | 4 +- .../2026-09-17-developer-tools-settings.md | 2 +- .../2026-09-17-developer-tools-settings.zh.md | 4 +- ...09-20-developer-tools-default-on.i18n.yaml | 6 +++ .../2026-09-20-developer-tools-default-on.md | 31 ++++++++++++ ...026-09-20-developer-tools-default-on.zh.md | 31 ++++++++++++ apps/web/tests/assembled-remote.ts | 20 ++++---- .../web/tests/developer-tools-settings.e2e.ts | 2 + apps/web/tests/document-preview.e2e.ts | 2 +- .../client/ui-agent-preset/README.i18n.yaml | 2 +- packages/client/ui-agent-preset/README.zh.md | 2 +- .../tests/section.client.spec.tsx | 29 ++++++----- .../src/client/view-selection.ts | 2 +- .../tests/apply-inject.client.spec.tsx | 22 ++++++++ .../tests/apply.client.spec.ts | 2 +- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 +- packages/client/ui-settings/README.zh.md | 2 +- .../ui-settings/src/client/developer-tools.ts | 4 +- .../tests/developer-tools.client.spec.ts | 50 +++++++++++++++++-- .../tests/html-body.client.spec.tsx | 28 +++++++++++ scripts/no-unknown-casts.baseline.json | 4 -- .../web/document-preview/document.expected.md | 2 +- 23 files changed, 208 insertions(+), 49 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.md create mode 100644 .agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.zh.md diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml index 0565db8d75..af257a325c 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md -2026-09-17-developer-tools-settings.md: 0745eb32cf5244d5a4ed8e70b84a7598232856c6 -2026-09-17-developer-tools-settings.zh.md: f177eef8c5c07891db1712b55f63d398276a0cf3 +2026-09-17-developer-tools-settings.md: 7819b2a2df3c9eaf2db1e1271d7b419768affaff +2026-09-17-developer-tools-settings.zh.md: 5ff9f2c1e2380f086969cc392bf649edd13e2893 diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md index 0745eb32cf..7819b2a2df 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md @@ -10,7 +10,7 @@ Diagnostic views, preset selection and changed-file summaries add complexity to ## Decision -The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference, defaulting on. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes. +The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference. The initial default-off choice reduces routine-task complexity; [Developer tools default on after preference resolution](2026-09-20-developer-tools-default-on.md) supersedes that default and owns startup enablement. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes. Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip and the settings row that allows switching Agent modes, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization. diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md index f177eef8c5..5ff9f2c1e2 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md @@ -10,9 +10,9 @@ Status: implemented ## Decision -设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认开启。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 +设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好。最初默认关闭的选择用于降低日常任务的复杂度;[偏好解析后默认开启开发者工具](2026-09-20-developer-tools-default-on.zh.md)取代该默认值并负责启动时的启用策略。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 -关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换 agent 模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 +关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换agent模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、`showPresetPicker` 和 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。 diff --git a/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.i18n.yaml new file mode 100644 index 0000000000..aa7199eff4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.md +2026-09-20-developer-tools-default-on.md: 42c89faed11b966ac8ea0882236af9bf13557702 +2026-09-20-developer-tools-default-on.zh.md: a684844f5ba53a0bebdd1c3a3a3ade1ed150cc31 diff --git a/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.md b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.md new file mode 100644 index 0000000000..42c89faed1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.md @@ -0,0 +1,31 @@ +# Agent Note: Developer tools default on after preference resolution + +Status: implemented + +English | [中文](2026-09-20-developer-tools-default-on.zh.md) + +## Problem + +New installations need access to developer views, preset selection, change summaries and interactive HTML previews without first finding a Settings switch. A saved disabled preference must still prevent those features from activating during startup, including script execution that cannot be undone after it occurs. + +## Decision + +The Host schema defaults `ui-developer-tools.enabled` to `true`. Remote browsers with process-local preferences also start enabled. Saved Host values take precedence, including `false`. This supersedes only the initial default-off choice in [Shared developer-tool settings](2026-09-17-developer-tools-settings.md); that note retains ownership of shared persistence and rendering policy. + +Host-backed clients publish disabled until an accepted schema-resolved value arrives. Loading, failed initial reads and an unavailable namespace do not grant interactive-preview capabilities. The Host supplies the default for an absent stored field; the client does not interpret an absent response as consent to enable scripts. An accepted value remains authoritative during later refreshes. + +Only the Trajectory tab follows the developer-tools preference; third-party Views remain available. The conversation shell retains the explicit Trajectory id because the requested rule names that View. No current requirement makes arbitrary diagnostic Views share the switch. + +## Alternatives considered + +**Keep the default off.** This minimizes the initial interface but requires a separate opt-in for the features new installations now expose. Users can still save the disabled preference. + +**Default the Host client to enabled before settings arrive.** This makes rendering immediate, but briefly activates features for users who disabled them. Unmounting a scripted HTML iframe cannot undo scripts or requests already executed. + +**Add a generic developer-tool flag to View registration.** This expands the plugin API and allows other Views to join a switch whose current requirement is specific to Trajectory. A future requirement can justify that API separately. + +## Consequences + +Fresh profiles expose developer features after settings resolve; users with a saved disabled choice retain the simpler presentation. New users also receive interactive HTML previews with the capabilities documented by the [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md). Host initialization can briefly withhold developer controls even when the eventual preference is enabled. + +Settings tests cover initial withholding, unavailable namespaces, explicit disabled values and process-local defaults. HTML renderer coverage checks that pending and disabled preferences neither read related files nor create script-enabled frames. Browser settings coverage verifies the default and a disabled choice across reload; document-preview replay pins the explicitly disabled scenario. diff --git a/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.zh.md b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.zh.md new file mode 100644 index 0000000000..a684844f5b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 偏好解析后默认开启开发者工具 + +Status: implemented + +[English](2026-09-20-developer-tools-default-on.md) | 中文 + +## Problem + +新安装需要直接使用开发者视图、预设选择、改动摘要和交互式 HTML 预览,无需先寻找设置开关。已保存的关闭偏好仍必须阻止这些功能在启动期间激活,包括发生后无法撤销的脚本执行。 + +## Decision + +Host schema 将 `ui-developer-tools.enabled` 默认为 `true`。使用进程内偏好的远程浏览器也从开启状态启动。已保存的 Host 值优先,包括 `false`。这仅取代[共享开发者工具设置](2026-09-17-developer-tools-settings.zh.md)中最初默认关闭的选择;该记录继续负责共享持久化和渲染策略。 + +使用 Host 偏好的客户端在收到经过 schema 解析并接受的值之前发布关闭状态。加载中、首次读取失败和命名空间不可用均不授予交互式预览能力。缺少存储字段时由 Host 提供默认值;客户端不会把缺少响应解释为允许开启脚本。后续刷新期间,已接受的值继续有效。 + +只有 Trajectory 标签页受开发者工具偏好控制;第三方 View 保持可用。对话外壳保留显式 Trajectory id,因为当前需求明确指定此 View。没有现行需求要求任意诊断 View 共用该开关。 + +## Alternatives considered + +**保持默认关闭。** 这可简化初始界面,但新安装现在直接提供的功能将需要额外启用。用户仍可保存关闭偏好。 + +**在设置到达前让 Host 客户端默认开启。** 这可以立即渲染,却会短暂激活用户已关闭的功能。卸载可执行脚本的 HTML iframe 无法撤销已经执行的脚本或请求。 + +**在 View 注册中增加通用开发者工具标记。** 这会扩展插件 API,并允许其他 View 加入当前仅针对 Trajectory 的开关。未来需求可以单独论证该 API。 + +## Consequences + +新 profile 在设置解析后展示开发者功能;已保存关闭选择的用户保留简化展示。新用户也会获得带有[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)所述能力的交互式 HTML 预览。即使最终偏好为开启,Host 初始化期间也可能短暂不展示开发者控件。 + +设置测试覆盖首次等待、命名空间不可用、显式关闭值和进程内默认值。HTML 渲染器覆盖验证等待中及关闭偏好均不读取关联文件,也不创建允许脚本的 frame。浏览器设置覆盖验证默认值和关闭选择在刷新后保持;文档预览回放固定显式关闭的场景。 diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 72a3db40db..5d9b7f8a14 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -99,7 +99,7 @@ interface CapturedFixture { } export interface AssembledRemoteOptions { - /** Supply an enabled Host preference for diagnostic View scenarios. */ + /** Override the schema-resolved Host preference for developer-tool scenarios. */ readonly developerTools?: boolean /** Return the fixture's image-dimension admission error from Session prompt. */ readonly rejectPrompt?: boolean @@ -142,16 +142,14 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass const mock = RemoteMock.create().load(remoteDefaultResponses) mock.load({ unary: { - 'settings/describe': options.developerTools === true - ? ok({ - ...fixture.settingsDescribe.value, - namespaces: [...fixture.settingsDescribe.value.namespaces, { - ns: 'ui-developer-tools', - schema: { type: 'object', dict: { enabled: { type: 'boolean' } } }, - value: { enabled: true }, applies: 'live', secrets: [], revision: 0, - }], - }) - : structuredClone(fixture.settingsDescribe), + 'settings/describe': ok({ + ...fixture.settingsDescribe.value, + namespaces: [...fixture.settingsDescribe.value.namespaces, { + ns: 'ui-developer-tools', + schema: { type: 'object', dict: { enabled: { type: 'boolean' } } }, + value: { enabled: options.developerTools ?? true }, applies: 'live', secrets: [], revision: 0, + }], + }), 'credentials/describe': structuredClone(fixture.credentialsDescribe), 'session/modelCatalog': structuredClone(fixture.modelCatalog), 'agentPresets/list': structuredClone(fixture.agentPresets), diff --git a/apps/web/tests/developer-tools-settings.e2e.ts b/apps/web/tests/developer-tools-settings.e2e.ts index 6790c94a0c..28dddd3c02 100644 --- a/apps/web/tests/developer-tools-settings.e2e.ts +++ b/apps/web/tests/developer-tools-settings.e2e.ts @@ -30,9 +30,11 @@ it('persists developer tools in the Host settings document and restores the acce expect(await pickerToggle.count()).toBe(0) await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true }) await expect.poll(() => pickerToggle.count()).toBe(1) + await expect.poll(() => pickerToggle.isEnabled()).toBe(true) const pickerEnabled = await pickerToggle.getAttribute('aria-checked') await scaffold.ctx.settings.update('ui-developer-tools', { enabled: false }) await expect.poll(() => pickerToggle.count()).toBe(0) await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true }) + await expect.poll(() => pickerToggle.isEnabled()).toBe(true) await expect.poll(() => pickerToggle.getAttribute('aria-checked')).toBe(pickerEnabled) }) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 31b2fd06b6..8c41184009 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -365,7 +365,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + await successShot(page, 'html-basic') sections.push([ '## Basic HTML', '', - '- Developer tools: off by default on Web and desktop', + '- Developer tools: disabled for this scenario', '- Sandbox: no permissions', `- Inline script: ${await basicHtml.locator('#result').innerText()}`, `- Local script: ${await basicHtml.locator('#local-result').innerText()}`, diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index acc5d685c6..3b67b4ca8a 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md README.md: 5f2c9acaa5840e71d6ca237c5fc5eccb52724cda -README.zh.md: 1bc0c8d08f2358725a104309269197036b74e71b +README.zh.md: 5c61823c25f145c0e516f3f2a16c2de05c250ef1 diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 1bc0c8d08f..5c61823c25 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -新会话选择器和管理区域中的「允许切换 agent 模式」整行设置仅在通用设置中开启开发者工具时显示。此展示偏好不会改变已选预设、保存的默认值、Host 组合或管理区域独立的选择器策略。 +新会话选择器和管理区域中的「允许切换agent模式」整行设置仅在通用设置中开启开发者工具时显示。此展示偏好不会改变已选预设、保存的默认值、Host 组合或管理区域独立的选择器策略。 与设置和对话包一起挂载本插件;通用设置开启开发者工具后,管理分区会显示一个默认开启的可见性开关。关闭期间,新建会话 chip 不出现,宿主会依据部署默认值(随附 Web bundle 中为 `standard`)组装未指名会话。开启时会恢复已保存的用户默认值;尚未保存时则使用部署默认值;该默认值会同时带到当前空白任务上。chip 中的选择本身只为下一个空白会话暂存一次。再次关闭选择器会以同样方式把当前空白任务带回部署默认值,并丢弃尚未使用的暂存选择;已开始及历史会话的标签、组装与已记录历史均保持不变。 diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 4c11822147..22de6a09cd 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -17,6 +17,8 @@ import { en } from '../src/client/locales.ts' afterEach(cleanup) +const translations: ReadonlyMap = new Map(Object.entries(en)) + const READY: AgentPresetSectionState = { status: 'ready', error: null, @@ -35,6 +37,10 @@ const READY: AgentPresetSectionState = { revealedPaths: {}, } +function unusedHook(): never { + throw new Error('This section does not read global slot sources') +} + /** * Render the section over a fixed snapshot, with every action a spy. * @param state - the snapshot to render. @@ -63,12 +69,18 @@ function renderSection( makeDefault: vi.fn(() => Promise.resolve()), setPickerVisible: vi.fn(() => Promise.resolve()), } - const props = { + const props: AgentPresetSectionProps = { ...actions, + usePanelInfo: unusedHook, + useSessions: unusedHook, + useSessionStatus: unusedHook, + useSessionRetainInfo: unusedHook, + useWorkspaces: unusedHook, + useResource: unusedHook, useAgentPresetSection: bindSnapshotSelector(store), useDeveloperTools: bindSnapshotSelector(createSnapshotStore(options.developerTools ?? true)), - t: (key: keyof typeof en) => en[key], - } as unknown as AgentPresetSectionProps + t: key => translations.get(key) ?? key, + } render() return actions } @@ -356,15 +368,8 @@ describe('the preset list', () => { }) it('renders nothing when the deployment composes no presets', () => { - const { container } = render(({ ...READY, status: 'unavailable', rows: [] })), - t: (key: keyof typeof en) => en[key], - load: vi.fn(() => Promise.resolve()), - } as unknown as AgentPresetSectionProps)} />) - - expect(container.firstChild).toBeNull() + renderSection({ status: 'unavailable', rows: [] }) + expect(screen.queryByRole('heading')).toBeNull() }) it('offers a retry when the roster could not be read', () => { diff --git a/packages/client/ui-conversation/src/client/view-selection.ts b/packages/client/ui-conversation/src/client/view-selection.ts index 53b0ffc86a..6d0589759c 100644 --- a/packages/client/ui-conversation/src/client/view-selection.ts +++ b/packages/client/ui-conversation/src/client/view-selection.ts @@ -1,6 +1,6 @@ import type { ViewTab } from './contract/views.ts' -/** Chat remains available when developer tools are disabled. */ +/** Fallback View selected when no stored preference resolves to a registered View. */ export const DEFAULT_VIEW_ID = 'chat' /** The one View the Developer tools preference gates; other Views stay registered by their owners. */ diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 06da77c712..e90a11a163 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -219,6 +219,28 @@ describe('Conversation inject API', () => { expect(source.getSnapshot()).toBeUndefined() await b.runtime.ctx.settingsScope.developerTools.setEnabled(true) expect(source.getSnapshot()).toBe(inspect) + // Trajectory still owns inspection while both Views are visible; hiding + // it must leave a third-party View's inspection capability reachable. + const removePipelineDefinition = b.runtime.ctx.uiConversation.views.register({ + target: 'pipeline', + toolCallFocus: callId => `stage:${callId}`, + create: () => ({ empty: null, replace: () => null, apply: () => null }), + }) + const removePipelineView = b.slots.register( + { name: 'conversation.view', id: 'pipeline' }, (() => null) as never, + ) + await b.runtime.flush() + expect(source.getSnapshot()).toBe(inspect) + await b.runtime.ctx.settingsScope.developerTools.setEnabled(false) + const pipelineInspect = source.getSnapshot()! + expect(pipelineInspect).toBeTypeOf('function') + pipelineInspect('call-2') + expect(body.instance.store.getSnapshot().viewRequest).toEqual({ view: 'pipeline', focus: 'stage:call-2' }) + await b.runtime.ctx.settingsScope.developerTools.setEnabled(true) + removePipelineView() + removePipelineDefinition() + await b.runtime.flush() + expect(source.getSnapshot()).toBe(inspect) removeView() await b.runtime.flush() expect(source.getSnapshot()).toBeUndefined() diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index ae974a73f3..1d11e8ea30 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -107,7 +107,7 @@ describe('ui-settings-general apply', () => { const developerRow = c.ctx.slots.entries('settings.general.item').find(row => row.options.id === 'developer-tools')! const developer = (developerRow.inject as unknown as () => DeveloperToolsRowInjected)() expect(developer.hooks.developerTools).toBe(c.ctx.settingsScope.developerTools.enabled) - expect(developer.hooks.developerTools.getSnapshot()).toBe(true) + expect(developer.hooks.developerTools.getSnapshot()).toBe(false) const setEnabled = vi.spyOn(c.ctx.settingsScope.developerTools, 'setEnabled').mockResolvedValue(undefined) await developer.setEnabled(true) expect(setEnabled).toHaveBeenCalledExactlyOnceWith(true) diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index fdb34612b3..8123e08a21 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/README.i18n.yaml @@ -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/ui-settings/README.md -README.md: 8418c14273c863eb786c83e2d8eaedfd3d1ae2f6 -README.zh.md: 296f61f929187785c7cefa3e452eec3f3b3e72b0 +README.md: d688442cb003f980b037354f76997c18a2ca0be2 +README.zh.md: 92e2026d04f188854fd6205fe4d35e8c9c8e57b2 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 8418c14273..d688442cb0 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re- ### Binding a namespace -`ctx.settingsScope.developerTools` owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `true`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording. +`ctx.settingsScope.developerTools` owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `true`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording. Host-backed clients keep developer features disabled until the first accepted schema-resolved value arrives; missing or failed initial responses do not enable them. Later refreshes retain the last accepted choice. A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. These methods return `true` for Host acceptance and `false` for refusal or skipped writes; transport failures reject. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 296f61f929..92e2026d04 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 绑定命名空间 -`ctx.settingsScope.developerTools` 管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `true`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。 +`ctx.settingsScope.developerTools` 管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `true`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。 使用 Host 偏好的客户端在首个经过 schema 解析并接受的值到达前保持开发者功能关闭;首次响应缺失或失败不会启用它们。后续刷新保留已接受的选择。 功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。这些方法在 Host 接受时返回 `true`,拒绝或跳过写入时返回 `false`;传输失败会 reject。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。 diff --git a/packages/client/ui-settings/src/client/developer-tools.ts b/packages/client/ui-settings/src/client/developer-tools.ts index 51466d878f..907b32065a 100644 --- a/packages/client/ui-settings/src/client/developer-tools.ts +++ b/packages/client/ui-settings/src/client/developer-tools.ts @@ -3,7 +3,7 @@ import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-c import type { DeveloperToolsSettings } from '../developer-tools-settings.ts' import type { SettingsScope } from './settings-contract.ts' -/** Shared preference; no accepted value means developer tools are enabled. */ +/** Shared preference; Host-backed features stay disabled until an accepted value arrives. */ export class DeveloperToolsPreference { /** Accepted enablement, observable through renderer-bound hooks. */ readonly enabled: ObservableSnapshot @@ -14,7 +14,7 @@ export class DeveloperToolsPreference { */ constructor(private readonly scope: SettingsScope) { this.enabled = scope.getSnapshot().mode === 'memory' ? this.local : { - getSnapshot: () => scope.getSnapshot().value?.enabled ?? true, + getSnapshot: () => scope.getSnapshot().value?.enabled ?? false, subscribe: (listener) => { let previous = this.enabled.getSnapshot() return scope.subscribe(() => { diff --git a/packages/client/ui-settings/tests/developer-tools.client.spec.ts b/packages/client/ui-settings/tests/developer-tools.client.spec.ts index 6b7aebebce..08d9e0aa47 100644 --- a/packages/client/ui-settings/tests/developer-tools.client.spec.ts +++ b/packages/client/ui-settings/tests/developer-tools.client.spec.ts @@ -39,6 +39,39 @@ describe('developer tools settings', () => { expect(ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(false) }) + it.each([false, true])('withholds features during a delayed Host read before accepting %s', async (enabled) => { + const ctx = new Context() + const accepted = { ok: true, value: { + writable: true, hasDocument: true, namespaces: [{ + ns: DEVELOPER_TOOLS_NAMESPACE, + schema: DeveloperToolsSettingsSchema.toJSON(), + value: { enabled }, revision: 1, applies: 'live', secrets: [], + }], + } } + const pending = Promise.withResolvers() + onTestFinished(async () => { pending.resolve(accepted); await ctx.fiber.dispose() }) + new TestRemote(ctx, { settings: { describe: () => pending.promise } }) + await ctx.plugin({ inject, apply: clientApply }).await() + const preference = ctx.settingsScope.developerTools + const changed = vi.fn() + const dispose = preference.enabled.subscribe(changed) + onTestFinished(dispose) + expect(preference.enabled.getSnapshot()).toBe(false) + pending.resolve(accepted) + await ctx.settingsScope.describe().ensure() + expect(preference.enabled.getSnapshot()).toBe(enabled) + expect(changed).toHaveBeenCalledTimes(enabled ? 1 : 0) + }) + + it('keeps features disabled when the initial Host read fails', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + new TestRemote(ctx, { settings: { describe: () => Promise.reject(new Error('disconnected')) } }) + await ctx.plugin({ inject, apply: clientApply }).await() + await ctx.settingsScope.describe().ensure() + expect(ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(false) + }) + it('shares one remote-browser preference across consumers and disposes it with the plugin', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) @@ -74,15 +107,15 @@ describe('developer tools settings', () => { expect(ctx.settings.describe().map(row => row.ns)).not.toContain(DEVELOPER_TOOLS_NAMESPACE) }) - it('stays on until accepted settings arrive, then follows a stored false and external changes', async () => { + it('stays off until accepted settings arrive, then follows a stored false and external changes', async () => { const host = stubSettingsScope() const preference = new DeveloperToolsPreference(host.scope) - expect(preference.enabled.getSnapshot()).toBe(true) + expect(preference.enabled.getSnapshot()).toBe(false) const notify = vi.fn() const dispose = preference.enabled.subscribe(notify) host.publish({ status: 'ready', value: { enabled: false } }) expect(preference.enabled.getSnapshot()).toBe(false) - expect(notify).toHaveBeenCalledOnce() + expect(notify).not.toHaveBeenCalled() await preference.setEnabled(true) expect(host.set).toHaveBeenCalledWith('enabled', true) host.publish({ value: { enabled: true } }) @@ -115,9 +148,16 @@ it('ignores host revisions that do not change enablement', () => { const notify = vi.fn() const dispose = preference.enabled.subscribe(notify) host.publish({ revision: 1 }) - host.publish({ value: { enabled: true } }) - expect(notify).not.toHaveBeenCalled() host.publish({ value: { enabled: false } }) + expect(notify).not.toHaveBeenCalled() + host.publish({ value: { enabled: true } }) expect(notify).toHaveBeenCalledOnce() dispose() }) + +it('keeps an unavailable Host namespace disabled', () => { + const host = stubSettingsScope() + const preference = new DeveloperToolsPreference(host.scope) + host.publish({ status: 'unavailable' }) + expect(preference.enabled.getSnapshot()).toBe(false) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx index be3a1a7869..b136e6791b 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx @@ -3,6 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindSnapshotSelector, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { DeveloperToolsPreference } from '@deepseek-ai/dsh-client-ui-settings/src/client/developer-tools.ts' +import type { DeveloperToolsSettings } from '@deepseek-ai/dsh-client-ui-settings/src/developer-tools-settings.ts' import type { Resources, ResourceSnapshot } from '@deepseek-ai/dsh-client-resources/client' import type { WorkspaceFileStat } from '@deepseek-ai/dsh-api-workspace-files/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -153,6 +156,31 @@ describe('HtmlBody', () => { expect(screen.getByRole('alert').textContent).toBe(en.failed) }) + it('follows the shared developer-tools preference from loading through a stored false to enabled', async () => { + const host = stubSettingsScope() + const preference = new DeveloperToolsPreference(host.scope) + const readRelated = vi.fn().mockResolvedValue({ ok: true, value: { + absolutePath: '/workspace/asset.js', version: 'asset-v1', offset: 0, eof: true, + data: btoa('window.loaded = true'), + } }) + const scripted: HtmlBodyProps = { + ...props('

      Preview

      '), + useInteractivePreview: bindSnapshotSelector(preference.enabled), + readRelated, + } + const view = render() + expect(screen.getByTitle(en.frame).getAttribute('sandbox')).toBe('') + expect(readRelated).not.toHaveBeenCalled() + act(() => { host.publish({ status: 'ready', value: { enabled: false } }) }) + expect(screen.getByTitle(en.frame).getAttribute('sandbox')).toBe('') + expect(readRelated).not.toHaveBeenCalled() + act(() => { host.publish({ value: { enabled: true } }) }) + const advanced = await screen.findByTitle(en.frame) + expect(advanced.getAttribute('sandbox')).toBe('allow-scripts') + expect(readRelated).toHaveBeenCalledOnce() + view.unmount() + }) + it('renders a Blob iframe with only scripts allowed, keeping it mounted for unrelated props', async () => { const initial = props() const view = render() diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index f5ecfea159..1cd20f6c7f 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -481,10 +481,6 @@ "packages/client/ui-agent-preset/tests/section-store.client.spec.ts": { "23e04af2a900d7a905ff7657de842e9833384a2d4c4afba33964477e43d8ba52": 1 }, - "packages/client/ui-agent-preset/tests/section.client.spec.tsx": { - "4cc9284f9e4d9df997ed042df717f143f3763130fdfb9e142226da4b5b4c60e6": 1, - "67694f8f44eb8347cb38c39d368f4f0ce1b78bd33a6a57a55cf4664f3eaa7f98": 1 - }, "packages/client/ui-agent-preset/tests/settings-store.client.spec.ts": { "c0e852e6b0e2a8a040b65723f8ab1d650d9cb06c26be7f3a0709ddb9f7525595": 1, "d6a04d954aef7a8edd403910851015c0ce1597f6f300292b5158a8bf993fc1d8": 1 diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index e92c8cf01f..5495962ea8 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -17,7 +17,7 @@ ## Basic HTML -- Developer tools: off by default on Web and desktop +- Developer tools: disabled for this scenario - Sandbox: no permissions - Inline script: pending - Local script: pending From 5a53758b03f244c8bb77e10b867f6ae4f7809cfc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:45:26 +0800 Subject: [PATCH 147/284] test: preserve plugin-manager integration after stack rebase --- .../tests/components.client.spec.tsx | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 2226aa0476..5de5e11780 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -101,20 +101,31 @@ function renderTab(state: Partial = {}, config: Partial { - if (!('view' in owner) || (owner.view !== 'summary' && owner.view !== 'page')) { - throw new Error('Configuration slot requires a summary or page view') - } - return bodies[`${name}:${opts?.only ?? opts?.entryKey ?? ''}`]?.(owner.view) ?? null + const unusedStandardHook = (): never => { throw new Error('Plugin manager fixture does not provide global state') } + const standard = { + usePanelInfo: unusedStandardHook, + useWorkspaces: unusedStandardHook, + useSessions: unusedStandardHook, + useSessionStatus: unusedStandardHook, + useSessionRetainInfo: unusedStandardHook, + useResource: unusedStandardHook, } - const props = { + const props: PluginManagerPageProps = { + ...standard, t, resolveText, ...actions, usePluginManager: bindSnapshotSelector(store), useConfigLedger: bindSnapshotSelector(ledger), - renderSlot, - } as PluginManagerPageProps + renderSlot: (name, owner, opts) => { + const body = bodies[`${name}:${opts?.only ?? opts?.entryKey ?? ''}`] + if (body === undefined) return null + if (!('view' in owner) || (owner.view !== 'summary' && owner.view !== 'page')) { + throw new Error('Plugin configuration fixture requires a summary or page view') + } + return body(owner.view) + }, + } const { rerender } = render() return { store, From f984683956cc6ccfb983a8ef2eabddd548c5b08a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 20 Sep 2026 14:46:54 +0800 Subject: [PATCH 148/284] fix(subagent): store unknown mode in the existing catalog event --- ...session-local-subagent-migration.i18n.yaml | 4 +- ...-09-19-session-local-subagent-migration.md | 4 +- ...-19-session-local-subagent-migration.zh.md | 4 +- ...6-09-11-persistence-type-history.i18n.yaml | 4 +- .../2026-09-11-persistence-type-history.md | 2 +- .../2026-09-11-persistence-type-history.zh.md | 2 +- apps/web/tests/preset-migration.snapshot.ts | 2 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 90 +- docs/persistence-catalog.zh.md | 90 +- ...2026-09-20-unknown-child-catalog.i18n.yaml | 4 +- .../2026-09-20-unknown-child-catalog.md | 12 +- ...26-09-20-unknown-child-catalog.schema.json | 118 ++- .../2026-09-20-unknown-child-catalog.zh.md | 12 +- docs/persistence-changes/README.i18n.yaml | 4 +- docs/persistence-changes/README.md | 3 + docs/persistence-changes/README.zh.md | 3 + .../historical-formats/README.i18n.yaml | 4 +- .../historical-formats/README.md | 2 +- .../historical-formats/README.zh.md | 2 +- docs/persistence-schema.json | 888 ++++++++---------- .../core/session/src/known-event-types.ts | 1 - packages/sdk/client/tests/sdk-client.spec.ts | 2 +- .../session-format-v3-to-v4/README.i18n.yaml | 4 +- .../session/session-format-v3-to-v4/README.md | 6 +- .../session-format-v3-to-v4/README.zh.md | 6 +- .../session-format-v3-to-v4/src/facts.ts | 22 +- .../session-format-v3-to-v4/src/migration.ts | 2 +- .../session-format-v3-to-v4/src/validation.ts | 6 +- .../tests/catalog-completion.spec.ts | 8 +- .../tests/validation.spec.ts | 7 +- .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../tests/catalog-migration.spec.ts | 20 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- packages/subagent/subagent/src/catalog.ts | 29 +- .../subagent/subagent/tests/catalog.spec.ts | 2 +- python/sdk/tests/test_client.py | 2 +- scripts/persistence-changes.spec.ts | 53 ++ scripts/persistence-changes.ts | 23 + 43 files changed, 703 insertions(+), 764 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml index fba52db72b..8b543c4f2b 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md -2026-09-19-session-local-subagent-migration.md: fcdbb7830a12bca645b2a20c68178fff97d74626 -2026-09-19-session-local-subagent-migration.zh.md: bd6aaf5c434ec109cae2d3c68ddd29249debabf2 +2026-09-19-session-local-subagent-migration.md: a44b6cdfe37656fc112f06187ef0c884e8b2fae1 +2026-09-19-session-local-subagent-migration.zh.md: e1c790d4e19dbc0bab962592d9ff14b2e94fbdb0 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md index fcdbb7830a..a44b6cdfe3 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md @@ -12,7 +12,7 @@ Opening a historical parent requires discovery facts from its direct children. A Opening A discovers candidate B Sessions through headers, reads their own descriptors, and completes A's catalog. Historical child reads use the V0–V3 catalog; current child reads use native validation. These reads neither prepare B's own catalog nor publish B's successor. Opening B separately performs B's migration and catalog preparation. Frontend discovery retains its parent-catalog source. Unknown-mode rows remain browsable; `list_agents` continues to select only known continuable children. -JSONL omits unreadable or unsupported headers from discovery. A child-body or descriptor-field failure produces a warning with the child path and retains the child's header identity. If the parent lacks a complete entry, migration appends `subagent/catalog-unknown`, projected as `mode: 'unknown'` alongside healthy siblings. Existing complete entries remain authoritative. Cancellation and source-consistency failures still stop the operation. Inspected child revisions, including failed decodes, are rechecked before reuse and publication so repairs cannot silently reuse stale evidence. Released predecessors remain byte-identical. +JSONL omits unreadable or unsupported headers from discovery. A child-body or descriptor-field failure produces a warning with the child path and retains the child's header identity. If the parent lacks a complete entry, migration appends `subagent/catalog`, projected as `mode: 'unknown'` alongside healthy siblings. Existing complete entries remain authoritative. Cancellation and source-consistency failures still stop the operation. Inspected child revisions, including failed decodes, are rechecked before reuse and publication so repairs cannot silently reuse stale evidence. Released predecessors remain byte-identical. This partially supersedes child-failure propagation in [released migrations](../architecture/2026-08-31-released-session-format-migrations.md) and [incomplete child evidence](2026-09-19-v3-incomplete-child-catalog-evidence.md). Their format conversion, descriptor cardinality, conflict validation, and immutable publication rules remain active. @@ -28,6 +28,6 @@ This partially supersedes child-failure propagation in [released migrations](../ ## Consequences -Initial parent preparation still scans headers and reads direct-child bodies one at a time. It does not claim to eliminate this I/O. Published V4 parents use their catalog without rescanning children. Unknown entries are not automatically rewritten after publication. Opening a child retries its log and determines browsing capabilities from its actual descriptor, or reports the child-local error. A separate required-on-read event preserves the finalized V4 catalog payload while making unknown membership durable; older readers that lack this event refuse the log. +Initial parent preparation still scans headers and reads direct-child bodies one at a time. It does not claim to eliminate this I/O. Published V4 parents use their catalog without rescanning children. Unknown entries are not automatically rewritten after publication. Opening a child retries its log and determines browsing capabilities from its actual descriptor, or reports the child-local error. Catalog mode adds `unknown` without changing existing alternatives or the V4 header. The persistence checker admits this scoped extension; older readers may reject records using the new mode. Raw and compressed tests cover catalog completion, child-local errors, source changes and repairs, unchanged child generations, and deferred child-catalog preparation. The shipped Web migration snapshot checks the parent catalog with a corrupt sibling, then opens the child independently. Its additional historical child raises the corpus budget from ten to eleven roles; the guard rejects a twelfth role. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md index bd6aaf5c43..e1c790d4e1 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md @@ -12,7 +12,7 @@ Status: implemented 打开 A 时通过 header 发现候选 B Session,读取其自身 descriptor,并补齐 A 的 catalog。历史子日志使用 V0–V3 catalog 读取,当前子日志使用原生校验。这些读取不准备 B 自己的 catalog,也不发布 B 的后继。单独打开 B 时才执行 B 的迁移与目录准备。前端发现继续读取父 catalog。模式未知的条目仍可浏览;`list_agents` 继续只选择已知可继续的子会话。 -JSONL 在发现时跳过不可读或不支持的 header。子正文或 descriptor 字段失败会产生带子路径的警告,并保留该子的 header 身份。父目录没有完整条目时,迁移追加 `subagent/catalog-unknown`,与健康兄弟项一同投影为目录条目,其模式为 `mode: 'unknown'`。已有完整条目仍具有权威性。取消与来源一致性失败仍中止操作。复用和发布前会重新检查已检查子日志的修订,包括解码失败的日志,使修复不能静默复用旧证据。已发布前驱保持逐字节不变。 +JSONL 在发现时跳过不可读或不支持的 header。子正文或 descriptor 字段失败会产生带子路径的警告,并保留该子的 header 身份。父目录没有完整条目时,迁移追加 `subagent/catalog`,与健康兄弟项一同投影为目录条目,其模式为 `mode: 'unknown'`。已有完整条目仍具有权威性。取消与来源一致性失败仍中止操作。复用和发布前会重新检查已检查子日志的修订,包括解码失败的日志,使修复不能静默复用旧证据。已发布前驱保持逐字节不变。 本决策部分取代[已发布迁移](../architecture/2026-08-31-released-session-format-migrations.zh.md)和[不完整子证据](2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)中的子错误传播规则。其格式转换、descriptor 数量、冲突校验和不可变发布规则仍有效。 @@ -28,6 +28,6 @@ JSONL 在发现时跳过不可读或不支持的 header。子正文或 descripto ## 后果 -首次父准备仍扫描 header 并逐个读取直属子正文,不宣称消除此 I/O。已发布 V4 父会话使用其 catalog,不重新扫描子日志。发布后不自动重写未知条目。打开子会话会重试其日志,根据实际 descriptor 确定浏览能力,或报告该子会话的错误。独立的必读事件保留已定稿 V4 目录载荷,同时持久记录未知成员;不认识该事件的旧读取器拒绝日志。 +首次父准备仍扫描 header 并逐个读取直属子正文,不宣称消除此 I/O。已发布 V4 父会话使用其 catalog,不重新扫描子日志。发布后不自动重写未知条目。打开子会话会重试其日志,根据实际 descriptor 确定浏览能力,或报告该子会话的错误。Catalog mode 增加 `unknown`,既有分支和 V4 header 保持不变。持久化检查允许这项限定扩展;旧读取器可能拒绝使用新模式的记录。 原始和压缩测试覆盖目录补齐、子会话错误隔离、来源变化与修复、子代际不变和延后的子目录准备。随产品 Web 迁移快照在损坏兄弟存在时检查父目录,再独立打开子会话。额外的历史子角色使语料预算由十个增加到十一个;守卫拒绝第十二个角色。 diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml index 94e92c9bac..30c4c0961b 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml @@ -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 .agents/notes/implemented/process/2026-09-11-persistence-type-history.md -2026-09-11-persistence-type-history.md: c7cddea5ecc479a64ad5f24f5447e869779b7f9e -2026-09-11-persistence-type-history.zh.md: 001ce197c4cd12dc64a5e2bd710e39cb2b61f23a +2026-09-11-persistence-type-history.md: a5c50a871ebdb583c7e955944b03f4e082c69fe2 +2026-09-11-persistence-type-history.zh.md: 8fc42ccd9964d7eab254da8bb8c40ae055ff1152 diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md index c7cddea5ec..a5c50a871e 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md @@ -14,7 +14,7 @@ One normalized type model supplies the readable persistence catalog, the complet The dedicated [persistence-change records](../../../../docs/persistence-changes/README.md) bind acknowledgement to the after digest of each affected root. A generated companion stores complete after schemas. The initial record covers all roots; later records name each root's predecessor. A predecessor's after schema supplies the next before schema. Verification rejects ambiguous history and requires current source to match the terminal recorded state without consulting Git history or remote services. -Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version. +Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. The [catalog exception](../../../../docs/persistence-changes/README.md#compatibility-rules) admits unknown child mode with unchanged identity fields and records the older-reader limitation. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version. The new document kind preserves the compatibility reasoning and evidence for a historical type transition. Agent Notes retain mechanism-level decisions; they do not become a growing inventory of individual acknowledgements. Machine declarations remain identical across the bilingual pair and are parsed once. diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md index 001ce197c4..8fc42ccd99 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md @@ -14,7 +14,7 @@ Status: implemented 专用的[持久化变更记录](../../../../docs/persistence-changes/README.zh.md)将确认绑定到每个受影响根的变更后摘要。生成的伴随文件保存完整的变更后 schema。初始记录覆盖所有根;后续记录为每个根指定前驱。前驱的变更后 schema 提供下一次的变更前 schema。验证拒绝有歧义的历史,并要求当前源码匹配最终记录状态,无需查询 Git 历史或远端服务。 -自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。 +自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。[Catalog 例外](../../../../docs/persistence-changes/README.zh.md#compatibility-rules) 允许增加身份字段不变的未知子模式,并记录旧读取器限制。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。 新文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。 diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index 1f1b81ed90..530a0f3ef9 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -108,7 +108,7 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t && (row['data'] as { agentPreset: string }).agentPreset === 'code' ? { ...row, data: { agentPreset: 'ptc' } } : row), - { type: 'subagent/catalog-unknown', seq: rows.length, time: rows.at(-1)?.['time'], data: { + { type: 'subagent/catalog', seq: rows.length, time: rows.at(-1)?.['time'], data: { version: 0, childId: brokenId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'unknown', } }, { type: 'subagent/catalog', seq: rows.length + 1, time: rows.at(-1)?.['time'], data: { diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index af54d7738c..43982e1667 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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 docs/persistence-catalog.md -persistence-catalog.md: 2c64c088a70a9a80430c2af377f09ddaedc356dc -persistence-catalog.zh.md: 9ea121c475aed4f12c164aeae1d6f26cb1679ede +persistence-catalog.md: 0733c6880cf65e445b35c2dea0589b7ce93a96b8 +persistence-catalog.zh.md: c4f4870fb8d712b43502579ecfd7adae8d74a535 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 2c64c088a7..0733c6880c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -57,8 +57,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871) | -| `event:subagent/catalog-unknown` | event | `d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21` | [`{ type: "subagent/catalog-unknown" }`](#persistence-type-sha256-d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21) | +| `event:subagent/catalog` | event | `4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -895,27 +894,13 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/ ```ts persistence-catalog /** - * A direct child's complete discovery fact. + * A direct child's identity and available discovery fields. * @param data - versioned parent-owned catalog entry. */ 'subagent/catalog': SubagentCatalogEvent ``` -Source: [`packages/subagent/subagent/src/catalog.ts:48`](../packages/subagent/subagent/src/catalog.ts) - - - -#### `subagent/catalog-unknown` — log-only - -```ts persistence-catalog -/** - * A historical child's header identity when its descriptor cannot establish a mode. - * @param data - child identity retained for discovery and later history reads. - */ -'subagent/catalog-unknown': UnknownCatalogEvent -``` - -Source: [`packages/subagent/subagent/src/catalog.ts:53`](../packages/subagent/subagent/src/catalog.ts) +Source: [`packages/subagent/subagent/src/catalog.ts:41`](../packages/subagent/subagent/src/catalog.ts) @@ -2490,14 +2475,6 @@ SHA-256: `44c8c77aa9aa100b0d74d2deee85474b29faa6eee0ac0f662f7abe9931ddd7f2` `"subagent/catalog"` - - -### `"subagent/catalog-unknown"` - -SHA-256: `d437ebdbc1839ffcf3f6fa201a003126ec63945186b0c0792f1db466bf8ebd89` - -`"subagent/catalog-unknown"` - ### `"subagent/descriptor"` @@ -4578,7 +4555,7 @@ One of: - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4586,7 +4563,7 @@ One of: ### `SubagentCatalogEvent` -SHA-256: `3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e` +SHA-256: `30a49d1a89bd03b8503fbdab94d4883e4ce839d931f29672ac070007cc0016ce` Sources: [`packages/subagent/subagent/src/catalog.ts:24`](../packages/subagent/subagent/src/catalog.ts) @@ -4594,6 +4571,7 @@ One of: - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca) @@ -5118,26 +5096,6 @@ Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src | `interrupted` | not declared | none | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | not declared | none | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - - - - - - -### `UnknownCatalogEvent` - -SHA-256: `c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca` - -Sources: [`packages/subagent/subagent/src/catalog.ts:34`](../packages/subagent/subagent/src/catalog.ts) - -| Property | Presence | Type | -|---|---|---| -| `childCreatedAt` | required | `number` | -| `childId` | required | `string` | -| `label` | optional | `string` | -| `mode` | required | `"unknown"` | -| `version` | required | `0` | - @@ -5677,6 +5635,20 @@ SHA-256: `5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694` | `mode` | required | `"one-shot"` | | `version` | required | `0` | + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | optional | `string` | +| `mode` | required | `"unknown"` | +| `version` | required | `0` | + ### `{ code, name, reason? }` @@ -8272,38 +8244,22 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | required | `number` | | `type` | required | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` +SHA-256: `4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e) | +| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-30a49d1a89bd03b8503fbdab94d4883e4ce839d931f29672ac070007cc0016ce) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | | `type` | required | `"subagent/catalog"` | - - - - -### `{ type: "subagent/catalog-unknown" }` - -SHA-256: `d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21` - -| Property | Presence | Type | -|---|---|---| -| `data` | required | [`UnknownCatalogEvent`](#persistence-type-sha256-c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca) | -| `ignorable` | optional | `true` | -| `seq` | required | `number` | -| `time` | required | `number` | -| `type` | required | `"subagent/catalog-unknown"` | - diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 9ea121c475..c4f4870fb8 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -59,8 +59,7 @@ | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871) | -| `event:subagent/catalog-unknown` | event | `d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21` | [`{ type: "subagent/catalog-unknown" }`](#persistence-type-sha256-d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21) | +| `event:subagent/catalog` | event | `4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -897,27 +896,13 @@ export type SessionEvent = { ```ts persistence-catalog /** - * A direct child's complete discovery fact. + * A direct child's identity and available discovery fields. * @param data - versioned parent-owned catalog entry. */ 'subagent/catalog': SubagentCatalogEvent ``` -来源:[`packages/subagent/subagent/src/catalog.ts:48`](../packages/subagent/subagent/src/catalog.ts) - - - -#### `subagent/catalog-unknown` — log-only - -```ts persistence-catalog -/** - * A historical child's header identity when its descriptor cannot establish a mode. - * @param data - child identity retained for discovery and later history reads. - */ -'subagent/catalog-unknown': UnknownCatalogEvent -``` - -来源:[`packages/subagent/subagent/src/catalog.ts:53`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:41`](../packages/subagent/subagent/src/catalog.ts) @@ -2492,14 +2477,6 @@ SHA-256: `44c8c77aa9aa100b0d74d2deee85474b29faa6eee0ac0f662f7abe9931ddd7f2` `"subagent/catalog"` - - -### `"subagent/catalog-unknown"` - -SHA-256: `d437ebdbc1839ffcf3f6fa201a003126ec63945186b0c0792f1db466bf8ebd89` - -`"subagent/catalog-unknown"` - ### `"subagent/descriptor"` @@ -4580,7 +4557,7 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4588,7 +4565,7 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` ### `SubagentCatalogEvent` -SHA-256: `3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e` +SHA-256: `30a49d1a89bd03b8503fbdab94d4883e4ce839d931f29672ac070007cc0016ce` 来源:[`packages/subagent/subagent/src/catalog.ts:24`](../packages/subagent/subagent/src/catalog.ts) @@ -4596,6 +4573,7 @@ SHA-256: `3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e` - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca) @@ -5120,26 +5098,6 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` | `interrupted` | 未声明 | 无 | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | 未声明 | 无 | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - - - - - - -### `UnknownCatalogEvent` - -SHA-256: `c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca` - -来源:[`packages/subagent/subagent/src/catalog.ts:34`](../packages/subagent/subagent/src/catalog.ts) - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `childCreatedAt` | 必需 | `number` | -| `childId` | 必需 | `string` | -| `label` | 可选 | `string` | -| `mode` | 必需 | `"unknown"` | -| `version` | 必需 | `0` | - @@ -5679,6 +5637,20 @@ SHA-256: `5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694` | `mode` | 必需 | `"one-shot"` | | `version` | 必需 | `0` | + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"unknown"` | +| `version` | 必需 | `0` | + ### `{ code, name, reason? }` @@ -8274,38 +8246,22 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | 必需 | `number` | | `type` | 必需 | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` +SHA-256: `4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e) | +| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-30a49d1a89bd03b8503fbdab94d4883e4ce839d931f29672ac070007cc0016ce) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | | `type` | 必需 | `"subagent/catalog"` | - - - - -### `{ type: "subagent/catalog-unknown" }` - -SHA-256: `d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21` - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `data` | 必需 | [`UnknownCatalogEvent`](#persistence-type-sha256-c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca) | -| `ignorable` | 可选 | `true` | -| `seq` | 必需 | `number` | -| `time` | 必需 | `number` | -| `type` | 必需 | `"subagent/catalog-unknown"` | - diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml b/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml index cfcd73af1d..d393e07ce1 100644 --- a/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml @@ -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 docs/persistence-changes/2026-09-20-unknown-child-catalog.md -2026-09-20-unknown-child-catalog.md: 80de55e616d63730bc1d0bbc0ffa43923f833923 -2026-09-20-unknown-child-catalog.zh.md: 1ee8a04e7318013ec27a7c736fadd540a96cfc0d +2026-09-20-unknown-child-catalog.md: 58e8a8ffedb7b3b913c9e04590ce5bbabd148ba6 +2026-09-20-unknown-child-catalog.zh.md: 488899ecadcdc93910927c80b22b8fcf0d319224 diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.md b/docs/persistence-changes/2026-09-20-unknown-child-catalog.md index 80de55e616..58e8a8ffed 100644 --- a/docs/persistence-changes/2026-09-20-unknown-child-catalog.md +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.md @@ -9,7 +9,7 @@ English | [中文](2026-09-20-unknown-child-catalog.zh.md) ## Summary -Add subagent/catalog-unknown to retain historical direct-child header identity when a descriptor cannot establish the mode. +Retain unreadable historical children in subagent/catalog with unknown mode. ## Table of Contents @@ -26,21 +26,21 @@ schemaVersion: 1 id: 2026-09-20-unknown-child-catalog baseline: false changes: - - root: "event:subagent/catalog-unknown" - previous: null - after: "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21" + - root: "event:subagent/catalog" + previous: "2026-09-11-initial" + after: "4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3" decision: same-version ``` ## Compatibility -This adds an ordinary required-on-read event without changing the finalized subagent/catalog payload, Session header, or envelope. Existing logs remain valid. Older readers that do not know the new event refuse logs containing it; current readers project it into the existing catalog with unknown mode and keep continuation requests restricted to supported child descriptors. +The catalog adds one unknown-mode alternative with the same identity and optional label fields as one-shot mode. Existing alternatives and the V4 header remain unchanged. New readers accept old records; older readers may reject records with unknown mode. The persistence classifier has a scoped catalog exception for this addition, while other union additions and modifications remain strict. Unknown membership grants no continuation capability. ## Verification -pnpm exec vitest run packages/session/session-format-v3-to-v4/tests/validation.spec.ts packages/subagent/subagent/tests/catalog.spec.ts: 16 tests passed, covering unknown identity restoration, malformed payload rejection, duplicate child rejection, and shared catalog projection. +Catalog migration, restoration, projection, and Web regressions cover retained unknown membership and child-local errors. Persistence classifier tests accept the exact catalog extension and reject altered existing fields, other new modes, extra unknown fields, removals, and unrelated union additions. ## Dev Note diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json b/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json index 7fb3b867fa..ce4dd23ac3 100644 --- a/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json @@ -2,11 +2,11 @@ "formatVersion": 2, "roots": [ { - "key": "event:subagent/catalog-unknown", + "key": "event:subagent/catalog", "kind": "event", - "event": "subagent/catalog-unknown", + "event": "subagent/catalog", "surface": false, - "digest": "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21", + "digest": "4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3", "schema": { "root": 0, "nodes": [ @@ -20,54 +20,62 @@ }, { "name": "ignorable", - "type": 6, + "type": 11, "optional": true }, { "name": "seq", - "type": 2, + "type": 3, "optional": false }, { "name": "time", - "type": 2, + "type": 3, "optional": false }, { "name": "type", - "type": 7, + "type": 12, "optional": false } ], "indices": [] }, + { + "kind": "union", + "types": [ + 2, + 7, + 9 + ] + }, { "kind": "object", "properties": [ { "name": "childCreatedAt", - "type": 2, + "type": 3, "optional": false }, { "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": true - }, - { - "name": "mode", "type": 4, "optional": false }, { - "name": "version", + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", "type": 5, "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false } ], "indices": [] @@ -88,13 +96,83 @@ "kind": "literal", "value": 0 }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 8, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, { "kind": "literal", "value": true }, { "kind": "literal", - "value": "subagent/catalog-unknown" + "value": "subagent/catalog" } ] } diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md b/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md index 1ee8a04e73..488899ecad 100644 --- a/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md @@ -9,7 +9,7 @@ kind: persistence-change ## 概述 -新增 subagent/catalog-unknown,在 descriptor 无法确定模式时保留历史直属子会话的 header 身份。 +在 subagent/catalog 中使用 unknown 模式保留不可读的历史子会话。 ## 目录 @@ -26,21 +26,21 @@ schemaVersion: 1 id: 2026-09-20-unknown-child-catalog baseline: false changes: - - root: "event:subagent/catalog-unknown" - previous: null - after: "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21" + - root: "event:subagent/catalog" + previous: "2026-09-11-initial" + after: "4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3" decision: same-version ``` ## 兼容性 -新增普通的必读事件,不修改已定稿的 subagent/catalog 载荷、Session header 或事件信封。已有日志仍有效。不认识新事件的旧读取器拒绝包含它的日志;当前读取器将其投影为现有目录中的未知模式项,继续执行请求仍受支持的子 descriptor 约束。 +Catalog 增加一个 unknown 模式分支,身份与可选 label 字段和 one-shot 模式相同。已有分支及 V4 header 保持不变。新读取器接受旧记录;旧读取器可能拒绝 unknown 模式记录。持久化分类器仅为该 catalog 扩展设置限定例外,其他联合类型新增与修改仍严格检查。未知成员关系不授予继续执行能力。 ## 验证 -pnpm exec vitest run packages/session/session-format-v3-to-v4/tests/validation.spec.ts packages/subagent/subagent/tests/catalog.spec.ts:16 个测试通过,覆盖未知身份恢复、畸形载荷拒绝、重复 child 拒绝及共享目录投影。 +Catalog 迁移、恢复、投影和 Web 回归覆盖未知成员保留与子会话局部报错。持久化分类器测试接受此 catalog 扩展,并拒绝已有字段修改、其他新增模式、未知分支额外字段、删除及无关联合类型新增。 ## 开发备注 diff --git a/docs/persistence-changes/README.i18n.yaml b/docs/persistence-changes/README.i18n.yaml index 830e77b05e..ea39a60513 100644 --- a/docs/persistence-changes/README.i18n.yaml +++ b/docs/persistence-changes/README.i18n.yaml @@ -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 docs/persistence-changes/README.md -README.md: 3fd95e48548fdb9c1d04330c2925096d072850d1 -README.zh.md: f4ab3b3d22b1aa2f7f5923e84fd4effc339874db +README.md: 1e7c7b7aec8ecf6687b37f36544dccb4c8e4c4f5 +README.zh.md: d6a315acb42a82a926fc323370b52c1a1b61065e diff --git a/docs/persistence-changes/README.md b/docs/persistence-changes/README.md index 3fd95e4854..1e7c7b7aec 100644 --- a/docs/persistence-changes/README.md +++ b/docs/persistence-changes/README.md @@ -53,12 +53,15 @@ Every detected structural change requires an acknowledgement. Record creation an | Add an optional event-body property, including its complete subtree | `same-version` | | Make a required event-body property optional | `same-version` | | Add an ordinary event type | `same-version` | +| Add `unknown` mode to `subagent/catalog` using the one-shot fields, while preserving existing alternatives | `same-version` | | Add an explicitly qualified attribution kind to a user/developer source slot whose before and after schemas carry the same supported policy | `same-version` | | Make an optional property required, add a required property, change an existing type, or remove/rename a property or event | `version-bump` | | Change the Session header or event envelope | `version-bump` | A finalized checkpoint protects the accepted baseline without replacing these compatibility rules. At V4, optional additions, ordinary events, and qualified attribution additions can receive new same-version records. Breaking differences require a higher writer version and an acknowledgement containing its own header increase. The accepted V4 record cannot be updated to reuse its original 3→4 transition. +The catalog exception retains a historical child's identity when its descriptor is unavailable. It admits only an `unknown` alternative with the same fields as the one-shot alternative; existing alternatives still receive ordinary comparison. It does not permit other mode additions, removals, header changes, or arbitrary union extensions. Existing logs remain readable by the updated reader; older readers may reject the new mode. The [acknowledgement](2026-09-20-unknown-child-catalog.md) records this forward-read limitation. + The extractor accepts an explicit `@persistenceSource` binding for a core-owned source property on a literal user or developer role; it does not infer a binding from an unannotated type. A producer qualifies its `MessageSourceMap` entry with `@persistenceAttribution`. Qualification promises that an unknown kind and its JSON metadata survive reading without the producer, and that the kind imposes no validation, replay, or authority requirement. A producer may inspect its own kind to resume duplicate suppression; other readers must preserve and derive the recorded messages without that projection. The recorded schema retains the binding, policy version, literal `kind` discriminator, preservation promise, and qualified kind set. Inventory format 2 stores those promises; extraction without a bound policy retains format 1. Session format versions are independent. Both compared snapshots must carry compatible policy state. Existing kind groups still receive ordinary structural comparison; removals, unmarked additions, policy changes, and unrelated breaking changes remain strict. Multiple context-form alternatives with the same wire kind form one group. A same-version explanation states why old records can omit the addition and why older readers can ignore it without changing replay. For required-to-optional changes, it explains how readers handle an absent value. The checker validates the type classification; reviewers assess the explanation. A version-bump record includes the increasing header version in the same transition and follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md). diff --git a/docs/persistence-changes/README.zh.md b/docs/persistence-changes/README.zh.md index f4ab3b3d22..d6a315acb4 100644 --- a/docs/persistence-changes/README.zh.md +++ b/docs/persistence-changes/README.zh.md @@ -53,12 +53,15 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc | 添加可选事件体属性,包括其完整子树 | `same-version` | | 将必选事件体属性改为可选 | `same-version` | | 添加普通事件类型 | `same-version` | +| 为 `subagent/catalog` 增加使用 one-shot 字段的 `unknown` 模式,并保留既有分支 | `same-version` | | 在前后 schema 均带有相同受支持策略的 user/developer 消息源字段中,添加显式声明为归属信息的 kind | `same-version` | | 将可选属性改为必选、添加必选属性、更改已有类型,或删除/重命名属性或事件 | `version-bump` | | 更改会话头或事件封装 | `version-bump` | 定稿检查点保护已接受基线,不替换这些兼容性规则。在 V4 中,可选新增、普通事件及符合条件的归属 kind 新增可以使用新的同版本记录。破坏性差异要求更高的写入器版本,以及包含自身头版本递增的确认记录。不能更新已接受 V4 记录以复用其原有 3→4 转换。 +Catalog 例外在 descriptor 不可用时保留历史子会话身份。它仅允许增加字段与 one-shot 分支相同的 `unknown` 分支;已有分支仍执行常规比较。它不允许其他模式新增、删除、header 变更或任意联合类型扩展。新版读取器仍可读取已有日志;旧版读取器可能拒绝新模式。[确认记录](2026-09-20-unknown-child-catalog.zh.md) 说明这一向前读取限制。 + 提取器接受核心拥有的 source 属性上针对 user 或 developer 字面量角色的显式 `@persistenceSource` 绑定,不会从未标记类型中推断绑定。生产者用 `@persistenceAttribution` 标记其 `MessageSourceMap` 成员。该标记承诺:读取器无需生产者即可保留未知 kind 及其 JSON 元数据,且该 kind 不引入校验、回放或权限要求。生产者可以检查自身 kind 来恢复去重状态;其他读取器必须无需该投影也能保留并派生已记录的消息。记录的 schema 保存绑定、策略版本、字面量 `kind` 判别字段、保留承诺及符合条件的 kind 集合。Inventory format 2 保存这些承诺;没有绑定策略的提取仍使用 format 1。Session 格式版本独立于此。比较双方的快照必须带有兼容的策略状态。已有 kind 分组仍进行常规结构比较;删除、未标记的添加、策略更改及无关破坏性变更继续采用严格规则。同一 wire kind 的多个上下文形式分支归为一组。 同版本说明须解释旧记录为何可以缺少新增内容,以及旧读取器为何可以忽略它而不改变回放。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index 93eb20d961..f34e60f4e7 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -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 docs/persistence-changes/historical-formats/README.md -README.md: 386b0bd9091cbd509843bd6464c8a95650accb99 -README.zh.md: e93e8033daa639eee3a5035efc2df8ef123f563e +README.md: f4e721bbef9d442021226c573280edc193792662 +README.zh.md: be4b7c86b68fa682e7f8275f85a2548f39f30df9 diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 386b0bd909..f4e721bbef 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 63 / 587 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 585 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index e93e8033da..be4b7c86b6 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 63 / 587 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 585 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index ba5f5dbb0b..7456c002c0 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -14850,7 +14850,7 @@ "kind": "event", "event": "subagent/catalog", "surface": false, - "digest": "ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871", + "digest": "4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3", "schema": { "root": 0, "nodes": [ @@ -14864,7 +14864,7 @@ }, { "name": "ignorable", - "type": 9, + "type": 11, "optional": true }, { @@ -14879,7 +14879,7 @@ }, { "name": "type", - "type": 10, + "type": 12, "optional": false } ], @@ -14889,7 +14889,8 @@ "kind": "union", "types": [ 2, - 7 + 7, + 9 ] }, { @@ -14908,7 +14909,7 @@ { "name": "label", "type": 4, - "optional": false + "optional": true }, { "name": "mode", @@ -14931,139 +14932,6 @@ "kind": "primitive", "type": "string" }, - { - "kind": "literal", - "value": "continuable" - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": true - }, - { - "name": "mode", - "type": 8, - "optional": false - }, - { - "name": "version", - "type": 6, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "subagent/catalog" - } - ] - } - }, - { - "key": "event:subagent/catalog-unknown", - "kind": "event", - "event": "subagent/catalog-unknown", - "surface": false, - "digest": "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "data", - "type": 1, - "optional": false - }, - { - "name": "ignorable", - "type": 6, - "optional": true - }, - { - "name": "seq", - "type": 2, - "optional": false - }, - { - "name": "time", - "type": 2, - "optional": false - }, - { - "name": "type", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": true - }, - { - "name": "mode", - "type": 4, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, { "kind": "literal", "value": "unknown" @@ -15072,13 +14940,83 @@ "kind": "literal", "value": 0 }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 8, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, { "kind": "literal", "value": true }, { "kind": "literal", - "value": "subagent/catalog-unknown" + "value": "subagent/catalog" } ] } @@ -33635,6 +33573,145 @@ "packages/core/tools/src/types.ts:11" ] }, + { + "digest": "30a49d1a89bd03b8503fbdab94d4883e4ce839d931f29672ac070007cc0016ce", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "union", + "types": [ + 1, + 6, + 8 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 7, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 9, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + } + ] + }, + "names": [ + "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" + ], + "sources": [ + "packages/subagent/subagent/src/catalog.ts:24" + ] + }, { "digest": "30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f", "schema": { @@ -35345,109 +35422,6 @@ "packages/feedback/message-feedback/src/types.ts:17" ] }, - { - "digest": "3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "union", - "types": [ - 1, - 6 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": false - }, - { - "name": "mode", - "type": 4, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "continuable" - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": true - }, - { - "name": "mode", - "type": 7, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "one-shot" - } - ] - }, - "names": [ - "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" - ], - "sources": [ - "packages/subagent/subagent/src/catalog.ts:24" - ] - }, { "digest": "3b04790e08b35241ffa0d38f6d7e4f8788aa921341f6c0d7574a329397c59408", "schema": { @@ -44281,6 +44255,180 @@ "packages/workflow/workflow/src/types.ts:13" ] }, + { + "digest": "4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 11, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 12, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 7, + 9 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 8, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/catalog" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159", "schema": { @@ -70277,144 +70425,6 @@ "names": [], "sources": [] }, - { - "digest": "ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "data", - "type": 1, - "optional": false - }, - { - "name": "ignorable", - "type": 9, - "optional": true - }, - { - "name": "seq", - "type": 3, - "optional": false - }, - { - "name": "time", - "type": 3, - "optional": false - }, - { - "name": "type", - "type": 10, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "union", - "types": [ - 2, - 7 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": false - }, - { - "name": "mode", - "type": 5, - "optional": false - }, - { - "name": "version", - "type": 6, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "continuable" - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": true - }, - { - "name": "mode", - "type": 8, - "optional": false - }, - { - "name": "version", - "type": 6, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "subagent/catalog" - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "ae63d9d1080ed8945031594d93f8d96651d7101b848719cc3e1475875cd3d649", "schema": { @@ -73643,12 +73653,8 @@ } ] }, - "names": [ - "packages/subagent/subagent/src/catalog.ts#UnknownCatalogEvent" - ], - "sources": [ - "packages/subagent/subagent/src/catalog.ts:34" - ] + "names": [], + "sources": [] }, { "digest": "c8e6f48878cc2a2f0dc59682280d81f407943fb503f32c64bf9ff3d9393c3eff", @@ -77131,102 +77137,6 @@ "names": [], "sources": [] }, - { - "digest": "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "data", - "type": 1, - "optional": false - }, - { - "name": "ignorable", - "type": 6, - "optional": true - }, - { - "name": "seq", - "type": 2, - "optional": false - }, - { - "name": "time", - "type": 2, - "optional": false - }, - { - "name": "type", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": true - }, - { - "name": "mode", - "type": 4, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "unknown" - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "subagent/catalog-unknown" - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "d28765f87d6b6ab408e0b2f3e7e297791b004b806d8e302c49903ebae815feb2", "schema": { @@ -77241,20 +77151,6 @@ "names": [], "sources": [] }, - { - "digest": "d437ebdbc1839ffcf3f6fa201a003126ec63945186b0c0792f1db466bf8ebd89", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "literal", - "value": "subagent/catalog-unknown" - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d", "schema": { diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index 2679faecfb..adaca29255 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -58,7 +58,6 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'step/end', 'step/start', 'subagent/catalog', - 'subagent/catalog-unknown', 'subagent/descriptor', 'subagent/model-selection-policy', 'system/message', diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index 2015d47d2c..41aef742aa 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/packages/sdk/client/tests/sdk-client.spec.ts @@ -647,7 +647,7 @@ describe('HarnessClient', () => { const inject = (method: string, params: Record): void => { (client as unknown as { dispatchNotification(n: HarnessNotification): void }).dispatchNotification({ method, params }) } - const unknownChild = { type: 'subagent/catalog-unknown', seq: 0, time: 1, + const unknownChild = { type: 'subagent/catalog', seq: 0, time: 1, data: { version: 0, childId: 'unreadable-child', childCreatedAt: 1, mode: 'unknown' } } inject('session.event', { sessionId: 'root', event: unknownChild }) inject('subagent.started', { parentSessionId: 'root', childSessionId: 'child' }) diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index c5b3413814..b4b64bdc40 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -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/session/session-format-v3-to-v4/README.md -README.md: 6721d43e7653942b055443a2da99d7fca1d6fd57 -README.zh.md: 48b7e5cbd00a19a81730cbf1310e462196cd8ca1 +README.md: 1c558d34cc67fc94aafcaf23405b0b1f2a0efa85 +README.zh.md: 6c079269503d5e11a0bbb03e3460d5a5432fa9e5 diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 6721d43e76..1c558d34cc 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -148,9 +148,9 @@ There is no recursive source search. Captured request text, assistant replay sta | More than one own descriptor | Retain an existing parent entry without mode/label comparison; otherwise append unknown-mode membership. | | Existing own parent entry | Retain it and its extensions; require matching child creation time and mode/label from exactly one supported own descriptor, when available. | | Missing own parent entry with complete supported evidence | Append a version-0 catalog fact with child id, creation time, mode, and optional label. | -| Missing own parent entry without complete evidence | Append `subagent/catalog-unknown` with header identity and unknown mode, without inventing a label. | +| Missing own parent entry without complete evidence | Append `subagent/catalog` with header identity and unknown mode, without inventing a label. | -Catalog version 0 requires string `childId`, nonnegative safe-integer `childCreatedAt`, mode `continuable` or `one-shot`, and a string label for continuable mode; a present one-shot label must also be a string. Duplicate own child ids are refused. Existing entries without a corresponding retained child remain in the parent. Descriptor collection does not restore a child's old continuation composition or recover deleted children from tool arguments. `subagent/catalog-unknown` separately records version 0, child id, creation time, and `mode: 'unknown'`; both event types contribute to the same unique-child catalog. Existing `subagent/catalog` payloads retain their mode requirements. +Catalog version 0 requires string `childId`, nonnegative safe-integer `childCreatedAt`, mode `continuable`, `one-shot`, or `unknown`, and a string label for continuable mode; a present label in any mode must also be a string. Duplicate own child ids are refused. Existing entries without a corresponding retained child remain in the parent. Descriptor collection does not restore a child's old continuation composition or recover deleted children from tool arguments. Unknown-mode entries retain header identity without asserting a supported child descriptor. The stage considers parent catalog records only after the final inherited cut. Every inherited marker discards earlier catalog candidates without interpreting their payloads. Missing entries append after all source events, sorted by creation time then child id, with dense new sequences. Their time is the final source event's time, or header creation time for an empty log. They neither enter the model surface nor change the inherited count. @@ -243,7 +243,7 @@ Current common admission does not validate each user/tool/developer content bloc | `compaction/start`, `compaction/summary`, `compaction/end` | Match compaction id, source command, and the active turn context. Summary spans name exact current-surface nodes and exclude the protected head; successful completion has one summary. Inherited unfinished compactions expire at the end-seed marker. | | `compaction/prune` | Its span names exact current-surface nodes and excludes the protected head; it does not require a compaction transaction or its owner fields. | | Compact checkpoint replacement | Its `compact-checkpoint` source identifies the active compaction. | -| Native `subagent/catalog` and `subagent/catalog-unknown` | Check own version-0 payload fields and unique child ids after the inherited cut. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. | +| Native `subagent/catalog` | Check own version-0 payload fields and unique child ids after the inherited cut. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. | | Inherited cut and delivery | Apply the marker, coordinate, and generation-ownership rules stated above. | These checks are generation-owned in [relationships.ts](src/relationships.ts). Full common message/envelope acceptance and plugin-owned message projections additionally use the installed Session; the exported V4 restorer alone is not a replacement for complete catalog restoration. diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index 48b7e5cbd0..6c07926950 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -148,9 +148,9 @@ V3 未知内容标签变为 `plugin:`,其他字段原样保留 | 多个自身 descriptor | 保留已有父目录项,不比较 mode/label;否则追加模式未知的目录项。 | | 已有自身父目录项 | 保留条目及其扩展;要求子创建时间一致,并在恰好一个受支持的自身 descriptor 可用时比较其 mode/label。 | | 缺少自身父目录项,且受支持证据完整 | 追加带 child id、创建时间、mode 和可选 label 的 version-0 目录事实。 | -| 缺少自身父目录项,且证据不完整 | 追加 `subagent/catalog-unknown`,记录 header 身份和未知模式,不编造标签。 | +| 缺少自身父目录项,且证据不完整 | 追加 `subagent/catalog`,记录 header 身份和未知模式,不编造标签。 | -Catalog version 0 要求字符串 `childId`、非负安全整数 `childCreatedAt`、`continuable` 或 `one-shot` mode,以及 continuable mode 下的字符串 label;存在的 one-shot label 也必须是字符串。重复的自身 child id 被拒绝。没有对应保留子日志的已有条目仍保留在父日志中。Descriptor 收集不恢复子级的旧 continuation composition,也不从工具参数恢复已删除子级。 `subagent/catalog-unknown` 单独记录 version 0、child id、创建时间和 `mode: 'unknown'`;两种事件共同组成 child id 唯一的目录。已有 `subagent/catalog` 载荷保留其 mode 要求。 +Catalog version 0 要求字符串 `childId`、非负安全整数 `childCreatedAt`、`continuable`、`one-shot` 或 `unknown` mode,以及 continuable mode 下的字符串 label;任何 mode 下存在的 label 都必须是字符串。重复的自身 child id 被拒绝。没有对应保留子日志的已有条目仍保留在父日志中。Descriptor 收集不恢复子级的旧 continuation composition,也不从工具参数恢复已删除子级。未知模式条目保留 header 身份信息,不声明子 descriptor 受支持。 Stage 只把最终继承截点之后的父目录记录作为候选。每个 inherited marker 都会丢弃更早的目录候选,不解释其载荷。缺失项追加在所有源事件之后,按创建时间、child id 排序,并使用连续的新序号。时间取最后一个源事件的 time;空日志则取 header 创建时间。这些记录既不进入模型表面,也不改变继承计数。 @@ -243,7 +243,7 @@ System image 接纳要求非空 attachment id、PNG/JPEG/WebP/GIF MIME 类 | `compaction/start`、`compaction/summary`、`compaction/end` | 匹配 compaction id、源 command 和活动 turn 上下文。Summary 区间引用精确的当前表面节点且排除 protected head;成功完成需要一个 summary。继承的未完成 compaction 在 end-seed marker 处过期。 | | `compaction/prune` | 其区间引用精确的当前表面节点且排除 protected head;它不要求存在 compaction 事务或其所有者字段。 | | Compact checkpoint 替换 | 其 `compact-checkpoint` 来源标识活动 compaction。 | -| 原生 `subagent/catalog` 与 `subagent/catalog-unknown` | 检查继承截点之后的自身 version-0 载荷字段与 child id 唯一性。原生读取既不收集子日志,也不比较其物理事实;继承项不建立自身成员关系。 | +| 原生 `subagent/catalog` | 检查继承截点之后的自身 version-0 载荷字段与 child id 唯一性。原生读取既不收集子日志,也不比较其物理事实;继承项不建立自身成员关系。 | | 继承截点与 delivery | 应用上文的 marker、坐标及代际归属规则。 | 这些检查由 [relationships.ts](src/relationships.ts) 按代际拥有。完整的通用消息/信封接纳与插件拥有的消息投影还使用已安装 Session;单独的导出 V4 恢复器不能替代完整 catalog 恢复。 diff --git a/packages/session/session-format-v3-to-v4/src/facts.ts b/packages/session/session-format-v3-to-v4/src/facts.ts index a250801759..2fb476377b 100644 --- a/packages/session/session-format-v3-to-v4/src/facts.ts +++ b/packages/session/session-format-v3-to-v4/src/facts.ts @@ -52,6 +52,9 @@ export function childCatalogFact(source: SessionFormatJsonObject): SessionFormat if (typeof descriptor['provider'] !== 'string') { throw new SessionFormatUnsupportedMigrationError(`${childCatalogSubject(source)} has an invalid subagent descriptor provider`) } + if (descriptor['version'] !== 1 && descriptor['mode'] !== 'continuable' && descriptor['mode'] !== 'one-shot') { + throw new SessionFormatUnsupportedMigrationError(`${childCatalogSubject(source)} has an invalid subagent descriptor mode`) + } return catalogFact({ version: 0, childId: id, childCreatedAt: source['childCreatedAt'] as number, // V1 described only continuable children and carried no mode field. @@ -69,25 +72,10 @@ export function childCatalogFact(source: SessionFormatJsonObject): SessionFormat export function catalogFact(value: SessionFormatJsonValue, subject = 'subagent/catalog'): SessionFormatJsonObject { if (!isSessionFormatJsonObject(value) || value['version'] !== 0 || typeof value['childId'] !== 'string' - || (value['mode'] !== 'continuable' && value['mode'] !== 'one-shot') + || (value['mode'] !== 'continuable' && value['mode'] !== 'one-shot' && value['mode'] !== 'unknown') || (value['mode'] === 'continuable' && typeof value['label'] !== 'string') || (value['label'] !== undefined && typeof value['label'] !== 'string')) { - throw new SessionFormatError(`${subject} requires a complete version 0 catalog fact`) - } - sessionFormatCount(value['childCreatedAt'], 'catalog child creation time') - return value -} - -/** - * Validate a current catalog entry retained without a supported child descriptor. - * @param value - decoded unknown-child payload. - * @returns the validated identity with unknown mode. - */ -export function unknownCatalogFact(value: SessionFormatJsonValue): SessionFormatJsonObject { - if (!isSessionFormatJsonObject(value) || value['version'] !== 0 - || typeof value['childId'] !== 'string' || value['mode'] !== 'unknown' - || (value['label'] !== undefined && typeof value['label'] !== 'string')) { - throw new SessionFormatError('subagent/catalog-unknown requires a version 0 identity with unknown mode') + throw new SessionFormatError(`${subject} requires a version 0 catalog fact`) } sessionFormatCount(value['childCreatedAt'], 'catalog child creation time') return value diff --git a/packages/session/session-format-v3-to-v4/src/migration.ts b/packages/session/session-format-v3-to-v4/src/migration.ts index 2184f5b819..50c796a1df 100644 --- a/packages/session/session-format-v3-to-v4/src/migration.ts +++ b/packages/session/session-format-v3-to-v4/src/migration.ts @@ -124,7 +124,7 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage { } const entry = fact ?? { version: 0, childId: id, childCreatedAt: source['childCreatedAt'] as number, mode: 'unknown' } existingCatalogs.set(id, entry) - context.emitEvent({ type: fact === undefined ? 'subagent/catalog-unknown' : 'subagent/catalog', + context.emitEvent({ type: 'subagent/catalog', seq: this.nextSeq++, time: this.time, data: entry }) } return cut diff --git a/packages/session/session-format-v3-to-v4/src/validation.ts b/packages/session/session-format-v3-to-v4/src/validation.ts index 4b9df349cc..8c0e74d61d 100644 --- a/packages/session/session-format-v3-to-v4/src/validation.ts +++ b/packages/session/session-format-v3-to-v4/src/validation.ts @@ -6,7 +6,7 @@ import type { SessionFormatArtifact, SessionFormatEvent } from '@deepseek-ai/dsh import { assertV4DeveloperData } from './developer.ts' import { assertV4LifecycleRelationships } from './relationships.ts' import { assertV4MessageSources } from './message-sources.ts' -import { catalogFact, unknownCatalogFact } from './facts.ts' +import { catalogFact } from './facts.ts' import { assertV4RetiredSyntax } from './retired-syntax.ts' import { assertV4SystemMessageFields } from './system-message.ts' import { assertV4ForkResult } from './fork-result.ts' @@ -119,8 +119,8 @@ export function assertReleasedV4Relationships(artifact: SessionFormatArtifact, k && deliveryId !== artifact.header.id) { throw new SessionFormatError('current-generation delivery marker names the wrong Session') } - if ((event.type === 'subagent/catalog' || event.type === 'subagent/catalog-unknown') && event.seq >= artifact.inheritedEventCount) { - const fact = event.type === 'subagent/catalog' ? catalogFact(event.data) : unknownCatalogFact(event.data) + if (event.type === 'subagent/catalog' && event.seq >= artifact.inheritedEventCount) { + const fact = catalogFact(event.data) const id = fact['childId'] as string if (ids.has(id)) throw new SessionFormatError(`duplicate catalog child ${id}`) ids.add(id) diff --git a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts index 73733b0afc..f533b9d502 100644 --- a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts @@ -67,14 +67,14 @@ describe('V3 parent catalog completion', () => { expect(migrate(source, [child], true).events).toEqual([ ...source, { type: 'subagent/catalog', seq: 4, time: 2, data: child }, ]) - expect(() => migrate([...source, { ...opaque, seq: 4 }], [], true)).toThrow('complete version 0') - expect(() => migrate([opaque], [])).toThrow('complete version 0') + expect(() => migrate([...source, { ...opaque, seq: 4 }], [], true)).toThrow('version 0') + expect(() => migrate([opaque], [])).toThrow('version 0') }) it('requires explicit corpus facts and refuses conflicting identities', () => { expect(() => sessionFormatV3ToV4.createStage({ sourceHeader: header, targetHeader: { ...header, version: 4 }, sourceKind: 'decoded', sourceInheritedEventCount: 0 })).toThrow('explicit historical child facts') - expect(() => migrate([], [{ ...child, mode: 'invalid' }])).toThrow('complete version 0') + expect(() => migrate([], [{ ...child, mode: 'invalid' }])).toThrow('version 0') expect(() => migrate([{ type: 'subagent/catalog', seq: 0, time: 2, data: child }], [{ ...child, childCreatedAt: 3 }])).toThrow('conflicts') }) @@ -148,7 +148,7 @@ describe('V3 parent catalog completion', () => { const unavailable = { childId: 'unavailable', childCreatedAt: 1, descriptorCount: kind === 'missing' ? 0 : kind === 'multiple' ? 2 : 1, descriptor } expect(migrate([], [child, unavailable]).events).toEqual([ - { type: 'subagent/catalog-unknown', seq: 0, time: 1, data: { version: 0, childId: 'unavailable', childCreatedAt: 1, mode: 'unknown' } }, + { type: 'subagent/catalog', seq: 0, time: 1, data: { version: 0, childId: 'unavailable', childCreatedAt: 1, mode: 'unknown' } }, { type: 'subagent/catalog', seq: 1, time: 1, data: child }, ]) }) diff --git a/packages/session/session-format-v3-to-v4/tests/validation.spec.ts b/packages/session/session-format-v3-to-v4/tests/validation.spec.ts index a0e1f9e9de..7bd8b24539 100644 --- a/packages/session/session-format-v3-to-v4/tests/validation.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/validation.spec.ts @@ -114,20 +114,19 @@ describe('restoreReleasedV4Artifact', () => { }) it('retains unknown child membership and rejects invalid identities or duplicate child ids', () => { - const known = new Set([...KNOWN, 'subagent/catalog', 'subagent/catalog-unknown']) + const known = new Set([...KNOWN, 'subagent/catalog']) const data = { version: 0, childId: 'unreadable', childCreatedAt: 2, mode: 'unknown' } - const event = { type: 'subagent/catalog-unknown', seq: 0, time: 1, data } + const event = { type: 'subagent/catalog', seq: 0, time: 1, data } const valid = artifact([event]) expect(restoreReleasedV4Artifact(valid, known)).toBe(valid) expect(() => restoreReleasedV4Artifact(artifact([{ ...event, data: { ...data, label: 'retained' } }]), known)).not.toThrow() for (const invalid of [null, { ...data, version: 1 }, { ...data, childId: 1 }, - { ...data, childCreatedAt: -1 }, { ...data, mode: 'one-shot' }, { ...data, label: null }]) { + { ...data, childCreatedAt: -1 }, { ...data, mode: 'invalid' }, { ...data, label: null }]) { expect(() => restoreReleasedV4Artifact(artifact([{ ...event, data: invalid }]), known)).toThrow() } expect(() => restoreReleasedV4Artifact(artifact([event, { ...event, seq: 1 }]), known)).toThrow('duplicate catalog child') expect(() => restoreReleasedV4Artifact(artifact([event, { type: 'subagent/catalog', seq: 1, time: 1, data: { ...data, mode: 'one-shot' } }]), known)).toThrow('duplicate catalog child') - expect(() => restoreReleasedV4Artifact(artifact([{ ...event, type: 'subagent/catalog' }]), known)).toThrow('complete version 0') }) it('validates only the envelope in physical mode', () => { diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 166eaa18dc..4f0d2c2fe3 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -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/session/session-persistence-jsonl/README.md -README.md: 066b43d6a0f7c0e29b8c2ebbba17fa460103187a -README.zh.md: 61693b96d904b8d2c3d679c065f3667eb3bdc46e +README.md: 609368aa42f1ba75d818a74877fcd574bf9d5ddf +README.zh.md: 07c0dc94270ddb69812ec8443688a505269eba59 diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 066b43d6a0..609368aa42 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -81,7 +81,7 @@ The current-generation scanner applies the current codec owner’s structural ad `open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. -Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It finds candidate direct children from headers, reads each child's own descriptor through historical codecs, and retains compact evidence plus source revisions. It neither prepares child catalogs nor publishes child successors. Unreadable or unsupported headers are omitted from discovery. Child decoding and descriptor-field failures produce warnings naming the child path and retain header identity through `subagent/catalog-unknown` when the parent has no complete entry; healthy children and existing parent catalog entries remain available. Opening a damaged child still reports that child's error. Missing, unsupported, or multiple descriptors likewise produce unknown-mode membership without inventing a label. Published unknown entries remain browsable; child history reads retry the actual log and resolve its mode from a valid descriptor. Preparation rechecks membership and inspected source revisions before returning, reuse, and publication, including failed child reads so a repaired child invalidates stale preparation. Source drift retries a read open once and refuses a write open. Cancellation still aborts the operation. Current V4 opens bypass discovery and validate catalog fields, uniqueness, and current delivery ownership before exposing events. +Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It finds candidate direct children from headers, reads each child's own descriptor through historical codecs, and retains compact evidence plus source revisions. It neither prepares child catalogs nor publishes child successors. Unreadable or unsupported headers are omitted from discovery. Child decoding and descriptor-field failures produce warnings naming the child path and retain header identity through `subagent/catalog` when the parent has no complete entry; healthy children and existing parent catalog entries remain available. Opening a damaged child still reports that child's error. Missing, unsupported, or multiple descriptors likewise produce unknown-mode membership without inventing a label. Published unknown entries remain browsable; child history reads retry the actual log and resolve its mode from a valid descriptor. Preparation rechecks membership and inspected source revisions before returning, reuse, and publication, including failed child reads so a repaired child invalidates stale preparation. Source drift retries a read open once and refuses a write open. Cancellation still aborts the operation. Current V4 opens bypass discovery and validate catalog fields, uniqueness, and current delivery ownership before exposing events. Historical `stat` and `list` revisions require metadata work proportional to the root’s Session count. Fresh body preparation scans all selected headers and decodes direct-child bodies; memo reuse still scans membership and checks revisions. Read-only access never publishes an upgrade, so cold processes and evicted preparations repeat that work. Current V4 body reads and revisions avoid the historical corpus scan. See the [measured costs and diagnostic command](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md#catalog-scan-measurements). diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 61693b96d9..07c0dc9427 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -81,7 +81,7 @@ kind: "package-reference" `open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;每个嵌套对象和数组都会冻结,句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 -历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:从 header 找到候选直属子 Session,通过历史编解码器逐个读取其自身 descriptor,保留紧凑证据与来源修订。此过程不准备子目录,也不发布子后继。不可读或不支持的 header 不参与发现。子日志解码或 descriptor 字段失败会产生带子路径的警告;父目录没有完整条目时,通过 `subagent/catalog-unknown` 保留 header 身份信息。健康子项和已有父目录项仍可使用。打开损坏子 Session 时仍报告该子会话的错误。缺失、不支持或多个 descriptor 同样生成模式未知的目录项,不编造标签。已发布的未知条目仍可浏览;读取子历史时会重试实际日志,并从有效 descriptor 确定模式。准备返回、复用与发布前会重新检查成员集合及已检查来源的修订,也包括读取失败的子日志,使修复后的子日志能够使旧准备缓存失效。来源变化时只读打开重试一次,写打开拒绝发布。取消仍会中止操作。当前 V4 打开跳过发现,并在暴露事件前校验目录字段、唯一性及当前投递归属。 +历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:从 header 找到候选直属子 Session,通过历史编解码器逐个读取其自身 descriptor,保留紧凑证据与来源修订。此过程不准备子目录,也不发布子后继。不可读或不支持的 header 不参与发现。子日志解码或 descriptor 字段失败会产生带子路径的警告;父目录没有完整条目时,通过 `subagent/catalog` 保留 header 身份信息。健康子项和已有父目录项仍可使用。打开损坏子 Session 时仍报告该子会话的错误。缺失、不支持或多个 descriptor 同样生成模式未知的目录项,不编造标签。已发布的未知条目仍可浏览;读取子历史时会重试实际日志,并从有效 descriptor 确定模式。准备返回、复用与发布前会重新检查成员集合及已检查来源的修订,也包括读取失败的子日志,使修复后的子日志能够使旧准备缓存失效。来源变化时只读打开重试一次,写打开拒绝发布。取消仍会中止操作。当前 V4 打开跳过发现,并在暴露事件前校验目录字段、唯一性及当前投递归属。 历史格式的 `stat` 与 `list` 修订号需要与根目录 Session 数量成正比的元数据工作。首次正文准备扫描所有所选 header 并解码直属子正文;复用准备缓存仍扫描成员集合并检查修订。只读访问从不发布升级,因此冷进程与被淘汰的准备缓存会重复这些工作。当前 V4 正文读取和修订号避开历史全库扫描。见[实测成本与诊断命令](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md#catalog-scan-measurements)。 diff --git a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts index 028edc5d1b..e4c6d677a9 100644 --- a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts @@ -120,7 +120,7 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', const catalog = { type: 'subagent/catalog', seq: 0, time: 1, data: { version: 0, childId: 'grandchild', childCreatedAt: 3, mode: 'one-shot' } } const path = await f.write('child', [catalog, { ...catalog, seq: 1 }], true, 4) - expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) expect(warn).toHaveBeenCalledWith(expect.stringContaining(path)) await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) }) @@ -137,7 +137,7 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', await f.write('healthy', [f.descriptor], true) const parent = await f.ctx.sessionPersistence.open(f.parent, access) try { expect((await parent.read()).events).toMatchObject([ - { type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }, + { type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }, { type: 'subagent/catalog', data: { childId: 'healthy', mode: 'continuable' } }, ]) } finally { await parent.close() } await expect(f.ctx.sessionPersistence.open(SessionId('child'), access)).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) @@ -170,7 +170,7 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', const f = await fixture() const childPath = await f.write('child', [], true) await writeFile(childPath, Buffer.concat([await readFile(childPath), Buffer.alloc(8)])) - expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) }) @@ -208,10 +208,10 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', const childPath = await f.write('child', events, true) const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) const original = await Promise.all([readFile(parentPath), readFile(childPath)]) - expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') - try { expect((await writer.read()).events).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) } finally { await writer.close() } - expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) + try { expect((await writer.read()).events).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) } finally { await writer.close() } + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) const child = await f.ctx.sessionPersistence.open(SessionId('child'), 'read') try { expect((await child.read()).events).toEqual(events) } finally { await child.close() } expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original) @@ -240,7 +240,7 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', it('retains unknown child identity without migrating unsupported contents', async () => { const f = await fixture() await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) - expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionFormatUnsupportedError) }) @@ -248,7 +248,7 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', const f = await fixture() const warn = vi.spyOn(f.ctx.logger, 'warn') const path = await f.write('child', [{ ...f.descriptor, data: { version: 2, provider: 'spawn', mode: 'unknown' } }], true) - expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) expect(warn).toHaveBeenCalledWith(expect.stringContaining(path)) }) @@ -283,7 +283,7 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', it('recollects newly available discovery fields before publishing a prepared parent', async () => { const f = await fixture() const childPath = await f.write('child', [], true) - expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) await f.write('child', [f.descriptor], true) await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(childPath) const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) @@ -383,7 +383,7 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', it('recollects a repaired child before publishing a prepared parent', async () => { const f = await fixture() await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) - expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) await f.write('child', [f.descriptor], true) expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child' } }]) const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 37e5596037..25cea24568 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -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/subagent/subagent/README.md -README.md: 94da1be172b441aec1fd16d3853596eaee74ca1b -README.zh.md: 462bc3aff2155b96f87d9f69c5aae1aa5002a9ca +README.md: 75237c6eda835459015d5b2f64402190d27d72af +README.zh.md: 187d26ea3a4261c4ddeed96cd6c41f50fbd48c91 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 94da1be172..75237c6eda 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -109,7 +109,7 @@ A request is validated against the provider's advertised capabilities, a durable The manager reserves a child identity, resolves the durable descriptor, creates (or cold-resumes) the child Agent, installs it in an Activation, and submits the prompt. Model-authored messages cross one parent/child edge through fixed Steer scheduling; browser human prompts choose Queue or best-effort Steer through an internal adapter, while other host protocols may retain Queue for distinct turns. A Session queue command admits a live subagent-owned Agent only from its own continuable descriptor. Settlement waits for Agent activity to finish, an empty Inbox, and no owned children, then flushes final Session state with admission open. Under the child lock, the manager revalidates the wake generation, Session sequence, Inbox, and owned children; the synchronous task entry of `Agent.runMaintenance()` claims the idle phase and closes the private subagent Inbox in the same JavaScript turn before handle disposal. An absent direct-child Activation cold-resumes from the persisted session. When a resident Activation settles, the manager tells the child's direct parent in the parent's own turn stream. -Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Each child's `subagentTiming` projection accumulates post-descriptor duration and records whether its latest closed turn ended with `completed`, clearing that completion when another turn opens. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Projection state-version changes refold cached rows from the durable log. The catalog view preserves parent event order in O(D) time for D facts, and its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives. Historical migration may instead append `subagent/catalog-unknown` from a readable child header. Its `mode: 'unknown'` projection keeps the child visible without claiming continuation support; an existing complete entry remains authoritative. +Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Each child's `subagentTiming` projection accumulates post-descriptor duration and records whether its latest closed turn ended with `completed`, clearing that completion when another turn opens. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Projection state-version changes refold cached rows from the durable log. The catalog view preserves parent event order in O(D) time for D facts, and its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives. Historical migration may instead append `subagent/catalog` from a readable child header. Its `mode: 'unknown'` projection keeps the child visible without claiming continuation support; an existing complete entry remains authoritative. ### Ownership and invariants diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 462bc3aff2..187d26ea3a 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -109,7 +109,7 @@ kind: "package-reference" 管理器预留 child 身份、解析持久化描述符、创建(或冷恢复)child、把它安装进 Activation 并提交提示词。模型编写的消息通过固定 Steer 调度跨一条 parent/child 边;浏览器人类 prompt 通过内部适配器选择 Queue 或 best-effort Steer,其他 host 协议仍可保留 Queue 以创建独立轮次。Session queue command 仅根据 child 自身的 continuable descriptor 准入在线 subagent-owned Agent。Settlement 会等待 Agent 活动结束、Inbox 为空且没有所拥有子级,再在准入开放时 flush 最终 Session 状态。管理器随后在 child lock 内重新验证 wake generation、Session 序号、Inbox 与所拥有子级;`Agent.runMaintenance()` 的同步 task 入口会占用 idle 阶段,并在同一个 JavaScript turn 内关闭私有 subagent Inbox,然后才释放句柄。直接 child 不存在 Activation 时会从持久化会话冷恢复。当驻留 Activation 结算时,管理器会在 parent 自身的轮次流中告知该 child 的直接 parent。 -本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在提供方返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。每个 child 的 `subagentTiming` projection 会累加 descriptor 之后的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次打开时会清除该完成状态。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。projection 状态版本变更会从持久日志重新折叠缓存行。目录视图对 D 条事实以 O(D) 时间保留父目录事件顺序,其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。 历史迁移可根据可读子 header 追加 `subagent/catalog-unknown`。其 `mode: 'unknown'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。 +本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在提供方返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。每个 child 的 `subagentTiming` projection 会累加 descriptor 之后的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次打开时会清除该完成状态。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。projection 状态版本变更会从持久日志重新折叠缓存行。目录视图对 D 条事实以 O(D) 时间保留父目录事件顺序,其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。 历史迁移可根据可读子 header 追加 `subagent/catalog`。其 `mode: 'unknown'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。 ### 所有权与不变式 diff --git a/packages/subagent/subagent/src/catalog.ts b/packages/subagent/subagent/src/catalog.ts index 2fbc828892..15ec24dd17 100644 --- a/packages/subagent/subagent/src/catalog.ts +++ b/packages/subagent/subagent/src/catalog.ts @@ -20,7 +20,7 @@ import type { SubagentCatalogEntry } from './projection-types.ts' /** Current payload version for `subagent/catalog` events. */ export const SUBAGENT_CATALOG_VERSION = 0 -/** One complete parent-owned catalog fact. */ +/** One parent-owned catalog fact with known or unknown child mode. */ export type SubagentCatalogEvent = & { readonly version: 0 @@ -29,35 +29,23 @@ export type SubagentCatalogEvent = } & ( | { readonly mode: 'one-shot'; readonly label?: string } | { readonly mode: 'continuable'; readonly label: string } + | { readonly mode: 'unknown'; readonly label?: string } ) -type UnknownCatalogEvent = { - readonly version: 0 - readonly childId: SessionId - readonly childCreatedAt: number - readonly mode: 'unknown' - readonly label?: string -} - declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** - * A direct child's complete discovery fact. + * A direct child's identity and available discovery fields. * @param data - versioned parent-owned catalog entry. */ 'subagent/catalog': SubagentCatalogEvent - /** - * A historical child's header identity when its descriptor cannot establish a mode. - * @param data - child identity retained for discovery and later history reads. - */ - 'subagent/catalog-unknown': UnknownCatalogEvent } } /** Host fold state for one parent catalog. */ export interface SubagentCatalogState { readonly inheritedEventCount: SessionLogOffset - readonly head?: ChunkedList | undefined + readonly head?: ChunkedList | undefined } const sessionIdSchema = z.string() as unknown as z.ZodType @@ -76,12 +64,11 @@ const continuableCatalogSchema = z.object({ label: z.string(), }).strict() const unknownCatalogSchema = oneShotCatalogSchema.extend({ mode: z.literal('unknown') }) -const completeCatalogSchema = z.union([oneShotCatalogSchema, continuableCatalogSchema]) const eventDataSchema = z.union([ oneShotCatalogSchema, continuableCatalogSchema, unknownCatalogSchema, -]) as unknown as z.ZodType +]) as unknown as z.ZodType const viewSchema = z.array(z.union([ oneShotCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({ id: sessionIdSchema, @@ -138,10 +125,8 @@ export const subagentCatalogProjectionDefinition = { stateSchema, init: (_header: SessionHeader, inheritedEventCount: SessionLogOffset) => ({ inheritedEventCount }), apply: (state, event: SessionEvent) => { - if ((event.type !== 'subagent/catalog' && event.type !== 'subagent/catalog-unknown') || event.seq < state.inheritedEventCount) return state - const data = (event.type === 'subagent/catalog-unknown' ? unknownCatalogSchema : completeCatalogSchema) - .parse(event.data) as SubagentCatalogEvent | UnknownCatalogEvent - return { ...state, head: appendChunkedList(state.head, data) } + if (event.type !== 'subagent/catalog' || event.seq < state.inheritedEventCount) return state + return { ...state, head: appendChunkedList(state.head, eventDataSchema.parse(event.data)) } }, stateVersion: 3, wire: { viewSchema, view: subagentCatalogEntries }, diff --git a/packages/subagent/subagent/tests/catalog.spec.ts b/packages/subagent/subagent/tests/catalog.spec.ts index f8450cd93d..589e336c88 100644 --- a/packages/subagent/subagent/tests/catalog.spec.ts +++ b/packages/subagent/subagent/tests/catalog.spec.ts @@ -107,7 +107,7 @@ describe('subagent catalog projection', () => { fact(0, 'child-b', 1, { mode: 'one-shot' }), fact(1, 'child-a', 1, { mode: 'one-shot', label: 'once' }), fact(2, 'child-d', 3, { mode: 'continuable', label: 'later' }), - { type: 'subagent/catalog-unknown', seq: SessionSeq(3), time: 0, + { type: 'subagent/catalog', seq: SessionSeq(3), time: 0, data: { version: 0, childId: SessionId('unreadable'), childCreatedAt: 4, mode: 'unknown' } }, ] const state = fold(events) diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index cb889a6a6f..78178c1e18 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -596,7 +596,7 @@ def test_session_subscription_keeps_descendant_relationships_across_subscription client = HarnessClient() with client.subscribe_session_notifications("main") as first: unknown_child = { - "type": "subagent/catalog-unknown", "seq": 0, "time": 1, + "type": "subagent/catalog", "seq": 0, "time": 1, "data": {"version": 0, "childId": "unreadable-child", "childCreatedAt": 1, "mode": "unknown"}, } client._handle_message({ diff --git a/scripts/persistence-changes.spec.ts b/scripts/persistence-changes.spec.ts index 54b5a9588f..0a3be90d70 100644 --- a/scripts/persistence-changes.spec.ts +++ b/scripts/persistence-changes.spec.ts @@ -397,7 +397,60 @@ function unionBody(arms: readonly (readonly SchemaProperty[])[]): PersistenceRoo return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) } } +function catalogModeRoot(unknownMode?: string, changeLabel = false, extraField = false): PersistenceRoot { + const snapshot = parsePersistenceSnapshot(JSON.parse(readFileSync( + new URL('../docs/persistence-changes/2026-09-11-initial.schema.json', import.meta.url), 'utf8', + ))) + const root = snapshot.roots.find(root => root.key === 'event:subagent/catalog')! + const nodes: SchemaNode[] = [...structuredClone(root.schema.nodes)] + const union = nodes.find(node => node.kind === 'union')! + if (union.kind !== 'union') throw new Error('catalog fixture requires mode alternatives') + const oneShot = union.types.find((index) => { + const node = nodes[index] + if (node?.kind !== 'object') return false + const mode = nodes[node.properties.find(property => property.name === 'mode')!.type] + return mode?.kind === 'literal' && mode.value === 'one-shot' + })! + const variant = nodes[oneShot]! + if (variant.kind !== 'object') throw new Error('catalog fixture requires object alternatives') + if (unknownMode !== undefined) { + const index = nodes.length + nodes.push({ ...variant, properties: variant.properties.map(property => property.name === 'mode' + ? { ...property, type: index + 1 } : property) }, { kind: 'literal', value: unknownMode }) + const added = nodes[index]! + if (extraField && added.kind === 'object') nodes[index] = { ...added, + properties: [...added.properties, { name: 'extra', type: variant.properties[0]!.type, optional: false }] } + nodes[nodes.indexOf(union)] = { ...union, types: [...union.types, index] } + } + if (changeLabel) nodes[oneShot] = { ...variant, properties: variant.properties.filter(property => property.name !== 'label') } + const schema = canonicalizeSchema(nodes, 0) + return { ...root, schema, digest: schemaDigest(schema) } +} + describe('persistence change classification', () => { + it('acknowledges unknown catalog mode at V4 while retaining the accepted checkpoint', () => { + const root = fixture() + const before = { ...inventory({}, 4), roots: [...inventory({}, 4).roots.filter(root => root.kind !== 'event'), catalogModeRoot()] } + finalize(root, before) + const after = { ...before, roots: before.roots.map(root => root.kind === 'event' ? catalogModeRoot('unknown') : root) } + expect(classifyPersistenceChange(catalogModeRoot(), catalogModeRoot('unknown'))) + .toEqual([expect.objectContaining({ path: 'event:subagent/catalog.data', requiresVersionBump: false })]) + runPersistenceChanges(['--record', COMPATIBLE_ID, '--prose', proseFile(root)], root, () => after) + verifyPersistenceChanges(root, after) + }) + + it('keeps other catalog modes, altered fields, classification changes, and removals breaking', () => { + const before = catalogModeRoot() + for (const after of [catalogModeRoot('other'), catalogModeRoot('unknown', true), catalogModeRoot('unknown', false, true)]) { + expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true) + } + const expanded = catalogModeRoot('unknown') + expect(classifyPersistenceChange(expanded, before).some(change => change.requiresVersionBump)).toBe(true) + expect(classifyPersistenceChange(before, { ...expanded, surface: true }).some(change => change.requiresVersionBump)).toBe(true) + expect(classifyPersistenceChange({ ...before, key: 'event:other' }, { ...expanded, key: 'event:other' }) + .some(change => change.requiresVersionBump)).toBe(true) + }) + it('treats a new optional payload subtree as one additive change even with required descendants', () => { const before = typeRoot('event:example/value', { value: 'string' }) const after = typeRoot('event:example/value', { value: 'string', 'details?': { name: 'string', count: 'number' } }) diff --git a/scripts/persistence-changes.ts b/scripts/persistence-changes.ts index 65f17b279f..e53092e921 100644 --- a/scripts/persistence-changes.ts +++ b/scripts/persistence-changes.ts @@ -471,6 +471,29 @@ export function classifyPersistenceChange(before: PersistenceRoot | null, after: function compareAlternatives( oldTypes: readonly number[], newTypes: readonly number[], path: string, scope: Scope, active: ReadonlySet, ): PersistenceTypeChange[] { + if (key === 'event:subagent/catalog' && path === `${key}.data` && scope === 'body' + && oldTypes.length === 2 && newTypes.length === 3) { + const modeNode = (index: number): number | undefined => { + const node = newRoot.schema.nodes[index] + return node?.kind === 'object' ? node.properties.find(property => property.name === 'mode' && !property.optional)?.type : undefined + } + const mode = (index: number): string | number | boolean | null | undefined => { + const node = newRoot.schema.nodes[modeNode(index) ?? -1] + return node?.kind === 'literal' ? node.value : undefined + } + const unknown = newTypes.find(index => mode(index) === 'unknown') + const oneShot = newTypes.find(index => mode(index) === 'one-shot') + if (unknown !== undefined && oneShot !== undefined) { + // The catalog exception admits only the one-shot fields with unknown mode; + // existing branches still pass the ordinary structural comparison below. + const normalized = { ...newRoot.schema, nodes: newRoot.schema.nodes.map((node, index) => + index === modeNode(unknown) ? { kind: 'literal' as const, value: 'one-shot' } : node) } + if (subDigest(normalized, unknown) === fingerprint(newRoot.schema, oneShot, 1)) { + return [describe(path, 'union-variants-changed', false), + ...compareAlternatives(oldTypes, newTypes.filter(index => index !== unknown), path, scope, active)] + } + } + } if (oldTypes.length !== newTypes.length) return [describe(path, 'union-variants-changed')] const candidates = oldTypes.map(oldType => newTypes.map(newType => compare(oldType, newType, path, scope, active))) const matching = matchUnionVariants(candidates.map(row => row.flatMap((candidate, index) => From 065def9d41bd2588db6df74ec2d5db3b9ed40ef4 Mon Sep 17 00:00:00 2001 From: fz Date: Sun, 20 Sep 2026 14:50:48 +0800 Subject: [PATCH 149/284] docs: use compliant issue references in native extraction note --- ...-single-file-executable-sdk-runtime-distribution.i18n.yaml | 4 ++-- ...6-07-10-single-file-executable-sdk-runtime-distribution.md | 2 +- ...7-10-single-file-executable-sdk-runtime-distribution.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index ce9821d058..6a0f5d94c7 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 2de12a542f6c17754eb494a9e42ba843573182cb -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: c391d7268ed55915aeefe4cb323dcf1350ad631f +2026-07-10-single-file-executable-sdk-runtime-distribution.md: ff00327c388b0468e5e98d488141aa35eb40d104 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3f7f18a9907b2f43f7cd8467f054f0d0bfcef14a diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 2de12a542f..ff00327c38 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -23,7 +23,7 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h `@yao-pkg/pkg` is an exact-pinned root `devDependency` invoked as `pnpm exec pkg`, with [`patches/@yao-pkg__pkg@6.21.0.patch`](../../../../patches/@yao-pkg__pkg@6.21.0.patch) removing the SEA bootstrap's `patchChildProcess` call. Unpatched, pkg rewrites spawned commands named `node` — including the string after a `-c`/`/c` flag, exactly the Bash tool's `bash -c` form — to the executable itself and stamps `PKG_EXECPATH` into every child environment, so a model-issued `node --version` silently boots the dsh CLI; Node's own SEA layer performs no such rewrite, and a SEA binary cannot impersonate plain Node because it always boots its embedded app. With the call removed, children resolve `node` through PATH like any other process (a machine without Node reports command-not-found honestly), no `PKG_EXECPATH` reaches children, absolute `process.execPath` spawns still re-enter the app, worker threads never applied the hook, and `process.pkg` sidecar selection is untouched. -The same patch makes the SEA bootstrap's native-addon extraction atomic. On the first `dlopen` of an addon inside the VFS, pkg's `patchDlopen` copies the addon's package tree to `$PKG_NATIVE_CACHE_PATH/pkg//` (default `~/.cache/pkg/...`), a per-user directory that every concurrent process of the same executable shares; upstream writes each file straight to its final path with `writeFileSync`, which truncates a file another process may already have mapped and lets that process's `existsSync`/hash check read a half-written file. Concurrent first starts therefore die with SIGBUS on Linux or fail with `ERR_DLOPEN_FAILED: Library not loaded` on sharp's libvips on macOS ([#4664](https://github.com/deepseek-harness/deepseek-harness/issues/4664)). The patch routes the three write sites through a same-directory temporary file and `renameSync`, so a cache path is either absent or complete, every process finishes its own copy pass before it loads, and concurrent renames of identical content need no lock. The cache location, hash keying, and per-file comparison are unchanged, so a consumer that isolates the cache per instance through `PKG_NATIVE_CACHE_PATH` keeps working; worker threads only define the hook and never install it. Measured (linux-x64, node24 target, pkg 6.21.0, a probe that loads sharp with its 18 MB libvips library, 16 concurrent first starts against one empty cache, five rounds): 39 of 80 processes died with SIGBUS before the change and 0 of 80 after it, with no temporary files left behind. +The same patch makes the SEA bootstrap's native-addon extraction atomic. On the first `dlopen` of an addon inside the VFS, pkg's `patchDlopen` copies the addon's package tree to `$PKG_NATIVE_CACHE_PATH/pkg//` (default `~/.cache/pkg/...`), a per-user directory that every concurrent process of the same executable shares; upstream writes each file straight to its final path with `writeFileSync`, which truncates a file another process may already have mapped and lets that process's `existsSync`/hash check read a half-written file. Concurrent first starts therefore die with SIGBUS on Linux or fail with `ERR_DLOPEN_FAILED: Library not loaded` on sharp's libvips on macOS (#4664). The patch routes the three write sites through a same-directory temporary file and `renameSync`, so a cache path is either absent or complete, every process finishes its own copy pass before it loads, and concurrent renames of identical content need no lock. The cache location, hash keying, and per-file comparison are unchanged, so a consumer that isolates the cache per instance through `PKG_NATIVE_CACHE_PATH` keeps working; worker threads only define the hook and never install it. Measured (linux-x64, node24 target, pkg 6.21.0, a probe that loads sharp with its 18 MB libvips library, 16 concurrent first starts against one empty cache, five rounds): 39 of 80 processes died with SIGBUS before the change and 0 of 80 after it, with no temporary files left behind. Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index c391d7268e..3f7f18a990 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -23,7 +23,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 `@yao-pkg/pkg` 是精确钉版的根 `devDependency`,经 `pnpm exec pkg` 调用,并以 [`patches/@yao-pkg__pkg@6.21.0.patch`](../../../../patches/@yao-pkg__pkg@6.21.0.patch) 移除 SEA bootstrap 中的 `patchChildProcess` 调用。未打补丁时,pkg 会把 spawn 的 `node` 命令——包括 `-c`/`/c` 标志后的命令串,恰是 Bash 工具的 `bash -c` 形态——改写为 exe 自身,并向每个子进程环境注入 `PKG_EXECPATH`,模型下发的 `node --version` 会静默启动 dsh CLI;Node 自身的 SEA 层没有这种改写,且 SEA 二进制永远启动内嵌应用、无法充当纯 Node。移除该调用后,子进程像普通进程一样经 PATH 解析 `node`(无 Node 的机器如实报 command not found),子进程环境不再出现 `PKG_EXECPATH`,以 `process.execPath` 绝对路径 spawn 的重入不受影响,worker 线程本来就未应用该钩子,`process.pkg` 侧车选择也不受影响。 -同一补丁还把 SEA bootstrap 的原生插件解包改成原子写入。VFS 内的插件首次 `dlopen` 时,pkg 的 `patchDlopen` 会把该插件所在的包目录整棵拷到 `$PKG_NATIVE_CACHE_PATH/pkg/<.node 文件的 sha256>/`(默认 `~/.cache/pkg/...`),这是同一个 exe 的所有并发进程共用的按用户目录;上游用 `writeFileSync` 直接写目标路径,会把另一个进程可能已经映射的文件截断,并让该进程的 `existsSync`/哈希比对读到写了一半的文件。因此并发首次启动在 Linux 上以 SIGBUS 崩溃,在 macOS 上对 sharp 的 libvips 报 `ERR_DLOPEN_FAILED: Library not loaded`([#4664](https://github.com/deepseek-harness/deepseek-harness/issues/4664))。补丁让三处写入都经过同目录临时文件加 `renameSync`,于是缓存路径要么不存在、要么完整,每个进程都在加载前走完自己的拷贝,相同内容的并发 rename 不需要锁。缓存位置、哈希键与逐文件比对均未改变,通过 `PKG_NATIVE_CACHE_PATH` 按实例隔离缓存的消费方照常工作;worker 线程只定义该钩子而从不安装。 实测(linux-x64、node24 构建目标、pkg 6.21.0,一个加载 sharp 及其 18 MB libvips 库的探针,16 个进程对同一个空缓存并发首次启动,跑五轮):改动前 80 个进程中 39 个以 SIGBUS 崩溃,改动后 80 个全部正常,且无残留临时文件。 +同一补丁还把 SEA bootstrap 的原生插件解包改成原子写入。VFS 内的插件首次 `dlopen` 时,pkg 的 `patchDlopen` 会把该插件所在的包目录整棵拷到 `$PKG_NATIVE_CACHE_PATH/pkg/<.node 文件的 sha256>/`(默认 `~/.cache/pkg/...`),这是同一个 exe 的所有并发进程共用的按用户目录;上游用 `writeFileSync` 直接写目标路径,会把另一个进程可能已经映射的文件截断,并让该进程的 `existsSync`/哈希比对读到写了一半的文件。因此并发首次启动在 Linux 上以 SIGBUS 崩溃,在 macOS 上对 sharp 的 libvips 报 `ERR_DLOPEN_FAILED: Library not loaded`(#4664)。补丁让三处写入都经过同目录临时文件加 `renameSync`,于是缓存路径要么不存在、要么完整,每个进程都在加载前走完自己的拷贝,相同内容的并发 rename 不需要锁。缓存位置、哈希键与逐文件比对均未改变,通过 `PKG_NATIVE_CACHE_PATH` 按实例隔离缓存的消费方照常工作;worker 线程只定义该钩子而从不安装。 实测(linux-x64、node24 构建目标、pkg 6.21.0,一个加载 sharp 及其 18 MB libvips 库的探针,16 个进程对同一个空缓存并发首次启动,跑五轮):改动前 80 个进程中 39 个以 SIGBUS 崩溃,改动后 80 个全部正常,且无残留临时文件。 术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP(Agent Client Protocol)回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。 From d8480db9f0d8d0dab3d8dde3d9f8642d42c65cb5 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Sun, 20 Sep 2026 14:54:20 +0800 Subject: [PATCH 150/284] refactor(web): read engagement retention from one identity set Address review: retention now has a single definition (retainedIds) shared by the list-refresh sweep and the single-id prune, an already-engaged identity skips the redundant row mutation, and the ledger documents why Host-asserted running is recorded before any holder exists while the client-local acceptance callback requires one. --- .../src/client/sessions/manager.ts | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 4a7d1d685d..9744bf3c75 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -87,7 +87,13 @@ export class SessionManager { private readonly sessions = new Map() /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */ private readonly sessionDisposals = new Set>() - /** Accepted/running presentation must survive a later empty-history list response. */ + /** + * Accepted/running presentation must survive a later empty-history list + * response. Host-asserted running is recorded even before a row, instance, or + * address holds the identity — the listing that would hold it may not have + * landed yet — while the client-local acceptance callback requires a current + * holder, because it can arrive from a replaced or already-dropped Session. + */ private readonly engagedSessions = new Set() private disposed = false /** Per-session projection value stores, retained independently of instance arrival (the @@ -187,7 +193,7 @@ export class SessionManager { if (session !== expected) return Promise.resolve() this.sessions.delete(sessionId) this.addresses.delete(sessionId) - this.pruneEngagement(sessionId) + this.pruneEngagement(sessionId, this.retainedIds(this.summaries)) return this.startSessionDisposal(session) } @@ -276,9 +282,12 @@ export class SessionManager { // The sender's local first-send flip mirrors into the list row so the // session surfaces (lists filter on blank) before any host frame lands. onEngaged: (engaged) => { - if (this.disposed || !this.retainsSession(engaged.sessionId)) return - this.engagedSessions.add(engaged.sessionId) - this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) + if (this.disposed || !this.retainedIds(this.summaries).has(engaged.sessionId)) return + // An identity already on the ledger needs no second row mutation. + if (!this.engagedSessions.has(engaged.sessionId)) { + this.engagedSessions.add(engaged.sessionId) + this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) + } this.sessions.get(engaged.sessionId)?.handleBlank(false) }, projections: this.projectionStore(sessionId), @@ -289,13 +298,26 @@ export class SessionManager { return summary.blank && !this.engagedSessions.has(summary.sessionId) } - private retainsSession(sessionId: SessionId): boolean { - return this.sessions.has(sessionId) || this.addresses.has(sessionId) - || this.summaries.some(summary => summary.sessionId === sessionId) + /** + * Identities an engagement may still belong to: the given list rows, resident + * Session instances, and retained child addresses. + * @param summaries - list rows of the caller's snapshot. + * @returns the retained identity set. + */ + private retainedIds(summaries: readonly SessionSummary[]): Set { + const retained = new Set(summaries.map(summary => summary.sessionId)) + for (const sessionId of this.sessions.keys()) retained.add(sessionId) + for (const sessionId of this.addresses.keys()) retained.add(sessionId) + return retained } - private pruneEngagement(sessionId: SessionId): void { - if (!this.retainsSession(sessionId)) this.engagedSessions.delete(sessionId) + /** + * Forget one engagement that no retained identity holds. + * @param sessionId - identity whose engagement may be dropped. + * @param retained - identities from {@link retainedIds} for the caller's snapshot. + */ + private pruneEngagement(sessionId: SessionId, retained: ReadonlySet): void { + if (!retained.has(sessionId)) this.engagedSessions.delete(sessionId) } /** Resident per-session projection store (create-on-demand; outlives instantiation). */ @@ -402,12 +424,8 @@ export class SessionManager { const summaries = mutations.reduce(applyMutation, baseline) this.summaries = summaries // A full list can remove identities without a removal frame. - const listedIds = new Set(summaries.map(summary => summary.sessionId)) - for (const sessionId of this.engagedSessions) { - if (!listedIds.has(sessionId) && !this.sessions.has(sessionId) && !this.addresses.has(sessionId)) { - this.engagedSessions.delete(sessionId) - } - } + const retained = this.retainedIds(summaries) + for (const sessionId of this.engagedSessions) this.pruneEngagement(sessionId, retained) this.listState = 'idle' this.listPhase = 'ready' this.updateParentAvailability() @@ -686,7 +704,7 @@ export class SessionManager { if (!durableSubagent && (catalog === undefined || catalog.length === 0)) { this.projectionStores.delete(sessionId) } - this.pruneEngagement(sessionId) + this.pruneEngagement(sessionId, this.retainedIds(this.summaries)) this.projectionInflight.get(sessionId)?.controller.abort() this.projectionInflight.delete(sessionId) this.projectionLoads.delete(sessionId) From b882e7fc2035bc4baeb0963797b15bc75e8ffbc5 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Sun, 20 Sep 2026 14:54:27 +0800 Subject: [PATCH 151/284] docs(session): record the client-local engagement memory limits --- packages/api/session-controller/README.i18n.yaml | 4 ++-- packages/api/session-controller/README.md | 2 ++ packages/api/session-controller/README.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index bab878d24d..32ffc5e1cd 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: 4bbb98b0ee9224f72a571b10e8ae40d32377a747 -README.zh.md: 820dd4c3f56e964cf2b34cb50d5e5f38e35eff68 +README.md: 56d0f17b815a4616707d33bfd84b77446125f742 +README.zh.md: 9d754a5a61da548e1fd6dd10d1332582bdd3d126 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 4bbb98b0ee..56d0f17b81 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -101,6 +101,8 @@ No direct effect; model requests remain owned by the Agent and LLM packages. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. - The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero. - File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata. +- Accepted/running display memory lives only in Client memory and is lost on page reload. +- That memory is not shared between tabs: one tab can show a converted row while another still shows `New Session` for the same Session. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 820dd4c3f5..9d754a5a61 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -101,6 +101,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 - follow 恢复失败会对调用方可见,而不会无限重试。 - 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第零字节重新传输整个文件。 - 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。 +- 受理/运行的展示记忆只存在于客户端内存,页面重载后即丢失。 +- 该记忆不在 tab 之间共享:同一会话可能在一个 tab 中已转正,在另一个 tab 中仍显示为 `New Session`。 From 5e13010b907ad24059cfda0b3ce0118d18613d23 Mon Sep 17 00:00:00 2001 From: liukx0205 Date: Sun, 20 Sep 2026 14:54:27 +0800 Subject: [PATCH 152/284] test(web): document suite-level browser setup and bound the reconnect polls --- apps/web/tests/replay-round-trip.e2e.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 632d198e38..48b505f398 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -8,6 +8,10 @@ // embedded Assistant stream, not transient DOM. // Record: DSH_SNAPSHOT=record writes session.v3.jsonl, then a keyless // DSH_SNAPSHOT=refresh regenerates ui.expected.md. +// Suite setup (beforeAll, not per step): one fixed page clock +// (page.clock.setFixedTime) and one routed Remote mux socket +// (page.routeWebSocket) serve every step below, so the reconnect checkpoints +// rely on both without re-pinning or re-routing them. import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -142,7 +146,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { await compareOrRefreshGolden(ECHO_EXPECTED, echoSnapshot, MODE) const row = page.getByRole('tree', { name: 'Sessions', exact: true }) .locator('[role="treeitem"][aria-selected="true"]') - await expect.poll(() => row.getAttribute('draggable')).toBe('true') + await expect.poll(() => row.getAttribute('draggable'), { timeout: 5_000 }).toBe('true') expect(sessionEvents.some(event => event.type === 'turn/start')).toBe(false) const disconnected = remoteSocket @@ -172,7 +176,8 @@ describe('web e2e: fresh round trip through the real assembly', () => { try { const refreshing = page.waitForResponse(listUrl) const [, refreshed] = await Promise.all([disconnected.close(), refreshing]) - await expect.poll(() => remoteSocket !== disconnected).toBe(true) + // Cross-network reconnect: allow more than expect.poll's default. + await expect.poll(() => remoteSocket !== disconnected, { timeout: 15_000 }).toBe(true) const baseline = await refreshed.json() as SessionListResponse expect(baseline.result.ok).toBe(true) if (!baseline.result.ok) throw new Error('the reconnect list request failed') From e71df56b1a2b6511bc7184a58c6a6142e934bed9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 14:59:15 +0800 Subject: [PATCH 153/284] feat(workspace): create the default workspace on first send --- ...ession-scope-and-provide-channel.i18n.yaml | 4 +- ...lient-session-scope-and-provide-channel.md | 8 +- ...nt-session-scope-and-provide-channel.zh.md | 8 +- .../2026-09-20-default-workspace.i18n.yaml | 6 + .../feature/2026-09-20-default-workspace.md | 32 ++++ .../2026-09-20-default-workspace.zh.md | 32 ++++ apps/web/tests/default-workspace.e2e.ts | 122 +++++++++++++++ .../default-workspace/failure.expected.md | 6 + apps/web/tests/scaffold.ts | 2 + apps/web/tests/support.ts | 23 +-- apps/web/tsconfig.json | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 19 ++- docs/config-catalog.zh.md | 19 ++- docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 23 +++ docs/subsystems/workspace.zh.md | 23 +++ .../api/workspace-controller/README.i18n.yaml | 4 +- packages/api/workspace-controller/README.md | 12 ++ .../api/workspace-controller/README.zh.md | 12 ++ .../api/workspace-controller/package.json | 2 + .../workspace-controller/src/client/index.ts | 2 +- .../workspace-controller/src/client/model.ts | 13 ++ .../src/client/service.ts | 15 +- .../src/default-directory.ts | 79 ++++++++++ .../api/workspace-controller/src/index.ts | 53 ++++++- .../api/workspace-controller/src/types.ts | 8 + .../tests/default-directory.host.spec.ts | 48 ++++++ .../tests/model.client.spec.ts | 17 +++ .../tests/remote/workspace.client.ts | 1 + .../tests/transport.client.spec.ts | 5 + .../tests/workspace-controller.host.spec.ts | 48 +++++- .../workspace-controller/tsconfig.host.json | 5 +- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 + packages/client/ui-agent-preset/README.zh.md | 2 + .../ui-agent-preset/src/client/index.ts | 8 + .../ui-agent-preset/src/client/seat-store.ts | 13 +- .../tests/apply.client.spec.ts | 33 +++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 6 +- packages/client/ui-conversation/README.zh.md | 6 +- .../ui-conversation/src/client/apply.ts | 41 +++-- .../src/client/contract/input.ts | 6 + .../src/client/contract/slots.ts | 13 +- .../ui-conversation/src/client/index.ts | 8 + .../src/client/input/facade.ts | 4 +- .../src/client/input/first-draft.ts | 79 ++++++++++ .../ui-conversation/src/client/locales.ts | 8 + .../client/skeleton/ConversationContent.tsx | 40 +++-- .../src/client/skeleton/ConversationRoot.tsx | 5 +- .../src/client/skeleton/InputBar.tsx | 81 ++++------ .../tests/apply-inject.client.spec.tsx | 52 ++++++- .../tests/assembly-surfaces.client.spec.tsx | 24 ++- .../tests/first-draft.client.spec.ts | 141 +++++++++++++++++ .../tests/input-bar.client.spec.tsx | 1 + .../tests/input-matrix.client.spec.tsx | 1 + .../tests/input-scenarios.client.spec.tsx | 1 + .../tests/skeleton.client.spec.tsx | 16 ++ packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 2 + .../ui-workspace/src/client/navigation.ts | 33 +++- .../tests/workspaces-service.client.spec.ts | 40 +++++ .../extensions/tool-cordis/src/api-catalog.ts | 16 ++ .../client-runtime/src/workspaces.ts | 15 +- packages/workspace/workspace/README.i18n.yaml | 4 +- packages/workspace/workspace/README.md | 13 +- packages/workspace/workspace/README.zh.md | 13 +- packages/workspace/workspace/src/index.ts | 41 ++++- packages/workspace/workspace/src/spec.ts | 2 + .../workspace/tests/workspace.spec.ts | 144 +++++++++++++++++- pnpm-lock.yaml | 6 + scripts/gen-cordis-catalog.ts | 2 + snapshots/web/default-workspace/snapshot.yml | 9 ++ .../web/default-workspace/ui.expected.md | 26 ++++ tsconfig.host.json | 1 + 77 files changed, 1472 insertions(+), 160 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-20-default-workspace.md create mode 100644 .agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md create mode 100644 apps/web/tests/default-workspace.e2e.ts create mode 100644 apps/web/tests/expected/default-workspace/failure.expected.md create mode 100644 packages/api/workspace-controller/src/default-directory.ts create mode 100644 packages/api/workspace-controller/tests/default-directory.host.spec.ts create mode 100644 packages/client/ui-conversation/src/client/input/first-draft.ts create mode 100644 packages/client/ui-conversation/tests/first-draft.client.spec.ts create mode 100644 snapshots/web/default-workspace/snapshot.yml create mode 100644 snapshots/web/default-workspace/ui.expected.md diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index e891710dea..2e242505c5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 990a9b6eef862a7ac4a549358beae7f9081eaa0b -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 5c90611fab36611766bb407138c4f0c8a6b3a01b +2026-07-25-web-client-session-scope-and-provide-channel.md: e2541836edc18755c2e49d30a10b44d2d3a7bcc3 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: fd082cc74974a22a69a67cbf657693911aea8f99 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 990a9b6eef..e2541836ed 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -26,8 +26,8 @@ The reference-owned lifetime and Provider targeting now follow [Client Session r Host-side `session.create(workspaceId)` produces Session + Agent + cwd in one piece (an atomic bundle, never split); the client side is the mirror of that birth — the instant a session row enters the list mirror, the client mints its Agent scope (actx + provide + the full input surface mounted): - Session identity is the host's true form from birth: the sessionId arrives via the `session.create` response / the `host/session-added` frame, and every client-side address (the scope tag, slot store keys, RPC addressing) uses that same id. -- The materialization moment = the instant the user picks a Workspace (cwd settled): the client calls `session.create({workspaceId})` on the spot and receives the complete entity. -- "New Session with no workspace picked" is a **pure view state** (a navigation position) corresponding to no session/scope entity; until the pick, the composer is locked whole (no slash, no plain text). +- A manual Workspace pick or successful [first-use default preparation](../feature/2026-09-20-default-workspace.md) establishes cwd before the client calls `session.create({workspaceId})` and receives the complete entity. +- "New Session with no workspace picked" has no Session or scope entity. The composer retains a local text draft; Session-scoped slash commands become available after connection. - A "blank session" is just an ordinary materialized session whose log is still empty; to every Agent-scope plugin on the host (goal/plan/skill/…) it is indistinguishable from any session, so slash/plan are all naturally live. ### Agent scope: the actx is the sole session carrier in the client-side cordis world @@ -95,7 +95,7 @@ Slot scope is the closed set `root | session-maybe | session`: - `session-maybe` inherits the nearest `SessionProvider` binding with ADOPTION identity: an incarnation born Session-less keeps its React instance when that Provider receives its first binding, then remounts when the Provider switches generation or returns to absence. Component-local per-Session state clears when the Provider switches generation. Across a switch, only persisted Store values survive generation retirement; binding-owned sources survive only when another reference keeps that generation alive. With no binding, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. Provider-roster changes rematerialize the mounted binding without changing its identity, while the per-entry adoption bookkeeping lives in the renderer's `SessionMaybeEntry`. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. -`conversation` is the resident `session-maybe` shell under its owning `SessionProvider`: `ConversationRoot`, HeroShell, the Workspace picker, the scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-Session → blank-Session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same Session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no Session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a binding appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. +`conversation` is the resident `session-maybe` shell under its owning `SessionProvider`: `ConversationRoot`, HeroShell, the Workspace picker, the scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-Session → blank-Session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same Session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: before a Session exists, its injected editor retains the first-use text draft; after connection, the same InputBar instance receives the retained Session's input. The remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. Blank Sessions retain the header's leading and corner slots so navigation controls, including the right-sidebar opener, are available before the first message. Title, actions, utilities, and View tabs remain hidden in the blank phase. The header still requires a selected Session; the Files and Terminal entries use that Session's workspace and execution services without requiring a recorded Turn. @@ -136,5 +136,5 @@ Blank Sessions retain the header's leading and corner slots so navigation contro - Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter. - The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly. - Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse. -- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) routes activation to the Workspace picker during that interval. +- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. Session-scoped input remains unavailable until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) owns the local first-use draft during that interval. - Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 5c90611fab..fd082cc749 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -26,8 +26,8 @@ web client 只有一张全局会话面:slot 全部从根上下文渲染,插 host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(作为不可拆分的原子整体);client 侧就是这次出生的镜像——会话行进入 list mirror 的瞬间,client 为它铸 Agent scope(actx + provide + 输入面全套挂上): - 会话身份自出生即为 host 真身:sessionId 由 `session.create` 响应 / `host/session-added` 帧带来,client 侧一切寻址(scope tag、slot store 键、RPC 地址)用的都是同一个 id。 -- 实体化时点 = 用户选定 Workspace(cwd 确定)的瞬间:client 当场调 `session.create({workspaceId})`,拿到完整实体。 -- 「New Session 且未选 workspace」是**纯视图态**(一个导航位置),不对应任何会话/scope 实体;选定之前 composer 整体锁死(无 slash、无纯文本)。 +- 手动选择 Workspace 或成功[准备首次使用默认工作区](../feature/2026-09-20-default-workspace.zh.md)先确定 cwd,client 随后调用 `session.create({workspaceId})` 并取得完整实体。 +- 「New Session 且未选 workspace」没有 Session 或 scope 实体。Composer 保留本地文本草稿;连接后才提供 Session scope 的 slash 命令。 - 「空会话」就是一个日志还空着的普通实体化会话;对 host 上所有 Agent-scope 插件(goal/plan/skill(技能)/…)它与任何会话无异,slash/plan 天然全活。 ### Agent scope:actx 是 client 侧 cordis 世界的唯一会话载体 @@ -95,7 +95,7 @@ slot scope 是闭集 `root | session-maybe | session`: - `session-maybe` 以**收养(adoption)身份语义**继承最近 `SessionProvider` 的 binding:空态出生的化身在该 Provider 第一次收到 binding 时保持 React 实例,此后 Provider 切换 generation 或回到空态时重挂。Provider 切换 generation 时,组件本地的逐 Session 状态会清零。切换过程中,只有持久化 Store 值能活过 generation 退休;只有另一份 reference 保活该 generation 时,binding 自有 source 才能保留。无 binding 时,`sessionId`、`useSession`/`useInput` 的结果与 `inputActions` 均可缺省。Provider roster 变化会重新物化已挂载 binding,但不改变其 identity;逐 entry 的收养记账住在 renderer 的 `SessionMaybeEntry`。 - `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换会话会重建该 entry 及其会话 store。 -`conversation` 是其 owner `SessionProvider` 下的 `session-maybe` 常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 Session → blank Session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 Session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 Session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。binding 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 +`conversation` 是其 owner `SessionProvider` 下的 `session-maybe` 常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 Session → blank Session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 Session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:Session 存在前,注入的编辑器保存首次使用文本草稿;连接后,同一 InputBar 实例接收已持有 Session 的输入。其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入口等导航控件在首条消息之前即可使用。标题、actions、utilities 和 View tabs 在 blank phase 中继续隐藏。header 仍要求已选中的 Session;Files 与 Terminal 入口使用该 Session 的工作区和执行服务,无需已有 Turn 记录。 @@ -136,5 +136,5 @@ blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入 - 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。 - client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。 - 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。 -- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)会把激活操作转到 Workspace picker。 +- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。Session 存在前,其 scope 输入仍不可用;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)负责本地首次使用草稿。 - 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。 diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml new file mode 100644 index 0000000000..cdb2673df8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-09-20-default-workspace.md +2026-09-20-default-workspace.md: d2a32d47a407dcf211c1cad8bb98f29847a77a3b +2026-09-20-default-workspace.zh.md: 6b0ec542da6e9e4fe76fb56b6562edea9e5777f4 diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.md b/.agents/notes/implemented/feature/2026-09-20-default-workspace.md new file mode 100644 index 0000000000..d2a32d47a4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.md @@ -0,0 +1,32 @@ +# Agent Note: First-use default Workspace + +Status: implemented + +English | [中文](2026-09-20-default-workspace.zh.md) + +## Problem + +A new installation requires a directory choice before the user can send a first message. Removing that prerequisite must preserve the Session's fixed working directory and must not treat hidden or archived history as a new installation. + +## Decision + +The conversation shell retains an unsent local text draft without creating a Session. Its first send asks the Host to prepare a default Workspace, then connects a real Session and submits through the ordinary composer pipeline. The first-use draft awaits staged mode selection before automatic submission. This partially supersedes the locked no-Session composer in [Session scope and provisioning](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md); that note still owns blank-Session reuse and provisioning rationale. + +The [Workspace registry](../../../../packages/workspace/workspace/README.md#first-use-workspace) owns eligibility and directory preparation. All live Sessions, persisted headers, and archived identities count, including entries absent from the sidebar. The operation shares the registry mutation queue and rechecks Session history after directory preparation because Sessions can start independently. + +The Client resolves the initial directory name and title from its language when sending. The Host controller resolves the Documents location; the registry receives a directory resolver with no locale dependency. The resolver runs inside the mutation queue only for eligible creation, so repeated requests reuse the durable Workspace without another OS lookup. A durable Workspace id records successful initialization independently of that name. Renaming, changing language, restarting, or deleting the registration cannot initialize another default. The marker commits with the registration, so a failed registration can retry. Directory contents remain subject to the existing [metadata-only deletion policy](2026-07-27-workspace-registration-deletion.md). + +Creation failure preserves the draft and offers the existing folder picker. A successful registration survives Session creation or prompt failure. Picking a folder after failure transfers the text without sending it; a later send remains the user's action. + +## Alternatives considered + +- Creating on application startup would write directories for users who never send a message. +- Inferring first use from visible sidebar rows would ignore archived, hidden, and cwd-less Sessions. +- Using the localized path as the initialization marker would allow language changes or deletion to create another default. +- Changing a Session's cwd after creation would change the meaning of its recorded tools and attachments. + +## Consequences + +First-use typing has no Host Session or filesystem effects. Preparation remains a Host responsibility for both Desktop and remote Web clients, so remote users use the Host account's Documents location. Unavailable Documents lookup fails with the same folder-selection recovery as directory creation failure. + +The feature preserves [reference-owned Client Session lifetimes](../architecture/2026-09-15-client-session-references.md) and requires no agent-loop or Session-event change. Registry and client tests cover retries, hidden history, concurrency, and draft recovery; the [browser scenario](../../../../apps/web/tests/default-workspace.e2e.ts) verifies the assembled first-send and picker paths with an isolated Documents directory and recorded Session replay. diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md b/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md new file mode 100644 index 0000000000..6b0ec542da --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 首次使用默认 Workspace + +Status: implemented + +[English](2026-09-20-default-workspace.md) | 中文 + +## Problem + +新安装的应用要求用户先选目录才能发送第一条消息。去掉这一步前置操作时,必须保留 Session 的固定工作目录,也不能把隐藏或已归档的历史误判为新安装。 + +## Decision + +会话外壳保存未发送的本地文本草稿,不创建 Session。首次发送请求 Host 准备默认 Workspace,随后连接真实 Session,并通过常规 composer 流程提交。首次使用的草稿会在自动提交前等待暂存的模式选择完成。这部分取代[Session scope 与供给通道](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md)中无 Session 时锁定 composer 的决策;该记录仍负责 blank Session 复用与供给通道的理由。 + +[Workspace registry](../../../../packages/workspace/workspace/README.zh.md#first-use-workspace)负责资格判断和目录准备。所有 live Session、持久化 header 和已归档身份都计入判断,包括侧栏中不可见的条目。操作共用 registry 修改队列,并在目录准备后重新检查 Session 历史,因为 Session 可以独立启动。 + +Client 在发送时按其语言解析初始目录名和标题。Host 控制器解析 Documents 位置;注册表接收不依赖 locale 的目录解析器。解析器仅在允许创建时于变更队列内运行,因此重复请求直接复用持久化的 Workspace,无需再次查询操作系统目录。持久化 Workspace id 独立于名称记录初始化成功。改名、切换语言、重启或删除登记均不能再次初始化默认工作区。标记与登记一起提交,因此登记失败可以重试。目录内容仍遵循已有的[仅删除元数据策略](2026-07-27-workspace-registration-deletion.zh.md)。 + +创建失败时保留草稿,并提供现有文件夹选择器。成功的登记会在 Session 创建或提示词发送失败后保留。失败后选择文件夹只转移文本,不发送;随后发送仍由用户操作。 + +## Alternatives considered + +- 在应用启动时创建,会为从未发送消息的用户写入目录。 +- 根据侧栏可见行推断首次使用,会忽略已归档、隐藏和无 cwd 的 Session。 +- 将本地化路径作为初始化标记,会允许切换语言或删除后再次创建默认工作区。 +- 在 Session 创建后更改 cwd,会改变其已记录工具和附件的含义。 + +## Consequences + +首次使用时输入文本不会产生 Host Session 或文件系统副作用。Desktop 和远程 Web 客户端均由 Host 准备目录,因此远程用户使用 Host 账户的 Documents 位置。Documents 查询不可用时,采用与目录创建失败相同的文件夹选择恢复路径。 + +该功能保留[由引用持有的 Client Session 生命周期](../architecture/2026-09-15-client-session-references.zh.md),无需修改 agent-loop 或 Session 事件。Registry 和客户端测试覆盖重试、隐藏历史、并发及草稿恢复;[浏览器场景](../../../../apps/web/tests/default-workspace.e2e.ts)通过隔离的 Documents 目录和已录制 Session 回放验证完整装配下的首次发送与选择器路径。 diff --git a/apps/web/tests/default-workspace.e2e.ts b/apps/web/tests/default-workspace.e2e.ts new file mode 100644 index 0000000000..00bec9de3c --- /dev/null +++ b/apps/web/tests/default-workspace.e2e.ts @@ -0,0 +1,122 @@ +/** First-use messages prepare a durable Workspace through the shipped Web composition. */ +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { describe, expect, it } from 'vitest' +import { + captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, + selectedSessionFixture, watchConsole, webSnapshotMode, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) +const EXPECTED = fileURLToPath(new URL('../../../snapshots/web/default-workspace/ui.expected.md', import.meta.url)) +const FAILURE_EXPECTED = fileURLToPath(new URL('./expected/default-workspace/failure.expected.md', import.meta.url)) +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { + it('sends the first draft in a newly selected default Workspace and keeps its identity across reload', async () => { + const fixture = await selectedSessionFixture(FIXTURE) + const scaffold = await launchWebScaffold({ + replayFixture: fixture, + // The shared recording remains read-only; replay compares the complete persisted Session. + compareReplaySession: MODE === 'replay', + paceMs: 5, + }) + try { + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + try { + await page.goto(scaffold.authenticatedUrl) + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + await input.waitFor() + const prompt = fixtureUserPrompts(await readFile(fixture, 'utf8'))[0]! + await input.fill(prompt) + expect(scaffold.ctx.workspaceRegistry.list()).toEqual([]) + expect(scaffold.ctx.sessions.list()).toEqual([]) + const settled = scaffold.whenTurnSettled() + await input.press('Enter') + const sessionId = await settled + const workspace = scaffold.ctx.workspaceRegistry.list()[0]! + expect(workspace.title).toBe('Default workspace') + expect(workspace.path).toBe(join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness', 'Default workspace')) + expect((await stat(workspace.path)).isDirectory()).toBe(true) + expect(workspace.sessionIds).toContain(sessionId) + expect(scaffold.ctx.sessions.get(sessionId)?.header.cwd).toBe(workspace.path) + await page.getByText('DONE', { exact: true }).waitFor() + await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) + await page.reload() + await page.getByText('DONE', { exact: true }).waitFor() + expect(scaffold.ctx.workspaceRegistry.list().map(item => item.id)).toEqual([workspace.id]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'web-e2e-default-workspace') + throw error + } + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } + }) + + it('keeps the draft on a directory conflict and opens the composed folder picker for recovery', async () => { + const scaffold = await launchWebScaffold() + try { + const parent = join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness') + await mkdir(parent, { recursive: true }) + await writeFile(join(parent, '默认工作区'), 'occupied') + const chosen = join(scaffold.workspaceCwd, 'chosen') + await mkdir(chosen) + const browser = await chromium.launch() + try { + const page = await browser.newPage({ locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) + const tripwire = watchConsole(page) + try { + await page.goto(scaffold.authenticatedUrl) + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + await input.fill('保留这条消息') + await input.press('Enter') + const modal = page.getByRole('dialog', { name: '无法创建默认工作区' }) + await modal.waitFor() + await expect.poll(() => input.textContent()).toBe('保留这条消息') + expect(scaffold.ctx.workspaceRegistry.list()).toEqual([]) + expect(scaffold.ctx.sessions.list()).toEqual([]) + await compareOrRefreshGolden(FAILURE_EXPECTED, await modal.ariaSnapshot(), MODE) + await modal.getByRole('button', { name: '取消', exact: true }).click() + expect(await input.textContent()).toBe('保留这条消息') + await input.press('Enter') + await modal.getByRole('button', { name: '选择文件夹', exact: true }).click() + const picker = page.getByRole('dialog', { name: '选择工作区目录' }) + await picker.waitFor() + await picker.getByRole('button', { name: '编辑路径', exact: true }).click() + await picker.getByRole('textbox', { name: '编辑路径' }).fill(chosen) + await page.keyboard.press('Enter') + await picker.getByRole('button', { name: '打开', exact: true }).click() + await picker.waitFor({ state: 'hidden' }) + await expect.poll(() => scaffold.ctx.workspaceRegistry.list().length).toBe(1) + await page.getByRole('treeitem', { name: '新会话', exact: true }).waitFor() + await expect.poll(() => input.textContent()).toBe('保留这条消息') + expect(scaffold.ctx.workspaceRegistry.list()[0]?.path).toBe(chosen) + expect(scaffold.ctx.sessions.list()).toHaveLength(1) + expect(scaffold.ctx.sessions.list().every(session => + session.snapshotEvents().every(event => event.type !== 'user/message'))).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'web-e2e-default-workspace-failure') + throw error + } + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } + }) +}) diff --git a/apps/web/tests/expected/default-workspace/failure.expected.md b/apps/web/tests/expected/default-workspace/failure.expected.md new file mode 100644 index 0000000000..6944d1cf14 --- /dev/null +++ b/apps/web/tests/expected/default-workspace/failure.expected.md @@ -0,0 +1,6 @@ +- dialog "无法创建默认工作区": + - heading "无法创建默认工作区" [level=2] + - button "关闭" + - text: 请选择一个文件夹后重试 + - button "取消" + - button "选择文件夹" diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index e7249a0b67..75371e73b2 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -600,6 +600,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { } /** - * Drive the hero's workspace picker through the composed directory dialog - * until the live composer unlocks. A fresh world has no Workspace, so the boot - * lands in the Workspace-trigger view state (startup auto-selection has nothing to - * select); every scenario that types into the composer must connect one - * first. With nothing to list, activating the composer surface raises the dialog directly — - * adding a workspace is the picker's only entry. The directory is staged here - * and adopted through the path editor, which is idempotent across the repeated - * connects a scenario may make; creating a folder from inside the dialog (the - * product's other half of the same route) is covered by - * workspace-management.e2e.ts. The default name 'workspace' keeps the session - * header cwd at /workspace, the materialization proof several scenarios - * assert. + * Select a staged directory through the hero's composed Workspace picker. + * The default name keeps the Session cwd at /workspace for scenarios + * that require a specific directory instead of automatic first-use preparation. * @param page - the page under test. * @param root - host directory the workspace folder is staged in (the scaffold's `workspaceCwd`). * @param name - folder name staged and adopted as the workspace. */ export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise { mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('textbox', { name: 'Choose workspace' }).click() + await page.getByRole('button', { name: 'Choose workspace', exact: true }).click() const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Edit path' }).click() @@ -131,8 +122,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: 'Open', exact: true }).click() - // The pick connected the workspace: the blank session's live composer - // replaces the locked placeholder and enables. + await page.getByRole('treeitem', { name: 'New Session', exact: true }).waitFor({ timeout: 15_000 }) await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]') .waitFor({ timeout: 15_000 }) } @@ -148,7 +138,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo */ export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise { mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('textbox', { name: '选择工作区' }).click() + await page.getByRole('button', { name: '选择工作区', exact: true }).click() const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: '编辑路径' }).click() @@ -156,6 +146,7 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = ' await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: '打开', exact: true }).click() + await page.getByRole('treeitem', { name: '新会话', exact: true }).waitFor({ timeout: 15_000 }) await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容, / 调用指令, @ 文件或对话"]') .waitFor({ timeout: 15_000 }) } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4508e03def..8560d4f99e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -63,6 +63,7 @@ "tests/deepseek-messages-chat.e2e.ts", "tests/models-settings-recovery.e2e.ts", "tests/default-model.e2e.ts", + "tests/default-workspace.e2e.ts", "tests/github-ready-review.e2e.ts", "tests/streaming-fence-highlight.e2e.ts", "tests/declared-reasoning.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 106e1545fb..4274817f94 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: dad6b57ae0712e04ad16729b4067c541f83c68d6 -config-catalog.zh.md: 4ba0378810f3db4797c0d41fa5230f7069438b3f +config-catalog.md: c3dabe13807ef3ec2db7d861fdc93f9016260bc7 +config-catalog.zh.md: bd3b7e4dbc64627b6b9904360fcfb13f7f3da43e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dad6b57ae0..c3dabe1380 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -274,6 +274,24 @@ export interface Config { Source: [`packages/api/terminal-controller/src/index.ts:26`](../packages/api/terminal-controller/src/index.ts) + + +## `@deepseek-ai/dsh-api-workspace-controller` + +Requires: `typert` · `workspaceRegistry` + +```ts config-catalog +/** First-use directory policy for the Host account. */ +export interface Config { + /** Override the system Documents directory with a fully qualified path. */ + documentsDirectory?: string + /** Maximum duration of the operating system's Documents lookup. */ + documentsLookupTimeoutMs?: number +} +``` + +Source: [`packages/api/workspace-controller/src/index.ts:34`](../packages/api/workspace-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-workspace-files` @@ -3783,7 +3801,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) -- `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-browser-use` ([`packages/browser-use/browser-use/src/index.ts`](../packages/browser-use/browser-use/src/index.ts)) - `@deepseek-ai/dsh-client-file-upload` — requires `agents` · `attachments` · `commands` · `connection` ([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4ba0378810..bd3b7e4dbc 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -276,6 +276,24 @@ export interface Config { 来源: [`packages/api/terminal-controller/src/index.ts:26`](../packages/api/terminal-controller/src/index.ts) + + +## `@deepseek-ai/dsh-api-workspace-controller` + +需要:`typert` · `workspaceRegistry` + +```ts config-catalog +/** First-use directory policy for the Host account. */ +export interface Config { + /** Override the system Documents directory with a fully qualified path. */ + documentsDirectory?: string + /** Maximum duration of the operating system's Documents lookup. */ + documentsLookupTimeoutMs?: number +} +``` + +源码:[`packages/api/workspace-controller/src/index.ts:34`](../packages/api/workspace-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-workspace-files` @@ -3785,7 +3803,6 @@ export interface Config { - `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) -- `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-browser-use` ([`packages/browser-use/browser-use/src/index.ts`](../packages/browser-use/browser-use/src/index.ts)) - `@deepseek-ai/dsh-client-file-upload` — 需要 `agents` · `attachments` · `commands` · `connection`([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index db7bb548ef..a7a3e3bb91 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -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 docs/subsystems/workspace.md -workspace.md: 9fe5f2bf4c62ba7d4f878b7b2f34490f43ca0b81 -workspace.zh.md: 63681e1a459c81a327654cfafa1f5cb2f231d6c8 +workspace.md: b68acd965b55df462a0751d0b7e3230c82a62172 +workspace.zh.md: 074f59438eb16a4ffb1423540c3e1868857ae40f diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 9fe5f2bf4c..b68acd965b 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -121,6 +121,10 @@ Ownership truth is the record's ordered `sessionIds`, never derived from session Sessions get their cwd at create time from whoever creates them, not from this registry — the API gateway resolves a new session's cwd from the chosen workspace's `path` (falling back to an explicit or default cwd), creates the session so the cwd lands in its immutable [`SessionHeader`](persistence.md#sessionheader--metadata-beside-the-log), then calls `attachSession`, which re-validates that stored header cwd against the workspace path. On the first successful start, the registry bootstraps history from persisted headers alone (`id`, `cwd`, `createdAt` — never event bodies), grouping sessions with a valid canonical cwd into per-directory workspaces, newest first; the initialized marker is written last so an interrupted bootstrap resumes safely. The bootstrap is one-time: cwd-less legacy sessions stay Ungrouped, and sessions created afterwards join a workspace only through `attachSession`. +## Default Workspace initialization + +The controller's [transport types](../../packages/api/workspace-controller/src/types.ts) define `WorkspaceInitializeDefaultRequest`: a Client-resolved `directoryName` and initial `title`. The Host resolves the Documents location and asks the registry to initialize once. Locale selection belongs to the Client; the registry accepts a directory resolver and commits the registration with its durable identity. [First-use behavior and configuration](../../packages/api/workspace-controller/README.md#first-use-workspace) describe reuse and failure handling. + ## Session pinning The controller's [transport types](../../packages/api/workspace-controller/src/types.ts) define `WorkspacePinSessionRequest` and `WorkspaceUnpinSessionRequest`, each carrying one `sessionId`. Both operations return `WorkspacePinValue`: the complete `pinnedSessionIds` array of Session ids, most recently pinned first. Pinning requires a known, unarchived Session; unpinning an id that is not pinned succeeds without changing the set. Archiving removes the Session's pin in the same durable write, and unarchiving does not restore it. @@ -301,6 +305,14 @@ Host service backing the generated `ctx.remote.workspace` namespace. */ @Remote('create') create(request: WorkspaceCreateRequest): Promise +/** + * Initialize or reuse the default Workspace before the first user message. + * @param request - initial directory name and title; never rename an existing default. + * @param signal - caller lifetime; cancels native directory lookup. + * @returns the durable Workspace without creating a Session or sending a message. + */ +@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise + /** * Rename one Workspace to a unique non-blank title. * @param request - Workspace identity and proposed title. @@ -466,6 +478,17 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c */ async create(path: string, title?: string): Promise +/** + * Initialize the default Workspace only while both the registry and Session + * history are empty. Repeated requests reuse its durable identity; deleting + * that registration permanently disables automatic creation. + * @param resolveDirectory - resolve the absolute directory and initial title; + * called only for eligible creation, inside the registry mutation queue. + * Missing directories are created recursively before registration. + * @returns the initialized Workspace, or undefined when automatic creation is ineligible. + */ +initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise + /** * Look up a workspace by id. * @param id - Workspace id. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 63681e1a45..074f59438e 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -121,6 +121,10 @@ interface Workspace { 会话的 cwd 在创建时由创建者赋予,而不是由本注册表赋予——API 网关从所选工作区的 `path` 解析新会话的 cwd(回退到显式或默认 cwd),先创建会话使 cwd 落入其不可变的 [`SessionHeader`](persistence.zh.md#sessionheader--metadata-beside-the-log),再调用 `attachSession`,后者会把已存储的 header cwd 与工作区路径重新校验一遍。首次成功启动时,注册表仅凭已持久化的 header(`id`、`cwd`、`createdAt`——绝不读事件正文)引导历史:把规范 cwd 有效的会话按目录分组为工作区,最新的排在最前;「已初始化」标记最后写入,因此被中断的引导可以安全续跑。引导只发生这一次:没有 cwd 的历史遗留会话保持 Ungrouped,此后创建的会话只能通过 `attachSession` 加入工作区。 +## 默认工作区初始化 + +控制器的[传输类型](../../packages/api/workspace-controller/src/types.ts)定义了 `WorkspaceInitializeDefaultRequest`,包含 Client 解析出的 `directoryName` 和初始 `title`。Host 解析 Documents 位置,并请求注册表执行一次初始化。语言选择由 Client 负责;注册表接收目录解析器,并将登记与持久化身份一起提交。[首次使用行为与配置](../../packages/api/workspace-controller/README.zh.md#first-use-workspace)说明复用和失败处理。 + ## 会话置顶 控制器的[传输类型](../../packages/api/workspace-controller/src/types.ts)定义了 `WorkspacePinSessionRequest` 和 `WorkspaceUnpinSessionRequest`,两者都携带一个 `sessionId`。两个操作都返回 `WorkspacePinValue`:完整的会话 id 数组 `pinnedSessionIds`,最近置顶的会话排在前面。置顶要求会话已知且未归档;对未置顶的 id 取消置顶会成功,且不改变集合。归档在同一次持久化写入中移除该会话的置顶,取消归档不会恢复置顶。 @@ -301,6 +305,14 @@ Host service backing the generated `ctx.remote.workspace` namespace. */ @Remote('create') create(request: WorkspaceCreateRequest): Promise +/** + * Initialize or reuse the default Workspace before the first user message. + * @param request - initial directory name and title; never rename an existing default. + * @param signal - caller lifetime; cancels native directory lookup. + * @returns the durable Workspace without creating a Session or sending a message. + */ +@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise + /** * Rename one Workspace to a unique non-blank title. * @param request - Workspace identity and proposed title. @@ -466,6 +478,17 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c */ async create(path: string, title?: string): Promise +/** + * Initialize the default Workspace only while both the registry and Session + * history are empty. Repeated requests reuse its durable identity; deleting + * that registration permanently disables automatic creation. + * @param resolveDirectory - resolve the absolute directory and initial title; + * called only for eligible creation, inside the registry mutation queue. + * Missing directories are created recursively before registration. + * @returns the initialized Workspace, or undefined when automatic creation is ineligible. + */ +initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise + /** * Look up a workspace by id. * @param id - Workspace id. diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index cd584226a9..e7bc836965 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/README.i18n.yaml @@ -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/api/workspace-controller/README.md -README.md: 46556b0105bb5186cf27b56faee4127b358eac9b -README.zh.md: 9d210c10486a71772cf6c0a10480b734fa992441 +README.md: 275c51d0018dda5b2209cc7bece71b618e7d113d +README.zh.md: 92ca3f86f11ce77b51f834ce7c24f195095065b1 diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index 46556b0105..275c51d001 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -26,6 +26,18 @@ The Host controller serializes mutations whose correctness depends on current re The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived and pinned Session identities, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. Pin snapshots change only when their identities or order change. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner. + +### First-use Workspace + +`workspace.initializeDefault({ directoryName, title })` returns the durable default Workspace; the Client service exposes the same request as `workspaces.initializeDefault(request, signal?)`. The [Conversation Client](../../client/ui-conversation/README.md) resolves these names from its send-time language. The Host places the directory under its account's `/deepseek-harness`, including on remote Web hosts. The directory name must be one non-blank segment without separators, colon, NUL, surrounding whitespace, or a trailing dot; the title must be non-blank. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory. + +The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace) owns eligibility, directory creation, and durable initialization. An existing default Workspace is returned without another Documents lookup; request names do not rename it. Invalid names or ineligible first use reject with `gateway/bad-request`; lookup and creation failures use standard Remote error handling. Initialization creates no Session and sends no message. + +| Configuration | Default | Purpose | +| --- | --- | --- | +| `documentsDirectory` | System Documents directory | Fully qualified Host directory override | +| `documentsLookupTimeoutMs` | `10000` | Positive maximum duration of OS directory lookup, in milliseconds | + ----- diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index 9d210c1048..92ca3f86f1 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -26,6 +26,18 @@ Host 控制器会串行执行正确性取决于当前注册表状态的变更, Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、归档与置顶会话身份、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。置顶快照仅在会话身份或顺序变化时更新。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。 + +### 首次使用工作区 + +`workspace.initializeDefault({ directoryName, title })` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(request, signal?)` 提供同一请求。[Conversation Client](../../client/ui-conversation/README.zh.md)按发送时的语言解析这些名称。Host 将目录放在其账户的 `/deepseek-harness` 下,远程 Web Host 也遵循此规则。目录名必须是非空的单个片段,不能含分隔符、冒号、NUL、首尾空白或末尾句点;标题不能为空。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录。 + +[Workspace 注册表](../../workspace/workspace/README.zh.md#first-use-workspace)负责资格判断、目录创建和持久化初始化。已有默认工作区直接返回,不再查询 Documents;请求中的名称不会将其重命名。名称无效或不满足首次使用条件时,以 `gateway/bad-request` 拒绝;查询和创建失败遵循标准 Remote 错误处理。初始化不创建 Session,也不发送消息。 + +| 配置 | 默认值 | 用途 | +| --- | --- | --- | +| `documentsDirectory` | 系统 Documents 目录 | 完全限定的 Host 目录覆盖值 | +| `documentsLookupTimeoutMs` | `10000` | 操作系统目录查询的正数最大时长,单位为毫秒 | + ----- diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 48881e37ce..8085412c62 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -66,6 +66,8 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-deque": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index 9a1af4c2e7..a9a98085f8 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -17,7 +17,7 @@ export type { } from './model.ts' export { WorkspaceController, WorkspaceCreateError } from './service.ts' export type { IWorkspaces, WorkspaceSource } from './service.ts' -export type { WorkspaceId, WorkspaceView } from '../types.ts' +export type { WorkspaceId, WorkspaceInitializeDefaultRequest, WorkspaceView } from '../types.ts' type WorkspaceBaselineFrame = Extract diff --git a/packages/api/workspace-controller/src/client/model.ts b/packages/api/workspace-controller/src/client/model.ts index 570b70aeb3..3f6dda9f76 100644 --- a/packages/api/workspace-controller/src/client/model.ts +++ b/packages/api/workspace-controller/src/client/model.ts @@ -12,6 +12,7 @@ import type { WorkspaceCreateValue, WorkspaceDeleteValue, WorkspaceInsertSessionBeforeRequest, + WorkspaceInitializeDefaultRequest, WorkspaceOrderValue, WorkspacePinSessionRequest, WorkspacePinValue, @@ -101,6 +102,18 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { return result } + /** + * Initialize the default Workspace and merge its authoritative row. + * @param request - initial directory name and title. + * @param signal - caller lifetime. + * @returns generated Remote result. + */ + async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise> { + const result = await this.remote.initializeDefault(request, signal) + if (result.ok) this.upsert(result.value.workspace) + return result + } + /** * Rename a Workspace and merge the unary result immediately. * @param workspaceId - target Workspace. diff --git a/packages/api/workspace-controller/src/client/service.ts b/packages/api/workspace-controller/src/client/service.ts index 00998f8519..f94baafdb2 100644 --- a/packages/api/workspace-controller/src/client/service.ts +++ b/packages/api/workspace-controller/src/client/service.ts @@ -4,7 +4,7 @@ import { Service, type Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' -import type { WorkspaceView } from '../types.ts' +import type { WorkspaceInitializeDefaultRequest, WorkspaceView } from '../types.ts' import type { ClientWorkspaceModel, WorkspaceSnapshot } from './model.ts' /** Structured create failure for callers that distinguish Host business errors. */ @@ -39,6 +39,13 @@ export interface IWorkspaces { * @returns the created or idempotently resolved Workspace. */ create(input: { path: string }): Promise + /** + * Initialize or reuse the default Workspace. + * @param request - initial directory name and title. + * @param signal - caller lifetime. + * @returns the prepared Workspace; rejects when directory selection is required. + */ + initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise /** * Rename a Workspace. * @param workspaceId - target Workspace. @@ -110,6 +117,12 @@ export class WorkspaceController extends Service implements IWorkspaces { return result.value.workspace } + async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise { + const result = await this.model.initializeDefault(request, signal) + if (!result.ok) throw new WorkspaceCreateError(result.error) + return result.value.workspace + } + async rename(workspaceId: WorkspaceId, title: string): Promise { const result = await this.model.rename(workspaceId, title) if (!result.ok) throw commandError('rename', result.error) diff --git a/packages/api/workspace-controller/src/default-directory.ts b/packages/api/workspace-controller/src/default-directory.ts new file mode 100644 index 0000000000..2001b903c6 --- /dev/null +++ b/packages/api/workspace-controller/src/default-directory.ts @@ -0,0 +1,79 @@ +/** Resolve the Host account's Documents directory for first-use Workspace creation. */ + +import { homedir } from 'node:os' +import { posix, win32 } from 'node:path' +import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' + +/** Platform observations replaceable in directory-resolution tests. */ +interface DocumentsDirectoryInternals { + readonly platform?: NodeJS.Platform + readonly home?: string + readonly run?: NativeCommandRunner +} + +/** + * Validate a configured or OS-returned Documents path without resolving it against cwd. + * @param directory - fully qualified directory spelling. + * @param platform - Host platform. + * @returns the normalized directory. + */ +export function validateDocumentsDirectory(directory: string, platform: NodeJS.Platform = process.platform): string { + const paths = platform === 'win32' ? win32 : posix + const root = paths.parse(directory).root + if (!paths.isAbsolute(directory) || (platform === 'win32' && (root === '\\' || root === '/'))) { + throw new Error(`Documents directory must be fully qualified: '${directory}'`) + } + return paths.normalize(directory) +} + +/** + * Resolve the first-use directory on the Host without creating files. + * @param directoryName - validated single directory name supplied by the Client. + * @param documentsDirectory - explicit deployment override for the system Documents directory. + * @param signal - caller lifetime and lookup deadline. + * @param internals - platform facts and native command runner. + * @returns the absolute candidate path. + */ +export async function defaultWorkspaceDirectory( + directoryName: string, + documentsDirectory: string | undefined, + signal: AbortSignal, + internals: DocumentsDirectoryInternals = {}, +): Promise { + const platform = internals.platform ?? process.platform + const paths = platform === 'win32' ? win32 : posix + signal.throwIfAborted() + let directory = documentsDirectory + if (directory === undefined) { + const run = internals.run ?? runNativeCommand + let stdout: string + switch (platform) { + case 'darwin': + ({ stdout } = await run('osascript', [ + '-e', 'POSIX path of (path to documents folder from user domain without folder creation)', + ], signal)) + break + case 'win32': + ({ stdout } = await run('powershell.exe', [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', + '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ' + + '[Environment]::GetFolderPath([Environment+SpecialFolder]::MyDocuments, ' + + '[Environment+SpecialFolderOption]::DoNotVerify)', + ], signal)) + break + case 'linux': + ({ stdout } = await run('xdg-user-dir', ['DOCUMENTS'], signal)) + break + default: + throw new Error(`system Documents directory is unavailable on ${platform}`) + } + directory = stdout.replace(/[\r\n]+$/, '') + // XDG reports the home directory when this user directory is disabled. + if (directory === '' || (platform === 'linux' && paths.normalize(directory) === (internals.home ?? homedir()))) { + throw new Error('system Documents directory is unavailable') + } + } + directory = validateDocumentsDirectory(directory, platform) + signal.throwIfAborted() + return paths.join(directory, 'deepseek-harness', directoryName) +} diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index deb547e791..2281273e1e 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -1,10 +1,12 @@ /** Host Workspace Remote owner: explicit commands and reconnect-safe state. */ import { Context } from '@deepseek-ai/cordis' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import z from '@deepseek-ai/schemastery' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { WorkspaceCommands } from './commands.ts' import { DirectoryPickerController } from './directory-picker.ts' -import { WorkspaceFeed } from './feed.ts' +import { WorkspaceFeed, workspaceView } from './feed.ts' +import { defaultWorkspaceDirectory, validateDocumentsDirectory } from './default-directory.ts' import type { WorkspaceArchiveSessionRequest, WorkspaceArchiveValue, @@ -14,6 +16,7 @@ import type { WorkspaceDeleteValue, WorkspaceFollowFrame, WorkspaceInsertBeforeRequest, + WorkspaceInitializeDefaultRequest, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, WorkspacePinSessionRequest, @@ -27,6 +30,14 @@ import type { export type * from './types.ts' export { DirectoryPickerController } from './directory-picker.ts' +/** First-use directory policy for the Host account. */ +export interface Config { + /** Override the system Documents directory with a fully qualified path. */ + documentsDirectory?: string + /** Maximum duration of the operating system's Documents lookup. */ + documentsLookupTimeoutMs?: number +} + declare module '@deepseek-ai/cordis' { interface Context { /** Host Workspace business API and Remote namespace owner. */ @@ -38,12 +49,20 @@ declare module '@deepseek-ai/cordis' { export class WorkspaceController extends TypertRemoteService { static inject = ['typert', 'workspaceRegistry'] + static Config: z = z.object({ + documentsDirectory: z.string(), + documentsLookupTimeoutMs: z.natural().min(1).default(10_000), + }) + + private readonly config: Config private readonly commands: WorkspaceCommands private readonly feed: WorkspaceFeed - /** @param ctx - Host context containing the Workspace registry. */ - constructor(ctx: Context) { + /** @param ctx - Host context containing the Workspace registry. @param config - first-use directory policy. */ + constructor(ctx: Context, config: Config = {}) { super(ctx, 'workspaceController', { namespace: 'workspace' }) + this.config = WorkspaceController.Config(config) + if (this.config.documentsDirectory !== undefined) validateDocumentsDirectory(this.config.documentsDirectory) this.commands = new WorkspaceCommands(ctx) this.feed = new WorkspaceFeed(ctx) // This package is the Loader entry for both Remote owners it hosts: the @@ -63,6 +82,32 @@ export class WorkspaceController extends TypertRemoteService { return this.commands.create(request) } + /** + * Initialize or reuse the default Workspace before the first user message. + * @param request - initial directory name and title; never rename an existing default. + * @param signal - caller lifetime; cancels native directory lookup. + * @returns the durable Workspace without creating a Session or sending a message. + */ + @Remote('initializeDefault') + async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise { + const { directoryName, title } = request + if (directoryName.trim() === '' || directoryName !== directoryName.trim() + || directoryName.endsWith('.') || /[/\\:\0]/.test(directoryName) || title.trim() === '') { + throw new RemoteError('gateway/bad-request', 'default Workspace requires a directory name and non-blank title', {}) + } + const workspace = await this.ctx.workspaceRegistry.initializeDefault(async () => { + const timeout = AbortSignal.timeout(this.config.documentsLookupTimeoutMs as number) + const path = await defaultWorkspaceDirectory( + directoryName, this.config.documentsDirectory, AbortSignal.any([signal, timeout]), + ) + return { path, title } + }) + if (workspace === undefined) { + throw new RemoteError('gateway/bad-request', 'choose a directory to create a Workspace', {}) + } + return { workspace: workspaceView(workspace) } + } + /** * Rename one Workspace to a unique non-blank title. * @param request - Workspace identity and proposed title. diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index f2b78e024b..e69c2b8c91 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -54,6 +54,14 @@ export interface WorkspaceCreateRequest { readonly path: string } +/** Names chosen by the first-use Client before default Workspace initialization. */ +export interface WorkspaceInitializeDefaultRequest { + /** Single non-blank name; no separators, colon, NUL, surrounding whitespace, or trailing dot. */ + readonly directoryName: string + /** Initial display title, retained across language changes. */ + readonly title: string +} + /** Created or previously registered Workspace. */ export interface WorkspaceCreateValue { readonly workspace: WorkspaceView diff --git a/packages/api/workspace-controller/tests/default-directory.host.spec.ts b/packages/api/workspace-controller/tests/default-directory.host.spec.ts new file mode 100644 index 0000000000..5878f42432 --- /dev/null +++ b/packages/api/workspace-controller/tests/default-directory.host.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import type { NativeCommandRunner } from '@deepseek-ai/dsh-native-command' +import { defaultWorkspaceDirectory, validateDocumentsDirectory } from '../src/default-directory.ts' + +describe('system Documents directory', () => { + it.each([ + ['darwin', '/Users/a/文档/\n', '/Users/a/文档/deepseek-harness/默认工作区', 'osascript'], + ['win32', 'D:\\Redirected Documents\r\n', 'D:\\Redirected Documents\\deepseek-harness\\默认工作区', 'powershell.exe'], + ['linux', '/home/a/My Documents\n', '/home/a/My Documents/deepseek-harness/默认工作区', 'xdg-user-dir'], + ] as const)('uses the %s account directory and preserves spaces and Unicode', async (platform, stdout, path, command) => { + const run = vi.fn(async () => ({ stdout, stderr: '' })) + const signal = new AbortController().signal + await expect(defaultWorkspaceDirectory('默认工作区', undefined, signal, { platform, run })).resolves.toBe(path) + expect(run).toHaveBeenCalledWith(command, expect.any(Array), signal) + }) + + it.each(['Default workspace', '默认工作区', 'default-workspace'])('uses the configured directory with name %s', async (folder) => { + const run = vi.fn() + await expect(defaultWorkspaceDirectory(folder, '/documents', new AbortController().signal, { platform: 'linux', run })) + .resolves.toBe(`/documents/deepseek-harness/${folder}`) + expect(run).not.toHaveBeenCalled() + }) + + it.each(['', '\r\n', '/home/a\n'])('rejects an unavailable XDG directory %j', async (stdout) => { + const run: NativeCommandRunner = async () => ({ stdout, stderr: '' }) + await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'linux', home: '/home/a', run })) + .rejects.toThrow('unavailable') + }) + + it('propagates lookup failure and cancellation', async () => { + const run: NativeCommandRunner = async () => { throw new Error('lookup denied') } + await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'darwin', run })) + .rejects.toThrow('lookup denied') + await expect(defaultWorkspaceDirectory('Default workspace', '/documents', AbortSignal.abort(), { platform: 'linux' })) + .rejects.toThrow() + await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'freebsd' })) + .rejects.toThrow('unavailable') + }) + + it.each(['relative', 'C:relative', '\\rooted'])('rejects a Windows path without a fully qualified root: %s', (path) => { + expect(() => validateDocumentsDirectory(path, 'win32')).toThrow('fully qualified') + }) + + it('accepts redirected UNC Documents directories', () => { + expect(validateDocumentsDirectory('\\\\server\\share\\Documents', 'win32')).toBe('\\\\server\\share\\Documents') + expect(() => validateDocumentsDirectory('relative', 'linux')).toThrow('fully qualified') + }) +}) diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts index b126805183..4558d4f64d 100644 --- a/packages/api/workspace-controller/tests/model.client.spec.ts +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -68,6 +68,7 @@ function deferred(): Deferred { } class FakeWorkspaceRemote implements WorkspaceRemote { + readonly initializeDefault = vi.fn(async () => remoteOk({ workspace: workspace('default') })) readonly calls: Array<{ readonly method: string; readonly request: unknown }> = [] onCreate: (request: WorkspaceCreateRequest) => Promise> = request => Promise.resolve(remoteOk({ workspace: workspace(request.path.split('/').pop() ?? 'workspace'), created: true })) @@ -167,6 +168,22 @@ function baseline( } describe('ClientWorkspaceModel', () => { + it('publishes the prepared Workspace and leaves the list unchanged on refusal', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model) + const signal = new AbortController().signal + await expect(model.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, signal)).resolves.toMatchObject({ ok: true }) + expect(remote.initializeDefault).toHaveBeenCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, signal) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['default']) + const before = model.getSnapshot() + remote.initializeDefault.mockResolvedValueOnce(workspaceError( + new RemoteError('gateway/bad-request', 'choose a folder', {}), + )) + await expect(model.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot()).toBe(before) + }) + it('replaces reconnect state and applies ordered increments', () => { const model = modelFor() expect(model.getSnapshot()).toMatchObject({ phase: 'pending', state: 'loading' }) diff --git a/packages/api/workspace-controller/tests/remote/workspace.client.ts b/packages/api/workspace-controller/tests/remote/workspace.client.ts index 6857e8e31d..b8951c0c39 100644 --- a/packages/api/workspace-controller/tests/remote/workspace.client.ts +++ b/packages/api/workspace-controller/tests/remote/workspace.client.ts @@ -91,6 +91,7 @@ export function followGenerations(generations: readonly StreamScript[]): StreamS /** Default answers: every command accepted and echoed back as the row or set it names. */ export const workspaceWorld: RemoteTable = { unary: { + 'workspace/initializeDefault': (): RemoteResult => ok({ workspace: workspace('default') }), 'workspace/create': (request: WorkspaceCreateRequest): RemoteResult => ok({ workspace: workspace('created', { path: request.path }), created: true, }), diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index b99406b00c..9829004125 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -318,6 +318,7 @@ describe('WorkspaceController', () => { expect(controller.list).toBe(model) expect(client.ctx.workspaces.list).toBe(model) + await expect(controller.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, new AbortController().signal)).resolves.toMatchObject({ workspaceId: 'default' }) await expect(controller.create({ path: '/work/created' })).resolves.toMatchObject({ workspaceId: 'created' }) await expect(controller.rename(wid('one'), 'renamed')).resolves.toMatchObject({ title: 'renamed' }) await expect(controller.insertBefore(wid('one'))).resolves.toBeUndefined() @@ -330,6 +331,7 @@ describe('WorkspaceController', () => { await expect(controller.unpinSession(sid('session'))).resolves.toBeUndefined() await expect(controller.delete(wid('one'))).resolves.toBeUndefined() // Each command crosses the wire as one positional request object. + expect(mock.log.requests('workspace/initializeDefault')).toEqual([{ directoryName: '默认工作区', title: '默认工作区' }]) expect(mock.log.requests('workspace/create')).toEqual([{ path: '/work/created' }]) expect(mock.log.requests('workspace/rename')).toEqual([{ workspaceId: 'one', title: 'renamed' }]) expect(mock.log.requests('workspace/insertBefore')).toEqual([{ workspaceId: 'one' }]) @@ -347,6 +349,9 @@ describe('WorkspaceController', () => { const missingWorkspace = new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('missing') }) const missingSession = new RemoteError('session/not-found', 'missing session', { sessionId: sid('session') }) + mock.remote.workspace.initializeDefault.mockResolvedValueOnce(err(new RemoteError('gateway/internal', 'directory denied', {}))) + await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })).rejects.toBeInstanceOf(WorkspaceCreateError) + mock.remote.workspace.create.mockResolvedValueOnce(err(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' }))) const create = controller.create({ path: '/missing' }) await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError) diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index 722b132255..ecaeb9a248 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -59,7 +59,7 @@ async function harness() { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose }, } as never) - const controller = new WorkspaceController(ctx) + const controller = new WorkspaceController(ctx, { documentsDirectory: root }) return { controller, ctx, root, storageDomain } } @@ -416,3 +416,49 @@ describe('WorkspaceController follow', () => { await expect(closing).resolves.toEqual({ done: true, value: undefined }) }) }) + +describe('first-use Remote', () => { + it.each(['', ' ', '.', '..', '../outside', 'nested/name', 'nested\\name', 'C:outside', 'name\0', '.. ', 'tail.'])( + 'rejects invalid directory name %j before invoking the registry', async (directoryName) => { + const { controller, ctx } = await harness() + const initialize = vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockResolvedValue(undefined) + await expect(controller.initializeDefault({ directoryName, title: 'Default workspace' }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) + expect(initialize).not.toHaveBeenCalled() + }, + ) + + it('rejects a blank initial title before invoking the registry', async () => { + const { controller, ctx } = await harness() + const initialize = vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockResolvedValue(undefined) + await expect(controller.initializeDefault({ directoryName: 'default-workspace', title: ' ' }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) + expect(initialize).not.toHaveBeenCalled() + }) + + it('uses the requested display title independently of the directory name', async () => { + const { controller, root } = await harness() + const result = await controller.initializeDefault({ directoryName: 'default-workspace', title: 'Default workspace' }, new AbortController().signal) + expect(result.workspace).toMatchObject({ path: join(root, 'deepseek-harness', 'default-workspace'), title: 'Default workspace' }) + }) + + it('returns a durable Workspace without allocating a Session', async () => { + const { controller, ctx, root } = await harness() + const signal = new AbortController().signal + const result = await controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, signal) + expect(result.workspace.path).toBe(join(root, 'deepseek-harness', 'Default workspace')) + expect(existsSync(result.workspace.path)).toBe(true) + expect(ctx.sessions.list()).toEqual([]) + expect(await controller.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, signal)).toEqual(result) + }) + + it('rejects ineligible first use and propagates preparation failures', async () => { + const { controller, ctx, root } = await harness() + await ctx.workspaceRegistry.create(root) + await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) + vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockRejectedValueOnce(new Error('permission denied')) + await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal)) + .rejects.toThrow('permission denied') + }) +}) diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json index 10c17a0c23..17fda7ee38 100644 --- a/packages/api/workspace-controller/tsconfig.host.json +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -10,15 +10,18 @@ "src/types.ts", "src/commands.ts", "src/directory-picker.ts", - "src/feed.ts" + "src/feed.ts", + "src/default-directory.ts" ], "references": [ { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, { "path": "../../core/session" }, { "path": "../../host/directory-picker" }, { "path": "../../storage/storage-domain" }, { "path": "../../typert/protocol" }, { "path": "../../util/deque" }, + { "path": "../../util/native-command" }, { "path": "../../workspace/workspace" } ] } diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 2ed6513974..7f0a28c544 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -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/ui-agent-preset/README.md -README.md: c7a87da591c1cafc433fbb3bb5fa216d94ab85ef -README.zh.md: a54b47baed132e790fc4f44ad1155f48a19c430b +README.md: 38eba97e2c11ed319a9a5724bece1fbbd1f8095a +README.zh.md: 730c363c0876c6f6bc9ee8565aeb922526ed8f3d diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index c7a87da591..38eba97e2c 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -49,6 +49,8 @@ The settings section writes the Host's existing `agent-presets` namespace throug The chip follows the preset projection of its bound Session while its plugin and Provider generation remain active. Settings synchronization creates the same bound controller when needed, even before the chip mounts. A late projection or another client's preset update refreshes the display without submitting a selection; list notifications do not repeat an in-flight selection. Settings changes wait for that selection and recheck the captured blank Session before applying. A choice staged during a pending request remains available afterward. +After preparing a default Workspace, the first-use draft waits for the staged preset through `conversation/prepare-first-send`. Concurrent `apply()` calls wait for the active selection without repeating it. A refusal keeps the unsent Session draft. + ----- diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index a54b47baed..730c363c08 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -49,6 +49,8 @@ kind: "package-reference" 只要插件及 Provider generation 仍有效,选择芯片就会跟随其绑定 Session 的预设投影。即使选择芯片尚未挂载,设置同步也会按需创建同一个绑定控制器。延后到达的投影或其他客户端的预设更新会刷新显示,而不会提交选择;列表通知不会重复仍在进行中的选择操作。设置修改会等待该操作结束,并在应用之前重新检查已捕获的空白 Session。请求期间暂存的新选择会保留。 +默认工作区准备完成后,首次使用的草稿通过 `conversation/prepare-first-send` 等待暂存的模式选择。并发的 `apply()` 调用等待已有选择操作,不会重复提交。选择被拒绝时保留未发送的 Session 草稿。 + ----- diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index c335518aff..bc8631e577 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -143,6 +143,14 @@ export function apply(ctx: ClientContext): void { // render and simply hides the button while no flow exists. let creatorDraft: (() => void) | undefined ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => { + scope.on('conversation/prepare-first-send', async (sessionId) => { + const binding = scope.sessions.binding(sessionId) + if (binding === undefined) return + const seat = seatFor(binding) + if (staged.id === undefined && !seat.store.getSnapshot().busy) return + const error = await seat.apply() + if (error !== undefined) throw new Error(error) + }) const seatInjected = (sessionId: SessionId | undefined): AgentPresetSeatInjected => { const binding = sessionId === undefined ? undefined : ctx.sessions.binding(sessionId) const seat = binding === undefined ? unboundSeat : seatFor(binding) diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 45bd9373e4..4e7488bc3b 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -193,11 +193,18 @@ export class AgentPresetSeatController { * * Called both by `select()` and by whoever observes the current session * changing, because the session may appear either before or after the pick. - * List updates do not repeat a selection while its response is pending. - * @returns this attempt's Host refusal, or undefined when successful or no switch starts. + * Concurrent callers wait for the active selection without repeating it. + * @returns the Host refusal, or undefined when successful or no switch starts. */ async apply(): Promise { - if (this.store.getSnapshot().busy) return + let pending = this.pendingSelection + while (pending !== undefined) { + await pending + const error = this.store.getSnapshot().error + if (error !== null) return error + pending = this.pendingSelection + if (pending === undefined && this.staged.id === undefined) return + } const staged = this.staged.id const session = this.currentSession() if (staged === undefined) { diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 1e28cb44f4..ea312699ce 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -747,10 +747,12 @@ describe('ui-agent-preset apply', () => { } sessions.notify() const bound = injectSeat(SessionId('s1')) + await ctx.serial('conversation/prepare-first-send', SessionId('s1')) await bound.load() // Connecting a workspace produced the session; the stage reaches it there. await vi.waitFor(() => { expect(calls).toContain('select:minimal') }) + expect(calls.filter(call => call === 'select:minimal')).toHaveLength(1) }) it('applies the stage to a session that records no preset of its own', async () => { @@ -946,6 +948,28 @@ describe('ui-agent-preset apply', () => { }) describe('AgentPresetSeatController reconciliation', () => { + it.each([false, true])('shares the result of an active preset selection (refused: %s)', async (refused) => { + const refusal = { ok: false as const, error: new RemoteError('gateway/internal', 'selection refused', {}) } + const reply = Promise.withResolvers<{ ok: true; value: string } | typeof refusal>() + const select = vi.fn(() => reply.promise) + const controller = new AgentPresetSeatController({ remote: { agentPresets: { select } } } as never, + () => ({ id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'standard' } })) + const selecting = controller.select('minimal') + let prepared = false + const preparation = controller.apply().finally(() => { prepared = true }) + try { + expect(prepared).toBe(false) + expect(select).toHaveBeenCalledOnce() + reply.resolve(refused ? refusal : { ok: true, value: 'minimal' }) + await expect(preparation).resolves.toBe(refused ? 'selection refused' : undefined) + await selecting + expect(select).toHaveBeenCalledOnce() + } finally { + reply.resolve({ ok: true, value: 'minimal' }) + await Promise.allSettled([selecting, preparation]) + } + }) + it.each([ { refuseFirst: false, refuseLatest: false }, { refuseFirst: false, refuseLatest: true }, @@ -966,16 +990,17 @@ describe('AgentPresetSeatController reconciliation', () => { const first = controller.select('minimal') let settingsSettled = false const settings = controller.syncBlankSession(session.id, 'cordis').finally(() => { settingsSettled = true }) - await controller.apply() + const repeatedFirst = controller.apply() expect(requests.map(request => request.preset)).toEqual(['minimal']) requests[0]!.outcome.resolve(refuseFirst ? refusal : { ok: true, value: 'minimal' }) await first expect(requests.map(request => request.preset)).toEqual(['minimal', 'cordis']) expect(settingsSettled).toBe(false) - await controller.apply() + const repeatedLatest = controller.apply() expect(requests).toHaveLength(2) requests[1]!.outcome.resolve(refuseLatest ? refusal : { ok: true, value: 'cordis' }) await expect(settings).resolves.toBe(refuseLatest ? 'selection refused' : undefined) + await Promise.all([repeatedFirst, repeatedLatest]) expect(controller.store.getSnapshot()).toMatchObject({ current: refuseLatest ? 'standard' : 'cordis', busy: false, error: refuseLatest ? 'selection refused' : null, @@ -992,10 +1017,10 @@ describe('AgentPresetSeatController reconciliation', () => { () => ({ id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'standard' } })) const first = controller.select('minimal') controller.stage('cordis', true) - await controller.apply() + const repeated = controller.apply() expect(select).toHaveBeenCalledOnce() firstReply.resolve(refused ? refusal : { ok: true, value: 'minimal' }) - await first + await Promise.all([first, repeated]) expect(controller.store.getSnapshot().current).toBe('cordis') await controller.apply() expect(select.mock.calls.map(call => call[1])).toEqual(['minimal', 'cordis']) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 74cd3168df..2002de04c1 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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/ui-conversation/README.md -README.md: fef3a6551b517af2ac636687883864525aac70ee -README.zh.md: e9ab08c686420c38c11ca2bede2fd00099a247a5 +README.md: 56b9d06ce5e48ab40691bede2ccb9669e180aa9f +README.zh.md: fd02565698171e0b6db35080181e57b8b5abd42d diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fef3a6551b..56b9d06ce5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -50,6 +50,10 @@ Claimed commands retain their identity and highlight when only their arguments a Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact. +The Client chooses the default directory name and initial title when sending: Chinese uses `默认工作区`, English uses `Default workspace`, and other languages use directory `default-workspace` with title `Default workspace`. The request retains those names during initialization. A retry after failed initialization uses the current language; a successfully initialized Workspace keeps its directory and title across language changes. + +On first use, the same input accepts text before a Workspace is selected. The first send prepares the [default Workspace](../../workspace/workspace/README.md#first-use-workspace), selects its blank Session, transfers the draft, and uses ordinary message submission. Preparation locks further input. Failure preserves the draft and offers Cancel or Choose folder through the existing picker. A manually chosen folder receives the unsent draft; the user sends again. Once created, the Workspace survives a prompt failure. `conversation/prepare-first-send` awaits staged Session settings before automatically submitting the transferred first-use draft; a rejected listener keeps that draft and reports its error. + The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident container reserves a 40px drag band on macOS desktop and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. @@ -60,7 +64,7 @@ The shell reads the persisted View preference before rendering when a Session fi Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([handle styles](src/client/skeleton/ConversationRoot.module.css)). -The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. +The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. Before a Session exists, the composer holds a local text draft; sending waits for complete Workspace and Session baselines and then prepares the default Workspace. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e9ab08c686..fd02565698 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -50,6 +50,10 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。 +Client 在发送时决定默认目录名和初始标题:中文使用 `默认工作区`,英文使用 `Default workspace`,其他语言使用目录 `default-workspace` 和标题 `Default workspace`。初始化期间,请求保留这些名称。初始化失败后的重试使用当前语言;初始化成功的 Workspace 在切换语言后仍保留原目录和标题。 + +首次使用时,同一个输入框允许在选择 Workspace 前输入文字。首条消息会准备[默认工作区](../../workspace/workspace/README.zh.md#first-use-workspace)、选中其 blank Session、转移草稿,再使用普通消息提交流程。准备期间锁定后续输入。失败时保留草稿,并提供“取消”或通过已有选择器“选择文件夹”。手动选择的文件夹接收未发送草稿,用户再次发送。Workspace 创建成功后,不会因提示词发送失败而消失。`conversation/prepare-first-send` 在自动提交已转移的首次使用草稿前等待暂存的 Session 设置完成;监听器拒绝时保留该草稿并显示错误。 + 本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻容器在 macOS 桌面保留 40px 拖拽区域,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。 @@ -60,7 +64,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([拖动手柄样式](src/client/skeleton/ConversationRoot.module.css))。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。Session 尚未存在时,composer 保存本地文字草稿;发送会等待 Workspace 与 Session 基线完整加载,然后准备默认工作区。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 3fef945990..02c1f3a593 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceInitializeDefaultRequest } from '@deepseek-ai/dsh-api-workspace-controller/client' import { IconPaperclipOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -17,13 +18,13 @@ import type { ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, DraftFileUploads, } from './contract/slots.ts' -import type { InputNotice } from './contract/input.ts' import { createConversationStore, readConversationViewPreference } from './stores.ts' import { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' import type { IConversation } from './service.ts' import { ComposerBlockRegistry } from './input/blocks.ts' import type { ComposerBlock } from './contract/composer-blocks.ts' import { InputHub } from './input/hub.ts' +import { FirstDraft } from './input/first-draft.ts' import { ComposerSubmissionPolicy } from './input/submission-policy.ts' import { queueDockEntry } from './queue/QueueDock.tsx' import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx' @@ -64,19 +65,10 @@ export const Config: z = z.object({ // Stable no-session sources keep the renderer's observable-hook cache and // hook order unchanged across current-Session transitions. -const ABSENT_NOTICES = { - getSnapshot: (): InputNotice | null => null, - subscribe: () => () => {}, -} const ABSENT_BLOCK = { getSnapshot: (): ComposerBlock | undefined => undefined, subscribe: () => () => {}, } -const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() -const ABSENT_LEXICON = { - getSnapshot: () => EMPTY_LEXICON, - subscribe: () => () => {}, -} const ABSENT_MENU_LAUNCHER = { getSnapshot: (): string | null => null, subscribe: () => () => {}, @@ -87,12 +79,19 @@ const ABSENT_FILE_UPLOADS = { subscribe: () => () => {}, } +// ui-workspace consumes Conversation slots, so this reverse runtime dependency +// uses a local method projection to keep the TypeScript project graph acyclic. interface WorkspaceNavigation { openSession(sessionId: SessionId): void openWorkspace( workspaceId: Parameters[0], beforeOpen: (sessionId: SessionId) => void, ): Promise + openDefaultWorkspace( + request: WorkspaceInitializeDefaultRequest, + beforeOpen: (sessionId: SessionId) => void, + signal: AbortSignal, + ): Promise } /** Action registration used by the composer without importing its command-UI consumer. */ @@ -206,6 +205,17 @@ export function apply(ctx: Context, config: Config = Config({})): void { }, 'ui-conversation: View selection') const inputHub = new InputHub(ctx, t) + const firstDraft = new FirstDraft(ctx, { + open: (beforeOpen, signal) => { + const language = ctx.locale.getSnapshot().active.toLowerCase().split('-')[0] + const title = (language === 'zh' ? zh : en)['defaultWorkspace.title'] + return workspaceNavigation.openDefaultWorkspace({ + directoryName: language === 'zh' || language === 'en' ? title : 'default-workspace', + title, + }, beforeOpen, signal) + }, + shell: id => inputHub.shell(id), + }) const composerBlocks = new ComposerBlockRegistry() ctx.inject(['commandUi'], (scope) => { @@ -266,8 +276,11 @@ export function apply(ctx: Context, config: Config = Config({})): void { inject: (sessionId: SessionId | undefined): ConversationInjected => ({ hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId), + firstDraft: firstDraft.state, }, + dismissDefaultFailure: () => { firstDraft.dismiss() }, selectWorkspace: workspaceId => workspaceNavigation.openWorkspace(workspaceId, (nextId) => { + if (sessionId === undefined) firstDraft.transfer(nextId) if (sessionId !== undefined && nextId !== sessionId) { const from = inputHub.shell(sessionId) const draft = from.snapshot.draft @@ -373,7 +386,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { inject: (sessionId: SessionId | undefined): ComposerBarInjected => { if (sessionId === undefined) { return { - keyboard: undefined, + keyboard: firstDraft.shell, addFiles: undefined, removeAttachment: undefined, resolveDraftAttachments: undefined, @@ -381,10 +394,11 @@ export function apply(ctx: Context, config: Config = Config({})): void { toggleCommandMenu: undefined, stop: undefined, hooks: { + composerInput: firstDraft.shell.state, busyEnter: submissionPolicy.busyEnter, fileUploads: ABSENT_FILE_UPLOADS, - notices: ABSENT_NOTICES, - lexicon: ABSENT_LEXICON, + notices: firstDraft.shell.notices, + lexicon: firstDraft.shell.lexicon, menuLauncher: ABSENT_MENU_LAUNCHER, }, } @@ -433,6 +447,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { }) }, hooks: { + composerInput: shell.state, busyEnter: submissionPolicy.busyEnter, fileUploads: conversation.fileUploads, notices: shell.notices, diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index b4cd6b9bdf..be338eb3df 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -34,6 +34,12 @@ export interface SubmitOutcome { readonly text?: string } +/** State of first-use Workspace preparation. */ +export interface FirstDraftState { + readonly busy: boolean + readonly failed: boolean +} + /** Command-mode credential supplied by one input-trigger source. */ export interface CommandClaim { /** Catalog command name without the leading slash (the key of per-command composer copy such as `hint.*`). */ diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 023babf26e..a0d9a6d592 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -16,7 +16,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { ComposerBlock } from './composer-blocks.ts' -import type { DraftAttachmentId, InputActions, InputNotice, InputState } from './input.ts' +import type { DraftAttachmentId, FirstDraftState, InputActions, InputNotice, InputState } from './input.ts' import type { ComposerKeyboard, EditSelection } from './draft-editor.ts' import type { createConversationStore } from '../stores.ts' import type { BusyEnterBehavior } from './composer-submission.ts' @@ -319,8 +319,13 @@ export type ConvViewProps = PropsRuntime<'conversation.view'> export interface ConversationInjected { /** Connect and open a blank Session in the selected Workspace. */ selectWorkspace: (workspaceId: WorkspaceId) => Promise + /** Dismiss default Workspace creation failure without discarding input. */ + dismissDefaultFailure: () => void /** Session-addressed composer block source, or the stable absent source. */ - hooks: { composerBlock: ObservableSnapshot } + hooks: { + composerBlock: ObservableSnapshot + firstDraft: ObservableSnapshot + } } /** Business callbacks injected into the strict Session body. */ @@ -365,7 +370,7 @@ export interface ComposerBarOwnerProps { /** Package-private operations injected into the resident composer bar. */ export interface ComposerBarInjected { - keyboard: ComposerKeyboard | undefined + keyboard: ComposerKeyboard addFiles: ((files: readonly File[]) => string | null) | undefined removeAttachment: ((id: DraftAttachmentId) => void) | undefined resolveDraftAttachments: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined @@ -374,6 +379,8 @@ export interface ComposerBarInjected { toggleCommandMenu: ((selection: EditSelection) => void) | undefined stop: (() => void) | undefined hooks: { + /** Resident editor state, including input before a Session exists. */ + composerInput: ObservableSnapshot /** * Live busy-state submission preference: the delivery mode plain Enter * and the primary Send button use while the addressed agent is busy. diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 73ddc6d7d3..468fc9c198 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -72,6 +72,14 @@ export type { ArbitrateKey, ArbitrateOutcome, ReferenceInsert, TokenSpan } from export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts' declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Finish staged settings before automatically submitting the first-use draft to its new Session. + * @mode serial + * @param sessionId - destination Session. + */ + 'conversation/prepare-first-send'(sessionId: import('@deepseek-ai/dsh-session/types').SessionId): void | Promise + } interface Context { /** Scope-addressed Conversation actions and per-Session input registry. */ conversation: import('./service.ts').IConversation diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 4f4d0aa300..77bff28708 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -5,8 +5,8 @@ * scoped-event application verbs, the submit transaction plumbing * (adjudicate via the session's InputTriggerController; claim.submit; default * sink), the notice channel, and the draft persistence mirror. - * Package-private; the hub alone constructs it and wires the scoped event - * listeners onto it. + * Package-private; InputHub binds Session events, while FirstDraft owns the + * local editor used before a Session exists. */ import type { Context } from '@deepseek-ai/cordis' import type { InboxState } from '@deepseek-ai/dsh-agent/types' diff --git a/packages/client/ui-conversation/src/client/input/first-draft.ts b/packages/client/ui-conversation/src/client/input/first-draft.ts new file mode 100644 index 0000000000..9e1c486eae --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/first-draft.ts @@ -0,0 +1,79 @@ +/** Local text draft retained until the first Workspace and Session can accept it. */ +import type { Context } from '@deepseek-ai/cordis' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { FirstDraftState, SubmitOutcome } from '../contract/input.ts' +import type { InputSubmitMode } from '../contract/composer-submission.ts' +import { SessionInputShell } from './facade.ts' + +/** First-use navigation and the resident destination input. */ +interface FirstDraftDeps { + open(beforeOpen: (id: SessionId) => void, signal: AbortSignal): Promise + shell(id: SessionId): SessionInputShell +} + +/** Owns a root-lifetime text editor without allocating a Host Session. */ +export class FirstDraft { + /** Preparation progress and the recoverable directory failure. */ + readonly state = createSnapshotStore({ busy: false, failed: false }) + /** Local editor that stays independent of Host Session allocation. */ + readonly shell: SessionInputShell + private pending: { text: string; transferred: boolean } | undefined + + /** @param ctx - Client plugin lifetime. @param deps - Workspace navigation and Session input access. */ + constructor(private readonly ctx: Context, private readonly deps: FirstDraftDeps) { + this.shell = new SessionInputShell({ + actx: ctx, + defaultSink: (text, _attachments, mode, signal) => this.send(text, mode, signal), + commandAttachments: { + serialize: () => Promise.resolve([]), + release: () => {}, + unsupportedNotice: () => '', + }, + }) + ctx.effect(() => () => { this.shell.dispose() }, 'conversation.input: first-use draft') + } + + /** Dismiss the preparation failure while retaining the draft. */ + dismiss(): void { + this.state.set({ ...this.state.getSnapshot(), failed: false }) + } + + /** + * Move the unsent text to a selected Session. + * @param id - retained destination Session receiving the unsent text. + */ + transfer(id: SessionId): void { + const text = this.pending?.text ?? this.shell.snapshot.draft + if (text !== '') this.deps.shell(id).setDraft(text) + if (this.pending !== undefined) this.pending.transferred = true + this.shell.setDraft('') + this.dismiss() + } + + private async send(text: string, mode: InputSubmitMode, signal: AbortSignal): Promise { + if (this.pending !== undefined) return { kind: 'error' } + const pending = { text, transferred: false } + this.pending = pending + this.state.set({ busy: true, failed: false }) + let target: SessionInputShell | undefined + try { + const id = await this.deps.open((nextId) => { this.transfer(nextId) }, signal) + if (id === undefined) return { kind: pending.transferred ? 'success' : 'error' } + target = this.deps.shell(id) + await this.ctx.serial('conversation/prepare-first-send', id) + signal.throwIfAborted() + target.submit(mode) + return { kind: 'success' } + } catch (error) { + if (!signal.aborted) { + if (target !== undefined) target.notify('error', error instanceof Error ? error.message : String(error)) + else if (!pending.transferred) this.state.set({ busy: true, failed: true }) + } + return { kind: pending.transferred ? 'success' : 'error' } + } finally { + this.pending = undefined + this.state.set({ ...this.state.getSnapshot(), busy: false }) + } + } +} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 464ff55e17..c66fe10a8a 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -9,6 +9,10 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'defaultWorkspace.failed': '无法创建默认工作区', + 'defaultWorkspace.title': '默认工作区', + 'defaultWorkspace.retry': '请选择一个文件夹后重试', + 'defaultWorkspace.choose': '选择文件夹', 'hint.plan': PLAN_NEXT_ACTION_ZH, 'hint.goal': '输入目标,智能体将持续执行', 'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', @@ -360,6 +364,10 @@ export type ConversationKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'defaultWorkspace.failed': 'Unable to create default workspace', + 'defaultWorkspace.title': 'Default workspace', + 'defaultWorkspace.retry': 'Please choose a folder and try again', + 'defaultWorkspace.choose': 'Choose folder', 'hint.plan': PLAN_NEXT_ACTION_EN, 'hint.goal': 'describe the objective for a long-running task', 'hint.goal.active': 'goal active — edit / pause / resume / clear', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index adace1c113..bf6456291f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' +import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { ConversationContentProps, ConversationViewsProps, InputZone } from '../contract/slots.ts' import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' @@ -22,7 +23,7 @@ export function ConversationContent(props: ConversationContentProps) { const { sessionId, phase, hero, useSession, useSessions, useSessionStatus, useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain, - selectWorkspace, t, useFactorySlot, + selectWorkspace, dismissDefaultFailure, useFirstDraft, t, useFactorySlot, } = props const session = useSession(snapshot => snapshot) const Views = useFactorySlot('views', ConversationSessionView) @@ -33,6 +34,8 @@ export function ConversationContent(props: ConversationContentProps) { const inputState = useInput(s => s) const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd) const workspaces = useWorkspaces(s => s) + const sessionsReady = useSessions(s => s.phase === 'ready') + const firstDraft = useFirstDraft(s => s) // A plugin this package cannot import (ui-model-selection) says this session cannot // send; its reason is already localized by whoever raised it. const composerBlock = useComposerBlock(block => block) @@ -127,15 +130,13 @@ export function ConversationContent(props: ConversationContentProps) {
      ) - // The placeholder chip ("Choose workspace") and the Workspace-trigger input travel - // together: no workspace picked yet (cold start, no session at all), or a - // blank session whose workspace vanished (deleted from the sidebar). The - // bar is ONE session-maybe slot rendered unconditionally — inert is a prop, - // not a different tree, so the textarea DOM survives the transition. - const inert = sessionId === undefined || (hero && chipTitle === undefined) - // A raised block is the same inert posture with the blocker's own reason: - // one disabled textarea, never a second tree. The no-workspace state wins - // when both hold — picking a workspace is the earlier prerequisite. + // First-use sending waits for both baselines. A blank Session whose + // Workspace disappeared still requires explicit directory selection. + const needsWorkspace = sessionId !== undefined && hero && chipTitle === undefined + const inert = firstDraft.busy || (sessionId === undefined + ? workspaces.phase !== 'ready' || !sessionsReady + : needsWorkspace) + // Owner availability takes precedence over a Session-specific model block. const blocked = !inert && composerBlock !== undefined const inputBar = renderSlot('conversation.composer.bar', { variant: hero ? 'hero' : 'composer', @@ -144,7 +145,7 @@ export function ConversationContent(props: ConversationContentProps) { disabled: true, placeholder: t('placeholder.workspace'), workspacePickerOpen: pickerOpen, - onRequestWorkspace: () => { setPickerOpen(true) }, + ...(needsWorkspace ? { onRequestWorkspace: () => { setPickerOpen(true) } } : {}), } : blocked // `blocked`, not `disabled`: the bar refuses input either way, but a @@ -191,6 +192,23 @@ export function ConversationContent(props: ConversationContentProps) { {composerSeat}
      + + + + + )} + > + {t('defaultWorkspace.retry')} +
    ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index c57fe69936..f66ee7a96a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -1,6 +1,5 @@ -// Resident conversation skeleton. Hero chrome, composer positioning, the -// chain, AND the composer bar (session-maybe slot) stay mounted across -// no-session/session transitions — the bar renders inert via owner props. +// Resident conversation shell keeps the composer mounted across local-draft, +// blank-Session, and active-conversation views. import type { ConversationSlotProps } from '../contract/slots.ts' import { ConversationMainPanel } from './ConversationMainPanel.tsx' diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 6a4ea12740..5852e4b5c4 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -1,16 +1,12 @@ /** The default composer body: the 'conversation.composer.bar' slot entry. - * Machine state arrives through the standard provide channel - * (useInput + inputActions); the keyboard/DOM command face and stop arrive - * through this entry's own inject, whose hooks compartment binds - * useNotices/useLexicon; layout-phase inputs (variant and placeholder) ride - * the owner props. Session facts - * (running/removed/promptError) are self-selected via useSession. + * The injected input state and keyboard address the current Session or the + * first-use local draft. Standard Session props supply attachment actions; + * owner props control layout and availability. * * The text surface is the shell-owned Lexical editor bound here through * ComposerContentEditable; chips render as decorator portals, and the * keymap registers submit/menu/paste gestures on the editor command layer. - * The no-session state renders the SAME div inert as the Workspace-picker - * trigger instead of a parallel tree. + * The contenteditable remains mounted when the first Session arrives. */ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -42,7 +38,7 @@ import css from './InputBar.module.css' export type InputBarProps = ComposerBarProps export const InputBar = memo(function InputBar({ - useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, + useSession, useComposerInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, retryFileUpload, toggleCommandMenu, stop, t, renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, @@ -50,7 +46,7 @@ export const InputBar = memo(function InputBar({ workspacePickerOpen = false, onRequestWorkspace, placeholder, accessory, }: InputBarProps) { - const input = useInput(s => s) + const input = useComposerInput(s => s) const notice = useNotices(s => s) const busyEnter = useBusyEnter(s => s) void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms @@ -64,14 +60,11 @@ export const InputBar = memo(function InputBar({ const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active)) // Absent (undefined: no frame yet) and cleared (null) both mean no goal. const hasGoal = useProjection('goal', goal => goal != null) - // Session-maybe: the machine faces are absent together while no session is - // current; the bar renders the same DOM inert instead of a parallel tree. - const live = input !== undefined && keyboard !== undefined && inputActions !== undefined - const draft = input?.draft ?? '' - const editor = keyboard?.editor ?? null + const draft = input.draft + const editor = keyboard.editor const attachments = useMemo( - () => input === undefined || resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds), - [resolveDraftAttachments, input?.attachmentIds], + () => resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds), + [resolveDraftAttachments, input.attachmentIds], ) const empty = draft.trim() === '' && attachments.length === 0 const uploads = useFileUploads(snapshot => snapshot) @@ -93,8 +86,7 @@ export const InputBar = memo(function InputBar({ // The deployment's image-intake limits (absent while no attachment service // is composed — the pre-check below then defers entirely to the host). const imageLimits = useProjection('imageLimits') - // Prompt failures are ordinary failures (no create/attach transaction exists - // anymore): the toast announces promptError, the draft stays in the machine, + // The toast announces promptError, the draft stays in the machine, // and the user resubmits. A remount over a session whose machine still holds // an unresolved promptError deliberately re-announces it once — the failure // is still pending, and a transient banner is its only surface. Attachment @@ -122,35 +114,30 @@ export const InputBar = memo(function InputBar({ // but its independent Stop below stays available while it runs. const continuable = subagent?.address.mode === 'continuable' const parentOffline = continuable && subagent.parentAvailable !== true - // Running input stays free; locked = session removed, the - // inert no-workspace state, the machine faces absent (no session), or a - // parent-offline continuable child. An owner block also disables input; - // adjudicating and submitting render read-only so the draft stays visible. - const disabled = removed || inert || !live || blocked !== undefined || parentOffline + // Preparation, removed Sessions, and unavailable parents lock editing. + // Adjudicating and submitting keep the draft visible and read-only. + const disabled = removed || inert || blocked !== undefined || parentOffline const locked = disabled // The model seat is the ONE control a block leaves live: every block this // contract has is cleared by choosing a model, so locking it too would leave // the composer asking for the only thing it prevents. The other reasons to // be disabled do lock it — there is no session to choose a model for. - const modelSeatLocked = removed || inert || !live - const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' - // The no-workspace surface remains the resident DOM node but acts as the - // existing picker trigger. Message controls stay locked until a Session - // exists; the trigger itself is read-only rather than disabled so pointer - // and keyboard users can reach the recovery action. + const modelSeatLocked = removed || inert + const machineBusy = input.phase === 'adjudicating' || input.phase === 'submitting' + // A blank Session whose Workspace was deleted offers directory selection + // through the same resident input node. const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined const editorDisabled = removed || (locked && !workspaceTrigger) - const editable = live && !locked && !machineBusy + const editable = !locked && !machineBusy const steeringAvailable = subagent === null || subagent.address.mode === 'continuable' const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && steeringAvailable && input.queue.length > 0 useEffect(() => { - if (input === undefined || inputActions === undefined) return - if (attachments.length !== input.attachmentIds.length) { + if (inputActions !== undefined && attachments.length !== input.attachmentIds.length) { inputActions.pruneAttachments(attachments.map(attachment => attachment.id)) } - }, [attachments, input?.attachmentIds, inputActions]) + }, [attachments, input.attachmentIds, inputActions]) // Scroll the draft scrollport the minimum that brings the selection focus // into view — the browser's own behavior for typing, performed for the @@ -167,7 +154,7 @@ export const InputBar = memo(function InputBar({ // is ours to perform — switching to a longer draft otherwise leaves the // caret (restored at the draft's end) off screen. useEffect(() => { - if (locked || editor === null) return + if (locked) return focusDraftEditor(editor, revealSelection) }, [locked, sessionId, editor]) @@ -244,12 +231,10 @@ export const InputBar = memo(function InputBar({ } useEffect(() => { - if (keyboard === undefined) return return installDraftFilePicker(keyboard, gate, fileInputRef) }, [keyboard]) useEffect(() => { - if (editor === null || keyboard === undefined) return return installDraftKeymap(editor, keyboard, gate) }, [editor, keyboard]) @@ -262,17 +247,16 @@ export const InputBar = memo(function InputBar({ } const onToggleCommandMenu = (): void => { - if (keyboard === undefined) return // The menu is a combobox over the editor, so the keyboard has to be there // before the launcher opens it: activating the button from the keyboard // leaves focus on the button, and restoring it afterwards would re-track an // empty draft and close the menu again. - if (editor !== null) focusDraftEditor(editor, revealSelection) + focusDraftEditor(editor, revealSelection) toggleCommandMenu?.(keyboard.caretSpan()) } - // The no-session Workspace trigger: the resident editable div acts as the - // picker trigger for keyboard users (no editor is bound in this state). + // When a blank Session loses its Workspace, the resident editable div acts as the + // picker trigger for keyboard users. const onWorkspaceKeyDown = (e: KeyboardEvent): void => { if (!workspaceTrigger) return if (e.key === 'Enter' || e.key === ' ') { @@ -295,7 +279,7 @@ export const InputBar = memo(function InputBar({ const primaryDisabled = primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending const interruptible = running && continuable const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable) - const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/') + const plainMessageDraft = !empty && input.phase === 'plain' && !draft.trimStart().startsWith('/') const primaryLabel = primaryStops ? t('input.stop') : running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft @@ -306,7 +290,6 @@ export const InputBar = memo(function InputBar({ stop?.() return } - if (keyboard === undefined) return // absent machine: the button is disabled /* v8 ignore next -- defensive: the primary button is disabled for empty, disabled, and pending-upload states. */ if (!empty && !disabled && !machineBusy && !uploadsPending) keyboard.submit(primarySubmitMode) } @@ -314,7 +297,7 @@ export const InputBar = memo(function InputBar({ // Claim ghost hint: rendered by CSS as generated content after the last // paragraph while the claim's args are blank (a hint implies a single-line // token draft). The translated per-command hint wins over the claim's own. - const claimActive = (input?.phase === 'claimed' || input?.phase === 'submitting') + const claimActive = (input.phase === 'claimed' || input.phase === 'submitting') && input.claim !== undefined && draft.startsWith(input.claim.token) const rawHint = claimActive && input.claim.hint !== undefined && draft.slice(input.claim.token.length).trim() === '' @@ -322,7 +305,7 @@ export const InputBar = memo(function InputBar({ : null const hint = ((): string | null => { if (rawHint === null) return null - const commandName = input?.claim?.name ?? '' + const commandName = input.claim?.name ?? '' const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}` // Dynamic lookup by claimed command name: unknown commands miss the // dictionary and keep the machine's own hint, so the call is wide. @@ -396,7 +379,7 @@ export const InputBar = memo(function InputBar({ scrollRef={scrollRef} editable={editable} editorDisabled={editorDisabled} - phase={input?.phase ?? 'inert'} + phase={input.phase} placeholderText={placeholderText} ariaLabel={workspaceTrigger ? t('hero.chooseWorkspace') : placeholderText} workspaceTrigger={workspaceTrigger} @@ -433,12 +416,12 @@ export const InputBar = memo(function InputBar({ {sessionId === undefined ? null : renderSlot('conversation.input.permission', { locked })} {sessionId === undefined ? null : renderSlot('conversation.input.plan', { locked })} - {input === undefined || sessionId === undefined + {sessionId === undefined ? null : renderSlot('conversation.input.left', {})}
    - {input === undefined || sessionId === undefined + {sessionId === undefined ? null : renderSlot('conversation.input.right', {})} {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })} @@ -482,7 +465,7 @@ export const InputBar = memo(function InputBar({
    - {variant === 'composer' && input !== undefined && sessionId !== undefined + {variant === 'composer' && sessionId !== undefined ? renderSlot('conversation.composer.dock', {}) : null} diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 6c12f28cfb..abc74918d5 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -2,6 +2,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client' +import type { UiWorkspace } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' @@ -73,12 +74,14 @@ async function bench() { opened(id) } const openSession = vi.fn((id: SessionId) => { replaceMain(id) }) + const openDefaultWorkspace = vi.fn(async () => undefined) runtime.ctx.provide('uiWorkspace', { openWorkspace: async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { const id = await connectWorkspace() replaceMain(id, beforeOpen) }, openSession, + openDefaultWorkspace, } as never) const sessionFake = sessionFakeFor() await runtime.sessions.add({ @@ -136,10 +139,54 @@ async function bench() { return { runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi, inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads, rootReference, references, opened, + locale, openDefaultWorkspace, } } describe('Conversation inject API', () => { + it.each([ + ['zh', '默认工作区', '默认工作区'], + ['en', 'Default workspace', 'Default workspace'], + ['ja', 'default-workspace', 'Default workspace'], + ])('resolves first-use names in the Client using the send-time locale %s', async (language, directoryName, title) => { + const b = await bench() + if (language === 'ja') b.locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }) + const composer = b.composerApi(undefined) + composer.keyboard.paste('first message') + b.locale.setLocale(language) + composer.keyboard.submit('steer') + await vi.waitFor(() => { + expect(b.openDefaultWorkspace).toHaveBeenCalledWith({ directoryName, title }, expect.any(Function), expect.any(AbortSignal)) + expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) + }) + }) + + it('keeps in-flight names fixed and resolves the new locale when retrying a failed first send', async () => { + const b = await bench() + const pending = Promise.withResolvers() + b.openDefaultWorkspace.mockReturnValueOnce(pending.promise) + const composer = b.composerApi(undefined) + b.locale.setLocale('en') + composer.keyboard.paste('keep this draft') + composer.keyboard.submit('steer') + try { + await vi.waitFor(() => { expect(b.openDefaultWorkspace).toHaveBeenCalledOnce() }) + b.locale.setLocale('zh') + expect(b.openDefaultWorkspace.mock.calls[0]?.[0]).toEqual({ directoryName: 'Default workspace', title: 'Default workspace' }) + pending.reject(new Error('directory unavailable')) + await vi.waitFor(() => { expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) }) + expect(composer.hooks.composerInput.getSnapshot().draft).toBe('keep this draft') + composer.keyboard.submit('steer') + await vi.waitFor(() => { + expect(b.openDefaultWorkspace).toHaveBeenLastCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, expect.any(Function), expect.any(AbortSignal)) + expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) + }) + } finally { + pending.resolve(undefined) + await vi.waitFor(() => { expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) }) + } + }) + it('owns the File action, reads its mounted composer availability, and unregisters on disposal', async () => { const b = await bench() onTestFinished(() => b.runtime.dispose()) @@ -159,7 +206,7 @@ describe('Conversation inject API', () => { expect(file.available({ sessionId: 'missing' as SessionId })).toBe(false) if (file.ui.kind !== 'action') throw new Error('File must be an action') file.ui.run({ sessionId: 'missing' as SessionId }) - const keyboard = b.composerApi(ROOT).keyboard! + const keyboard = b.composerApi(ROOT).keyboard const open = vi.fn() let available = true const unbind = keyboard.bindFilePicker({ open, available: () => available }) @@ -386,7 +433,8 @@ describe('Conversation inject API', () => { expect(() => { injectBar('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/) const absent = injectBar(undefined) - expect(absent.keyboard).toBeUndefined() + expect(absent.keyboard).toBeDefined() + expect(absent.hooks.composerInput.getSnapshot().draft).toBe('') expect(absent.toggleCommandMenu).toBeUndefined() expect(absent.stop).toBeUndefined() expect(absent.hooks.notices.getSnapshot()).toBeNull() diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx index 84e5ba28a1..51513cb28e 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -100,8 +100,10 @@ async function bench(opts?: { blank?: boolean }) { } describe('resident composer', () => { - it('renders the locked view state while no session exists at all', async () => { + it('accepts a local draft before any Session exists and keeps the Workspace chip available', async () => { const runtime = await SlotTestRuntime.create() + runtime.sessions.list.set({ ...runtime.sessions.list.getSnapshot(), phase: 'pending' }) + await runtime.workspaces.update((draft) => { draft.phase = 'pending' }) provideWorkspaceNavigation(runtime) runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) @@ -113,15 +115,21 @@ describe('resident composer', () => { const view = runtime.renderRoot() const textarea = view.container.querySelector('[data-composer-input]') expect(textarea).not.toBeNull() + expect(textarea!.getAttribute('contenteditable')).toBe('false') + await runtime.workspaces.update((draft) => { draft.phase = 'ready' }) + expect(textarea!.getAttribute('contenteditable')).toBe('false') + await act(async () => { + runtime.sessions.list.set({ ...runtime.sessions.list.getSnapshot(), phase: 'ready' }) + }) expect(textarea!.getAttribute('aria-disabled')).not.toBe('true') - expect(textarea!.getAttribute('contenteditable')).not.toBe('true') - expect(textarea!.getAttribute('aria-haspopup')).toBe('menu') + expect(textarea!.getAttribute('contenteditable')).toBe('true') + expect(textarea!.getAttribute('aria-haspopup')).toBeNull() expect(view.getByTestId('workspace-probe').textContent).toBe('false:0') - fireEvent.click(textarea!) - expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') - expect(textarea!.getAttribute('aria-expanded')).toBe('true') fireEvent.click(view.getByRole('button', { name: '选择工作区' })) - fireEvent.keyDown(textarea!, { key: 'Enter' }) + expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') + expect(textarea!.getAttribute('aria-expanded')).toBeNull() + fireEvent.click(view.getByRole('button', { name: '选择工作区' })) + fireEvent.click(view.getByRole('button', { name: '选择工作区' })) expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') expect(view.getByRole('button', { name: '选择工作区' })).toBeTruthy() await runtime.dispose() @@ -149,7 +157,7 @@ describe('resident composer', () => { const workspaceChip = view.getByRole('button', { name: '选择工作区' }) const workspaceProbe = view.getByTestId('workspace-probe') expect(textarea.getAttribute('aria-disabled')).not.toBe('true') - expect(textarea.getAttribute('contenteditable')).not.toBe('true') + expect(textarea.getAttribute('contenteditable')).toBe('true') fireEvent.click(workspaceChip) fireEvent.click(workspaceProbe) diff --git a/packages/client/ui-conversation/tests/first-draft.client.spec.ts b/packages/client/ui-conversation/tests/first-draft.client.spec.ts new file mode 100644 index 0000000000..3d223a38fe --- /dev/null +++ b/packages/client/ui-conversation/tests/first-draft.client.spec.ts @@ -0,0 +1,141 @@ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { FirstDraft } from '../src/client/input/first-draft.ts' +import { SessionInputShell } from '../src/client/input/facade.ts' +import type { SubmitOutcome } from '../src/client/contract/input.ts' + +const contexts: Context[] = [] +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +function harness() { + const ctx = new Context() + contexts.push(ctx) + const opened = Promise.withResolvers() + ctx.effect(() => () => { opened.resolve(undefined) }) + const submitted = vi.fn(async (): Promise => ({ kind: 'success' })) + const target = new SessionInputShell({ + actx: ctx, + defaultSink: submitted, + commandAttachments: { serialize: async () => [], release: () => {}, unsupportedNotice: () => '' }, + }) + ctx.effect(() => () => { target.dispose() }) + let beforeOpen: ((id: SessionId) => void) | undefined + const open = vi.fn((prepare: (id: SessionId) => void) => { + beforeOpen = prepare + return opened.promise + }) + const first = new FirstDraft(ctx, { open, shell: () => target }) + return { + ctx, first, target, submitted, open, opened, + complete: () => { + const id = SessionId('default') + beforeOpen!(id) + opened.resolve(id) + }, + } +} + +describe('first message draft', () => { + it('retains a local draft without opening a Session until send', async () => { + const h = harness() + h.first.shell.setDraft('first message') + expect(h.open).not.toHaveBeenCalled() + h.first.shell.submit('steer') + expect(h.open).toHaveBeenCalledTimes(1) + expect(h.first.state.getSnapshot().busy).toBe(true) + h.first.shell.submit('steer') + expect(h.open).toHaveBeenCalledTimes(1) + h.complete() + await vi.waitFor(() => { expect(h.submitted).toHaveBeenCalledTimes(1) }) + expect(h.submitted.mock.calls[0]).toEqual(['first message', [], 'steer', expect.any(AbortSignal)]) + expect(h.first.state.getSnapshot()).toEqual({ busy: false, failed: false }) + }) + + it.each([false, true])('waits for first-use settings and keeps the Session draft on refusal (refused: %s)', async (refused) => { + const h = harness() + const settings = Promise.withResolvers() + const started = Promise.withResolvers() + h.ctx.on('conversation/prepare-first-send', (id) => { + started.resolve(id) + return settings.promise + }) + try { + h.first.shell.setDraft('use my selected mode') + h.first.shell.submit() + h.complete() + expect(await started.promise).toBe(SessionId('default')) + expect(h.submitted).not.toHaveBeenCalled() + if (refused) settings.reject(new Error('preset refused')) + else settings.resolve(undefined) + await vi.waitFor(() => { expect(h.first.state.getSnapshot().busy).toBe(false) }) + expect(h.first.state.getSnapshot().failed).toBe(false) + if (refused) { + expect(h.submitted).not.toHaveBeenCalled() + expect(h.target.snapshot.draft).toBe('use my selected mode') + expect(h.target.notices.getSnapshot()?.text).toBe('preset refused') + } else { + expect(h.submitted).toHaveBeenCalledOnce() + } + } finally { + settings.resolve(undefined) + await vi.waitFor(() => { expect(h.first.state.getSnapshot().busy).toBe(false) }) + } + }) + + it('restores input on preparation failure and dismisses the modal without losing it', async () => { + const h = harness() + h.first.shell.setDraft('preserve me') + h.first.shell.submit() + h.opened.reject(new Error('permission denied')) + await vi.waitFor(() => { expect(h.first.shell.snapshot.draft).toBe('preserve me') }) + expect(h.first.state.getSnapshot()).toEqual({ busy: false, failed: true }) + expect(h.submitted).not.toHaveBeenCalled() + h.first.dismiss() + expect(h.first.state.getSnapshot().failed).toBe(false) + expect(h.first.shell.snapshot.draft).toBe('preserve me') + }) + + it('hands a draft to a manual selection without sending it', async () => { + const h = harness() + h.first.shell.setDraft('manual folder') + h.first.transfer(SessionId('manual')) + expect(h.target.snapshot.draft).toBe('manual folder') + expect(h.first.shell.snapshot.draft).toBe('') + expect(h.submitted).not.toHaveBeenCalled() + }) + + it('lets manual navigation take the pending draft and cancels automatic submission', async () => { + const h = harness() + h.first.shell.setDraft('pending text') + h.first.shell.submit() + h.first.transfer(SessionId('manual')) + h.opened.resolve(undefined) + await vi.waitFor(() => { expect(h.first.state.getSnapshot().busy).toBe(false) }) + expect(h.target.snapshot.draft).toBe('pending text') + expect(h.first.shell.snapshot.draft).toBe('') + expect(h.submitted).not.toHaveBeenCalled() + }) + + it('restores a superseded draft without a directory error', async () => { + const h = harness() + h.first.shell.setDraft('later') + h.first.shell.submit() + h.opened.resolve(undefined) + await vi.waitFor(() => { expect(h.first.shell.snapshot.draft).toBe('later') }) + expect(h.first.state.getSnapshot().failed).toBe(false) + }) + + it('leaves ordinary send failures in the selected Session input', async () => { + const h = harness() + h.submitted.mockResolvedValue({ kind: 'error' }) + h.first.shell.setDraft('retry the prompt') + h.first.shell.submit() + h.complete() + await vi.waitFor(() => { expect(h.target.snapshot.draft).toBe('retry the prompt') }) + expect(h.first.shell.snapshot.draft).toBe('') + expect(h.first.state.getSnapshot().failed).toBe(false) + }) +}) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 9e0366952c..a4805db1ec 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -185,6 +185,7 @@ function bench(over?: BenchOptions) { ? over?.plan : key === 'goal' ? over?.goal : key === 'imageLimits' ? over?.imageLimits : undefined)), + useComposerInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 15d87a0ca7..c8641afcb4 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -67,6 +67,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled })), useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), + useComposerInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 347b4d05c1..0c098d95fb 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -161,6 +161,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo })), useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), + useComposerInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 17aadfb056..25f7002ea4 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -137,6 +137,8 @@ function mount( nestedSubagent?: boolean /** A composer block another plugin raised for this session. */ composerBlock?: { reason: string } + /** First-use preparation remains active after selecting its Session. */ + preparingFirstDraft?: boolean /** Mutable view ledger used by registration-order regressions. */ viewTabs?: ViewTab[] } = {}, @@ -280,6 +282,7 @@ function mount( useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} + useComposerInput={useInput} keyboard={wiring} addFiles={() => null} useFileUploads={bindSnapshotSelector(createSnapshotStore({}))} @@ -336,6 +339,8 @@ function mount( useWorkspaces: bindSnapshotSelector(workspaces), useProjection: (() => undefined), useComposerBlock: select => select(options.composerBlock), + useFirstDraft: select => select({ busy: options.preparingFirstDraft === true, failed: false }), + dismissDefaultFailure: () => {}, useInput, inputActions, renderSlot, @@ -405,6 +410,17 @@ describe('ConversationRoot resident composer', () => { expect(b.view.queryByTestId('view-conversation.session.header.corner')).toBeNull() }) + it('locks the selected Session draft until automatic first-use preparation settles', () => { + const b = mount(sessionSnapshotOf({ blank: true }), undefined, undefined, { + summaryBlank: true, preparingFirstDraft: true, + }) + const box = b.view.getByRole('textbox') + expect(box.getAttribute('aria-disabled')).toBe('true') + expect(box.getAttribute('aria-haspopup')).toBeNull() + fireEvent.keyDown(box, { key: 'Enter' }) + expect(b.sink).not.toHaveBeenCalled() + }) + it('does not redispatch composer child slots for an unrelated Session publication', () => { const b = mount(sessionSnapshotOf()) const childKeys = new Set([ diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 5f4245e471..951fd978ab 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -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/ui-workspace/README.md -README.md: 0125f563e7d0f062dd37f58a218d76dd85b0fa51 -README.zh.md: 9db3ea3a39f7c314ebb7d874d858457f00501bd7 +README.md: 6b34c9bf764d04df591317a22c55a47c2307459d +README.zh.md: 550490a49651d01e20537cb924a43112a233966c diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 0125f563e7..6b34c9bf76 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -69,6 +69,8 @@ The value is intentionally best effort for cold Sessions. An identity-matching u New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request and are currently reported only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. +`openDefaultWorkspace(request, beforeOpen, signal)` initializes the Host default Workspace from the requested directory name and title before connecting its Session. It captures navigation cancellation before directory preparation; superseded work cannot transfer the draft or select its Session. A successfully registered Workspace remains available when Session creation or later submission fails. [Conversation](../ui-conversation/README.md) owns the first-use draft and failure dialog. + ## Understand the implementation diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 9db3ea3a39..550490a496 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -69,6 +69,8 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** 新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求,目前只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 +`openDefaultWorkspace(request, beforeOpen, signal)` 在连接 Session 前,按请求中的目录名和标题初始化 Host 默认工作区。它在目录准备之前捕获导航取消信号;被取代的操作不能转移草稿或选中 Session。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。[Conversation](../ui-conversation/README.zh.md) 负责首次使用草稿和失败弹窗。 + ## 理解实现 diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 22138ec707..e6ccd65f62 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -12,7 +12,7 @@ import type { import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { - IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView, + IWorkspaces, WorkspaceId, WorkspaceInitializeDefaultRequest, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' @@ -36,6 +36,18 @@ export interface UiWorkspace { * @returns completion; a superseded request may create a Session but does not open it. */ openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise + /** + * Prepare the first-use Workspace and select its blank Session. + * @param request - initial directory name and title. + * @param beforeOpen - synchronous draft transfer after retaining the Session. + * @param signal - caller cancellation. + * @returns selected Session, or undefined after navigation supersession. + */ + openDefaultWorkspace( + request: WorkspaceInitializeDefaultRequest, + beforeOpen: (sessionId: SessionId) => void, + signal: AbortSignal, + ): Promise /** * Fork a Session without changing the current selection. * @param sessionId - source Session. @@ -184,6 +196,25 @@ class UiWorkspaceService extends Service implements UiWorkspace { this.replaceMain(sessionId, navigation, 'reveal', beforeOpen) } + async openDefaultWorkspace( + request: WorkspaceInitializeDefaultRequest, + beforeOpen: (sessionId: SessionId) => void, + signal: AbortSignal, + ): Promise { + const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal, signal]) + try { + const workspace = await this.workspaces.initializeDefault(request, navigation) + navigation.throwIfAborted() + const sessionId = await this.connectWorkspace(workspace.workspaceId) + navigation.throwIfAborted() + this.replaceMain(sessionId, navigation, 'reveal', beforeOpen) + return sessionId + } catch (error) { + if (navigation.aborted) return undefined + throw error + } + } + async forkSession(sessionId: SessionId): Promise { await this.sessions.fork({ sessionId, increaseTitle: true }) } diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 310bb3627d..a952cb9d32 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -167,6 +167,11 @@ class FakeSessions implements ISessions { } class FakeWorkspaces implements IWorkspaces { + readonly initializeDefault = vi.fn(async () => { + const item = workspace('default') + this.list.set(workspaceState([item])) + return item + }) readonly list: MutableSource readonly archiveCalls: SessionId[] = [] readonly unarchiveCalls: SessionId[] = [] @@ -272,6 +277,41 @@ function bench(options: BenchOptions = {}) { } describe('UiWorkspaceService', () => { + it('prepares the default Workspace before creating and retaining its Session', async () => { + const b = bench({ workspaces: workspaceState(), sessions: sessionState() }) + const beforeOpen = vi.fn(() => { + expect(b.sessions.retain).toHaveBeenCalledWith(sid('created-default'), { source: 'mainView' }) + }) + await expect(b.uiWorkspace.openDefaultWorkspace({ directoryName: '默认工作区', title: '默认工作区' }, beforeOpen, new AbortController().signal)) + .resolves.toBe(sid('created-default')) + expect(b.workspaces.initializeDefault).toHaveBeenCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, expect.any(AbortSignal)) + expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('default') }) + expect(beforeOpen).toHaveBeenCalledWith(sid('created-default')) + }) + + it('does not create a Session when default preparation fails or is superseded', async () => { + const b = bench({ workspaces: workspaceState(), sessions: sessionState() }) + b.workspaces.initializeDefault.mockRejectedValueOnce(new Error('denied')) + await expect(b.uiWorkspace.openDefaultWorkspace({ directoryName: 'Default workspace', title: 'Default workspace' }, vi.fn(), new AbortController().signal)).rejects.toThrow('denied') + expect(b.sessions.create).not.toHaveBeenCalled() + const pending = Promise.withResolvers() + b.workspaces.initializeDefault.mockReturnValueOnce(pending.promise) + const beforeOpen = vi.fn() + const opening = b.uiWorkspace.openDefaultWorkspace({ directoryName: 'Default workspace', title: 'Default workspace' }, beforeOpen, new AbortController().signal) + b.uiWorkspace.openSession(sid('manual')) + pending.resolve(workspace('default')) + await expect(opening).resolves.toBeUndefined() + expect(b.sessions.create).not.toHaveBeenCalled() + expect(beforeOpen).not.toHaveBeenCalled() + }) + + it('retains the prepared Workspace if creating its Session fails', async () => { + const b = bench({ workspaces: workspaceState(), sessions: sessionState() }) + b.sessions.create.mockRejectedValueOnce(new Error('session failed')) + await expect(b.uiWorkspace.openDefaultWorkspace({ directoryName: 'Default workspace', title: 'Default workspace' }, vi.fn(), new AbortController().signal)).rejects.toThrow('session failed') + expect(b.workspaces.list.getSnapshot().items).toEqual([workspace('default')]) + }) + it('retains an explicit main target before revealing its Conversation', () => { const b = bench() b.uiWorkspace.openSession(sid('target')) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 78975d4a43..407b489580 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3250,6 +3250,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'directory path to register.' }], returns: 'the Workspace and whether this call created it.', }, + { + signature: '@Remote(\'initializeDefault\') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise', + description: 'Initialize or reuse the default Workspace before the first user message.', + parameters: [{ name: 'request', description: 'initial directory name and title; never rename an existing default.' }, { name: 'signal', description: 'caller lifetime; cancels native directory lookup.' }], + returns: 'the durable Workspace without creating a Session or sending a message.', + }, { signature: '@Remote(\'rename\') rename(request: WorkspaceRenameRequest): Promise', description: 'Rename one Workspace to a unique non-blank title.', @@ -3367,6 +3373,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'path', description: 'Existing directory to own, in a fully qualified path spelling.' }, { name: 'title', description: 'Display title used only when a new record is created.' }], returns: 'the existing or newly durable workspace.', }, + { + signature: 'initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise', + description: 'Initialize the default Workspace only while both the registry and Session history are empty. Repeated requests reuse its durable identity; deleting that registration permanently disables automatic creation.', + parameters: [{ name: 'resolveDirectory', description: 'resolve the absolute directory and initial title; called only for eligible creation, inside the registry mutation queue. Missing directories are created recursively before registration.' }], + returns: 'the initialized Workspace, or undefined when automatic creation is ineligible.', + }, { signature: 'get(id: WorkspaceId): Workspace | undefined', description: 'Look up a workspace by id.', @@ -7279,6 +7291,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WorkspaceFollowIncrement', declaration: 'export type WorkspaceFollowIncrement = {\n readonly type: \'upsert\';\n readonly workspace: WorkspaceView;\n} | {\n readonly type: \'remove\';\n readonly workspaceId: WorkspaceId;\n} | {\n readonly type: \'order\';\n readonly workspaceIds: readonly WorkspaceId[];\n} | {\n readonly type: \'archived\';\n readonly archivedSessionIds: readonly SessionId[];\n} | {\n readonly type: \'pinned\';\n readonly pinnedSessionIds: readonly SessionId[];\n};', }, + { + name: 'WorkspaceInitializeDefaultRequest', + declaration: 'export interface WorkspaceInitializeDefaultRequest {\n readonly directoryName: string;\n readonly title: string;\n}', + }, { name: 'WorkspaceInsertBeforeRequest', declaration: 'export interface WorkspaceInsertBeforeRequest {\n readonly workspaceId: WorkspaceId;\n readonly beforeWorkspaceId?: WorkspaceId;\n}', diff --git a/packages/test-support/client-runtime/src/workspaces.ts b/packages/test-support/client-runtime/src/workspaces.ts index 68a5c61d09..2c6b788fa3 100644 --- a/packages/test-support/client-runtime/src/workspaces.ts +++ b/packages/test-support/client-runtime/src/workspaces.ts @@ -1,7 +1,7 @@ /** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView, + IWorkspaces, WorkspaceId, WorkspaceInitializeDefaultRequest, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -80,6 +80,19 @@ export class TestWorkspaces implements IWorkspaces { } as unknown as WorkspaceView } + /** + * Initialize the default Workspace through an explicitly installed test stub. + * @param request - initial directory name and title. + * @param signal - caller lifetime. + * @returns the stubbed Workspace. + */ + async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise { + this.calls.push({ method: 'initializeDefault', args: [request, signal] }) + const stub = this.stubs.get('initializeDefault') + if (stub === undefined) throw new Error('initializeDefault requires a Workspace fixture') + return await (stub(request, signal) as Promise) + } + /** * Rename a Workspace (recorded). The default echoes a minimal view. * @param workspaceId - target workspace. diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index da38092a17..43e9c7d159 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/README.i18n.yaml @@ -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/workspace/workspace/README.md -README.md: 51fa66d346ffc4027397d8851b0c81ea87269ded -README.zh.md: 102308d3257ba13d3ec851adcbdb1d4c159abc14 +README.md: f11d6496c04cf346c21f869e00ec5fb5526008db +README.zh.md: 3d458e13157bda612c3d33d0e7bfb57fa914e9b3 diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index 51fa66d346..f11d6496c0 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -33,7 +33,7 @@ Use it when the product shows a persistent workspace surface — a sidebar, sess ### Setting up -The package takes no configuration of its own; it needs a session store, a session persistence backend, and the storage rows that keep its records. A minimal composition: +The package needs a session store, a session persistence backend, and the storage rows that keep its records. A minimal composition: ```yaml - name: '@deepseek-ai/dsh-session' @@ -59,6 +59,15 @@ await project.setTitle('Renamed') ctx.workspaceRegistry.list() // shows the project, newest first ``` + +### First-use Workspace + +`initializeDefault(resolveDirectory)` initializes the default Workspace without creating a Session. Initial creation requires an empty Workspace registry and no live, persisted, or archived Session, including Sessions without a working directory. The registry checks persistent history directly; an empty visible sidebar is insufficient. + +The directory resolver runs inside the mutation queue only when creation is eligible. It returns an absolute path and initial title; the registry creates missing parent directories, canonicalizes the path, rechecks Session history, and commits the Workspace with its initialization marker. An existing directory is reused; a file conflict or directory failure rejects initialization. The [Host controller](../../api/workspace-controller/README.md#first-use-workspace) supplies the Documents path policy. + +The first successful registration records its identity durably. Repeated calls return it without resolving a directory again; renaming keeps that identity, and deleting its registration does not permit another automatic creation. Directory or registration failure leaves initialization unset for retry. Directories created before a later failure remain on disk. The [first-use decision](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.md) explains this lifetime. + ### Grouping sessions under a project A session joins the project of the directory it runs in: create a session in a project's directory and it appears under that project, newest first. A session can only belong to one project. A session whose directory cannot be validated — no recorded directory, or a moved or deleted folder — cannot join and stays ungrouped. @@ -102,7 +111,7 @@ The API has two owners: `WorkspaceRegistry` creates, orders, and deletes project ### Durable shape -The registry opens the `workspace` domain (version 2): a `workspaces` table keyed by `WorkspaceId` plus one global state holding `workspaceIds` (the authoritative display order), `archivedSessionIds`, `pinnedSessionIds`, and the optional `pendingMutation` marker. Archive and pin sets contain Session id strings, default to empty, and carry no per-entry objects or timestamps; the pin array keeps the most recently pinned id first. Archiving clears the pin in the same global-state write without changing Workspace membership. Unarchive runs no session-existence probe, because dropping an id from the set cannot introduce an unknown one, while archive verifies the session before adding it. +The registry opens the `workspace` domain (version 2): a `workspaces` table keyed by `WorkspaceId` plus one global state holding `workspaceIds` (the authoritative display order), `archivedSessionIds`, `pinnedSessionIds`, the optional `defaultWorkspaceId` first-use identity, and the optional `pendingMutation` marker. Archive and pin sets contain Session id strings, default to empty, and carry no per-entry objects or timestamps; the pin array keeps the most recently pinned id first. Archiving clears the pin in the same global-state write without changing Workspace membership. Unarchive runs no session-existence probe, because dropping an id from the set cannot introduce an unknown one, while archive verifies the session before adding it. ### Lifecycle diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 102308d325..3d458e1315 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 设置 -此包本身不声明任何配置;它需要会话存储、会话持久化后端,以及保存其记录的存储行。最小组合如下: +此包需要会话存储、会话持久化后端,以及保存其记录的存储行。最小组合如下: ```yaml - name: '@deepseek-ai/dsh-session' @@ -59,6 +59,15 @@ await project.setTitle('Renamed') ctx.workspaceRegistry.list() // shows the project, newest first ``` + +### 首次使用工作区 + +`initializeDefault(resolveDirectory)` 初始化默认 Workspace,不创建 Session。首次创建要求 Workspace 注册表为空,且不存在运行时、持久化或已归档 Session,包括没有工作目录的 Session。注册表直接检查持久化历史;仅凭可见侧边栏为空不足以判断。 + +目录解析器仅在允许创建时于变更队列内运行。它返回绝对路径和初始标题;注册表创建缺失的父目录、规范化路径、重新检查 Session 历史,再一起提交 Workspace 和初始化标记。已存在的目录直接复用;文件冲突或目录操作失败时拒绝初始化。[Host 控制器](../../api/workspace-controller/README.zh.md#first-use-workspace)提供 Documents 路径策略。 + +首次成功登记会持久保存工作区身份。重复调用直接返回它,不再解析目录;改名保留该身份,删除登记也不会允许再次自动创建。目录或登记失败时,初始化状态保持未设置,可以重试。后续步骤失败前已创建的目录会保留在磁盘上。[首次使用决策](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md)说明这一生命周期。 + ### 将会话归入项目 会话加入它运行目录所在的项目:在项目目录中创建会话,它就会出现在该项目下,新到旧排列。一个会话只能属于一个项目。目录无法校验的会话——没有记录目录,或目录被移动、删除——无法加入,保持 Ungrouped。 @@ -102,7 +111,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first ### 持久形态 -注册表打开 `workspace` 领域(版本 2):一张以 `WorkspaceId` 为键的 `workspaces` 表,加上一个持有 `workspaceIds`(权威显示顺序)、`archivedSessionIds`、`pinnedSessionIds` 与可选 `pendingMutation` 标记的全局状态。归档与置顶集合存储会话 id 字符串,默认值为空,不包含逐项对象或时间戳;置顶数组把最近置顶的 id 放在前面。归档在同一次全局状态写入中清除置顶,但不改变 Workspace 成员关系。取消归档不做会话存在性探测,因为从集合中移除 id 不可能引入未知 id,而归档会在加入前校验会话。 +注册表打开 `workspace` 领域(版本 2):一张以 `WorkspaceId` 为键的 `workspaces` 表,加上一个持有 `workspaceIds`(权威显示顺序)、`archivedSessionIds`、`pinnedSessionIds`、可选的首次使用身份 `defaultWorkspaceId` 与可选 `pendingMutation` 标记的全局状态。归档与置顶集合存储会话 id 字符串,默认值为空,不包含逐项对象或时间戳;置顶数组把最近置顶的 id 放在前面。归档在同一次全局状态写入中清除置顶,但不改变 Workspace 成员关系。取消归档不做会话存在性探测,因为从集合中移除 id 不可能引入未知 id,而归档会在加入前校验会话。 ### 生命周期 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index ab9ff50409..6cb179022a 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -6,7 +6,7 @@ */ import { randomUUID } from 'node:crypto' -import { stat } from 'node:fs/promises' +import { mkdir, stat } from 'node:fs/promises' import { Context, Service } from '@deepseek-ai/cordis' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-persistence' @@ -15,7 +15,7 @@ import { WorkspaceEntity } from './entity.ts' import type { WorkspaceEntityHost } from './entity.ts' export { WorkspaceMoveInvalidError } from './entity.ts' -import { defaultWorkspaceTitle, realpathNormalize } from './paths.ts' +import { defaultWorkspaceTitle, fullyQualifiedWorkspacePath, realpathNormalize } from './paths.ts' import { workspaceDomainSpec } from './spec.ts' import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts' @@ -175,6 +175,34 @@ export class WorkspaceRegistry extends Service { return await this.enqueueOperation(() => this.createCanonical(canonical, title)) } + /** + * Initialize the default Workspace only while both the registry and Session + * history are empty. Repeated requests reuse its durable identity; deleting + * that registration permanently disables automatic creation. + * @param resolveDirectory - resolve the absolute directory and initial title; + * called only for eligible creation, inside the registry mutation queue. + * Missing directories are created recursively before registration. + * @returns the initialized Workspace, or undefined when automatic creation is ineligible. + */ + initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise { + return this.enqueueOperation(async () => { + const state = this.requireState() + if (state.defaultWorkspaceId !== undefined) return this.entities.get(state.defaultWorkspaceId) + const sessions = this.ctx.get('sessions') + if (sessions === undefined) throw new Error('default Workspace initialization requires the Session store') + if (state.workspaceIds.length > 0 || state.archivedSessionIds.length > 0 + || sessions.list().length > 0 || (await this.listStoredHeaders()).length > 0) return undefined + + const { path, title } = await resolveDirectory() + if (!fullyQualifiedWorkspacePath(path)) throw new TypeError(`Workspace path is not fully qualified: '${path}'`) + await mkdir(path, { recursive: true }) + const canonical = await realpathNormalize(path) + // A Session can start outside the registry queue while directory preparation awaits I/O. + if ((await this.listStoredHeaders()).length > 0 || sessions.list().length > 0) return undefined + return this.createCanonical(canonical, title, true) + }) + } + /** * Look up a workspace by id. * @param id - Workspace id. @@ -379,7 +407,7 @@ export class WorkspaceRegistry extends Service { return undefined } - private async createCanonical(canonical: string, title?: string): Promise { + private async createCanonical(canonical: string, title?: string, firstUse = false): Promise { for (const entity of this.entities.values()) { if (entity.path === canonical) return entity } @@ -425,7 +453,10 @@ export class WorkspaceRegistry extends Service { try { await this.setState({ + ...state, + pendingMutation: undefined, initialized: true, + ...(firstUse ? { defaultWorkspaceId: id } : {}), workspaceIds: [id, ...state.workspaceIds], archivedSessionIds: state.archivedSessionIds, pinnedSessionIds: state.pinnedSessionIds, @@ -458,6 +489,8 @@ export class WorkspaceRegistry extends Service { if (entity === undefined) return false const state = this.requireState() const nextState = { + ...state, + pendingMutation: undefined, initialized: true, workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id), archivedSessionIds: state.archivedSessionIds, @@ -516,6 +549,8 @@ export class WorkspaceRegistry extends Service { } await this.requireTable().delete(pending.workspaceId) await this.setState({ + ...state, + pendingMutation: undefined, initialized: state.initialized, workspaceIds: state.workspaceIds, archivedSessionIds: state.archivedSessionIds, diff --git a/packages/workspace/workspace/src/spec.ts b/packages/workspace/workspace/src/spec.ts index 165bff34e0..9bd792258a 100644 --- a/packages/workspace/workspace/src/spec.ts +++ b/packages/workspace/workspace/src/spec.ts @@ -56,6 +56,8 @@ const workspacePendingMutation = z.discriminatedUnion('operation', [ */ export const workspaceDomainState = z.object({ initialized: z.boolean(), + /** First-use Workspace identity, retained after its registration is deleted. */ + defaultWorkspaceId: workspaceId.optional(), workspaceIds: z.array(workspaceId), archivedSessionIds: z.array(sessionId).default([]), pinnedSessionIds: z.array(sessionId).default([]), diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index f860028d4e..6eafae9f2e 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -2,7 +2,7 @@ import type { z } from 'zod' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' +import { basename, join, relative } from 'node:path' import { Context } from '@deepseek-ai/cordis' import Storage from '@deepseek-ai/dsh-storage' import type { StorageBackend } from '@deepseek-ai/dsh-storage' @@ -1156,3 +1156,145 @@ describe('registry-global session unpin', () => { expect(result.list.mock.calls.length).toBe(listingsBefore) }) }) + +describe('first-use Workspace preparation', () => { + const contexts: Context[] = [] + + afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + }) + + async function firstUse(options: HarnessOptions = {}) { + const directoryRoot = await makeDir('first-use') + const result = await harness({ liveSessions: [], ...options }) + contexts.push(result.ctx) + const resolveDirectory = vi.fn(async () => ({ path: join(directoryRoot, 'nested', 'Workspace'), title: 'Workspace' })) + return { ...result, directoryRoot, resolveDirectory } + } + + it('creates parent directories and resolves only one directory for concurrent initialization', async () => { + const h = await firstUse() + const laterDirectory = vi.fn(async () => { throw new Error('directory lookup unavailable') }) + const [first, repeated] = await Promise.all([ + h.registry.initializeDefault(h.resolveDirectory), h.registry.initializeDefault(laterDirectory), + ]) + expect(first).toBeDefined() + expect(repeated).toBe(first) + expect(h.resolveDirectory).toHaveBeenCalledOnce() + expect(laterDirectory).not.toHaveBeenCalled() + expect(first?.path).toBe(await realpath(join(h.directoryRoot, 'nested', 'Workspace'))) + expect(first?.title).toBe('Workspace') + expect(h.registry.list()).toEqual([first]) + expect(storedState(h.pool).defaultWorkspaceId).toBe(first?.id) + expect(h.ctx.sessions.list()).toEqual([]) + expect(h.open).not.toHaveBeenCalled() + }) + + it('reuses an existing directory without changing its contents', async () => { + const h = await firstUse() + const directory = join(h.directoryRoot, 'nested', 'Workspace') + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'keep.txt'), 'keep') + expect((await h.registry.initializeDefault(h.resolveDirectory))?.path).toBe(directory) + const { readFile } = await import('node:fs/promises') + expect(await readFile(join(directory, 'keep.txt'), 'utf8')).toBe('keep') + }) + + it('leaves initialization retryable when directory resolution fails', async () => { + const h = await firstUse() + h.resolveDirectory.mockRejectedValueOnce(new Error('lookup unavailable')) + await expect(h.registry.initializeDefault(h.resolveDirectory)).rejects.toThrow('lookup unavailable') + expect(storedState(h.pool).defaultWorkspaceId).toBeUndefined() + expect(await h.registry.initializeDefault(h.resolveDirectory)).toBeDefined() + }) + + it('rejects a relative candidate before creating its directory', async () => { + const h = await firstUse() + const candidate = join(h.directoryRoot, 'relative') + h.resolveDirectory.mockResolvedValueOnce({ path: relative(process.cwd(), candidate), title: 'Workspace' }) + await expect(h.registry.initializeDefault(h.resolveDirectory)).rejects.toThrow('fully qualified') + await expect(realpath(candidate)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(h.registry.list()).toEqual([]) + expect(storedState(h.pool).defaultWorkspaceId).toBeUndefined() + }) + + it('keeps the initialization marker across deletion and restart', async () => { + const h = await firstUse() + const workspace = (await h.registry.initializeDefault(h.resolveDirectory))! + await workspace.setTitle('Renamed') + expect((await h.registry.initializeDefault(h.resolveDirectory))?.title).toBe('Renamed') + await h.registry.delete(workspace.id) + await h.ctx.fiber.dispose() + const restarted = await firstUse({ pool: h.pool }) + await expect(restarted.registry.initializeDefault(restarted.resolveDirectory)).resolves.toBeUndefined() + expect(storedState(h.pool).defaultWorkspaceId).toBe(workspace.id) + expect(restarted.registry.list()).toEqual([]) + expect(restarted.resolveDirectory).not.toHaveBeenCalled() + }) + + it.each(['persisted', 'live', 'archived'] as const)('refuses automatic creation for a %s cwd-less Session', async (kind) => { + const history = header('old') + const h = await firstUse(kind === 'persisted' ? { sessions: [history] } : { liveSessions: [history] }) + if (kind === 'archived') await h.registry.archiveSession(history.id) + await expect(h.registry.initializeDefault(h.resolveDirectory)).resolves.toBeUndefined() + expect(h.registry.list()).toEqual([]) + expect(storedState(h.pool).defaultWorkspaceId).toBeUndefined() + expect(h.resolveDirectory).not.toHaveBeenCalled() + }) + + it('refuses a non-empty Workspace list and newly persisted history', async () => { + const h = await firstUse() + const explicit = await h.registry.create(h.directoryRoot) + await expect(h.registry.initializeDefault(h.resolveDirectory)).resolves.toBeUndefined() + await h.registry.delete(explicit.id) + h.setSessions([header('arrived')]) + await expect(h.registry.initializeDefault(h.resolveDirectory)).resolves.toBeUndefined() + }) + + it('fails on a same-path file and remains eligible after it is removed', async () => { + const h = await firstUse() + const parent = join(h.directoryRoot, 'nested') + await mkdir(parent) + const path = join(parent, 'Workspace') + await writeFile(path, 'occupied') + await expect(h.registry.initializeDefault(h.resolveDirectory)).rejects.toThrow() + expect(storedState(h.pool).defaultWorkspaceId).toBeUndefined() + expect(h.registry.list()).toEqual([]) + await rm(path) + expect((await h.registry.initializeDefault(h.resolveDirectory))?.path).toBe(path) + }) + + it.each(['persisted', 'live'] as const)('refuses registration when a %s Session appears during directory preparation', async (kind) => { + const h = await firstUse() + const arrived = header('arrived-during-preparation') + if (kind === 'persisted') { + h.list.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { header: arrived, revision: SessionPersistenceRevision('arrived-revision') }, + ]) + } else { + vi.spyOn(h.ctx.sessions, 'list').mockReturnValueOnce([]).mockReturnValueOnce([{ header: arrived }] as never) + } + await expect(h.registry.initializeDefault(h.resolveDirectory)).resolves.toBeUndefined() + expect(h.registry.list()).toEqual([]) + expect(storedState(h.pool).defaultWorkspaceId).toBeUndefined() + }) + + it('does not infer an empty live Session store when the peer is unavailable', async () => { + const h = await harness() + contexts.push(h.ctx) + const resolveDirectory = vi.fn(async () => { throw new Error('unexpected directory lookup') }) + await expect(h.registry.initializeDefault(resolveDirectory)).rejects.toThrow('Session store') + expect(resolveDirectory).not.toHaveBeenCalled() + expect(storedState(h.pool).defaultWorkspaceId).toBeUndefined() + }) + + it('rolls back a failed final marker write and allows a retry', async () => { + const pool = new MemoryMediaPool() + const h = await firstUse({ pool, backend: selectiveFailureBackend(pool, { globalAt: 3 }) }) + await expect(h.registry.initializeDefault(h.resolveDirectory)).rejects.toThrow('marker failure') + expect(h.registry.list()).toEqual([]) + expect(storedState(pool).defaultWorkspaceId).toBeUndefined() + await expect(realpath(join(h.directoryRoot, 'nested', 'Workspace'))).resolves.toBe(join(h.directoryRoot, 'nested', 'Workspace')) + expect(await h.registry.initializeDefault(h.resolveDirectory)).toBeDefined() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc16be7fbf..6543dd2ade 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1297,6 +1297,12 @@ importers: '@deepseek-ai/dsh-deque': specifier: workspace:^ version: link:../../util/deque + '@deepseek-ai/dsh-native-command': + specifier: workspace:^ + version: link:../../util/native-command + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index c3a8c6a239..1f19959e7e 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -246,6 +246,7 @@ export const EVENT_SCOPE_PAGE: Record = { export const EVENT_WALK_EXEMPTIONS: Record = { 'command/executed': 'client-face local command acknowledgment — packages/client/ui-commands/README.md owns the API', 'connection/reset': 'client-face transport signal — packages/api/session-controller/README.md owns the API', + 'conversation/prepare-first-send': 'client-face first-use draft preparation — packages/client/ui-conversation/README.md owns the API', 'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the API', 'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', 'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', @@ -681,6 +682,7 @@ export const LINK_MAP: Readonly> = { WorkspaceArchiveSessionRequest: 'workspace.md', WorkspaceArchiveValue: 'workspace.md', WorkspaceCreateRequest: 'workspace.md', + WorkspaceInitializeDefaultRequest: 'workspace.md', WorkspaceCreateValue: 'workspace.md', WorkspaceDeleteRequest: 'workspace.md', WorkspaceDeleteValue: 'workspace.md', diff --git a/snapshots/web/default-workspace/snapshot.yml b/snapshots/web/default-workspace/snapshot.yml new file mode 100644 index 0000000000..d1eed14d70 --- /dev/null +++ b/snapshots/web/default-workspace/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: default-workspace +profile: web +composition: web-default +recording: authored +header: + class: web-default +session: + source: ../fresh-round-trip/session.v3.jsonl diff --git a/snapshots/web/default-workspace/ui.expected.md b/snapshots/web/default-workspace/ui.expected.md new file mode 100644 index 0000000000..425343834b --- /dev/null +++ b/snapshots/web/default-workspace/ui.expected.md @@ -0,0 +1,26 @@ +- banner: + - navigation "Session hierarchy": Use the bash tool to + - text: Standard mode + - button "More actions" + - button "Open right sidebar" + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt" +- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" +- button "Copy" +- button "1 tool call" +- paragraph: DONE +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- text: {{clock}} +- textbox "Message or run a task, / commands, @ files or sessions" +- button "Add files or run commands" +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash +- button "Send message" [disabled] +- button "1 turns 2 steps · {{throughput}} tok/s": 1 turns 2 steps{{throughput}} tok/s +- button "15.8K tok · Cache hit 99%": 15.8K tokCache hit 99% +- button "6% of context used": 6% diff --git a/tsconfig.host.json b/tsconfig.host.json index c1088e0ae8..0e9797b99b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -63,6 +63,7 @@ "apps/web/tests/public-mount.e2e.ts", "apps/web/tests/workspace-new-session-folding.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", + "apps/web/tests/default-workspace.e2e.ts", "apps/web/tests/workspace-recency.e2e.ts", "apps/web/tests/session-unarchive.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", From 30851396998bf5e6280ead17478a9a880e4a2349 Mon Sep 17 00:00:00 2001 From: fz Date: Sun, 20 Sep 2026 15:02:02 +0800 Subject: [PATCH 154/284] test(runtime): align fixtures with current type-safety gates --- .../tests/profiles/sdk/keyless-smoke.e2e.ts | 9 +++++---- .../tests/tool-workspace-dependencies.spec.ts | 20 +++++++------------ scripts/no-unknown-casts.baseline.json | 3 --- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 18f13aa540..e4a615b8bc 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -513,11 +513,12 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', ' `) } else if (enabled) { expect(requests).toHaveLength(2) + const content: unknown = expect.arrayContaining([ + expect.objectContaining({ type: 'tool_result', tool_use_id: 'workspace-dependencies', + content: [{ type: 'text', text: JSON.stringify(paths, undefined, 2) }] }), + ]) expect(requests[1]!.messages).toEqual(expect.arrayContaining([ - expect.objectContaining({ role: 'user', content: expect.arrayContaining([ - expect.objectContaining({ type: 'tool_result', tool_use_id: 'workspace-dependencies', - content: [{ type: 'text', text: JSON.stringify(paths, undefined, 2) }] }), - ]) as unknown }), + expect.objectContaining({ role: 'user', content }), ])) } if (mode === 'missing-assets') expect(stderr).toContain('check_office.py') diff --git a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts index 2b9e187207..57f1eb1c15 100644 --- a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts +++ b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts @@ -7,6 +7,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' +import z from '@deepseek-ai/schemastery' import AgentRegistry from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' @@ -195,18 +196,11 @@ it('loads the real tool through Cordis, exposes payload paths in place, and unre ctx.baseUrl = pathToFileURL(directory).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include - const modules = new Map([ - ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], ['dependencies', workspaceDependencies], - ]) - ctx.loader.internal = { - version: 'v2', - async import(specifier: string) { - if (!modules.has(specifier)) throw new Error(`unexpected plugin ${specifier}`) - return modules.get(specifier) - }, - } as unknown as NonNullable + Object.assign(ctx.loader.builtins, { + agents: AgentRegistry, systemPrompt: SystemPrompt, tools: ToolRuntime, dependencies: workspaceDependencies, + }) const config = join(directory, 'cordis.yml') - await writeFile(config, `- name: agents\n- name: systemPrompt\n- name: tools\n- name: dependencies\n config:\n source: ${JSON.stringify(source)}\n`) + await writeFile(config, `- name: cordis:agents\n- name: cordis:systemPrompt\n- name: cordis:tools\n- name: cordis:dependencies\n config:\n source: ${JSON.stringify(source)}\n`) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } }) await ctx.loader.await() for (const entry of ctx.loader.entries()) await entry.fiber?.await() @@ -218,7 +212,7 @@ it('loads the real tool through Cordis, exposes payload paths in place, and unre expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(workspaceDependencyPaths(source, manifest), undefined, 2) }]) } expect(process.env).toEqual(environment) - const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'dependencies') + const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'cordis:dependencies') expect(entry).toBeDefined() await entry?.fiber?.dispose() expect(ctx.tools.schemas().some(tool => tool.name === 'load_workspace_dependencies')).toBe(false) @@ -373,7 +367,7 @@ it.each([{ source: 'relative' }, { source: process.cwd(), root: 'relative' }])(' }) it.each([{}, { source: '' }, { source: 1 }])('rejects invalid payload configuration with a named source error: %j', (config) => { - expect(() => workspaceDependencies.Config(config as unknown as workspaceDependencies.Config)).toThrow('source') + expect(() => z.resolve(config, workspaceDependencies.Config, {})).toThrow('source') }) it('validates the previous directory even when a current installation exists', async () => { diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index f5ecfea159..3302a68bc3 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -26,9 +26,6 @@ "apps/desktop-host/tests/office.spec.ts": { "3f8653cd87f99ebd70765379dae51ab308ed9c280e831d39e1abd469bfcdc6e3": 1 }, - "apps/desktop-host/tests/primary-runtime.spec.ts": { - "3f8653cd87f99ebd70765379dae51ab308ed9c280e831d39e1abd469bfcdc6e3": 1 - }, "apps/desktop/scripts/installed-update-qualification.ts": { "5a8270cf9b1e554ae0cff6eaf7086e44c22f61a08e5f91dc7e1397eccaa726a5": 2 }, From fd75c47ae9e013d9b5a8d683fcb8e0464b17db8d Mon Sep 17 00:00:00 2001 From: winewill Date: Sun, 20 Sep 2026 15:08:57 +0800 Subject: [PATCH 155/284] fix(desktop): localize macOS application menu names --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/locale.ts | 8 ++++++++ apps/desktop/src/main.ts | 7 +++++-- .../tests/expected/application-menu-en-US.json | 18 ++++++++++++++++++ .../tests/expected/application-menu-zh-CN.json | 18 ++++++++++++++++++ apps/desktop/tests/main-startup.spec.ts | 16 ++++++++++++++++ 8 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/tests/expected/application-menu-en-US.json create mode 100644 apps/desktop/tests/expected/application-menu-zh-CN.json diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 455501862e..fe73a5be76 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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 apps/desktop/README.md -README.md: 6c9a6a8784732e6fa1369df5632391b789786b22 -README.zh.md: a0f2184d911eeab46902670cf992b6847e2bca31 +README.md: da4db5806b8ca50e14fc2e95d579cbcf0e50bb77 +README.zh.md: 8d43dbde6b829555203b39d06c71b367f318e14b diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6c9a6a8784..da4db5806b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. -The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG. +The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG. Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a0f2184d91..8d43dbde6b 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -4,7 +4,7 @@ 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 -应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。 +应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。 Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 03e6f29a57..09dedf44e5 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -3,6 +3,10 @@ export const en = { application: 'Application', aboutMenu: 'About DeepSeek Harness', + hideApplication: 'Hide DeepSeek Harness', + hideOtherApplications: 'Hide Others', + showAllApplications: 'Show All', + quitApplication: 'Quit DeepSeek Harness', edit: 'Edit', menuBar: 'Application menu', delete: 'Delete', @@ -94,6 +98,10 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string } export const zh = { application: '应用', aboutMenu: '关于 DeepSeek Harness', + hideApplication: '隐藏 DeepSeek Harness', + hideOtherApplications: '隐藏其他', + showAllApplications: '显示全部', + quitApplication: '退出 DeepSeek Harness', edit: '编辑', menuBar: '应用菜单', delete: '删除', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e5aa563c6e..10634b7626 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -613,7 +613,9 @@ async function main(): Promise { ? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }] : [{ role: 'editMenu' }] const hideCommands: MenuItemConstructorOptions[] = darwin - ? [{ role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }] + ? [{ role: 'hide', label: currentDesktopLocale().messages.hideApplication }, + { role: 'hideOthers', label: currentDesktopLocale().messages.hideOtherApplications }, + { role: 'unhide', label: currentDesktopLocale().messages.showAllApplications }, { type: 'separator' }] : [] const applicationItems = (): MenuItemConstructorOptions[] => [ { label: currentDesktopLocale().messages.aboutMenu, role: 'about' }, @@ -621,7 +623,8 @@ async function main(): Promise { { label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } }, { type: 'separator' }, ...hideCommands, - { role: 'quit', ...(process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) }, + { role: 'quit', ...(darwin ? { label: currentDesktopLocale().messages.quitApplication } + : process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) }, ] Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{ label: darwin ? app.name : currentDesktopLocale().messages.application, diff --git a/apps/desktop/tests/expected/application-menu-en-US.json b/apps/desktop/tests/expected/application-menu-en-US.json new file mode 100644 index 0000000000..af72c9b264 --- /dev/null +++ b/apps/desktop/tests/expected/application-menu-en-US.json @@ -0,0 +1,18 @@ +[ + { + "role": "hide", + "label": "Hide DeepSeek Harness" + }, + { + "role": "hideOthers", + "label": "Hide Others" + }, + { + "role": "unhide", + "label": "Show All" + }, + { + "role": "quit", + "label": "Quit DeepSeek Harness" + } +] diff --git a/apps/desktop/tests/expected/application-menu-zh-CN.json b/apps/desktop/tests/expected/application-menu-zh-CN.json new file mode 100644 index 0000000000..991b9d7639 --- /dev/null +++ b/apps/desktop/tests/expected/application-menu-zh-CN.json @@ -0,0 +1,18 @@ +[ + { + "role": "hide", + "label": "隐藏 DeepSeek Harness" + }, + { + "role": "hideOthers", + "label": "隐藏其他" + }, + { + "role": "unhide", + "label": "显示全部" + }, + { + "role": "quit", + "label": "退出 DeepSeek Harness" + } +] diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 171e3855f1..3c792be2eb 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -672,6 +672,22 @@ describe('desktop main startup', () => { expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce() }) + it.each(['en-US', 'zh-CN'])('localizes macOS visibility and quit commands without changing the application name (%s)', async (locale) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + vi.spyOn(harness.app, 'getLocale').mockReturnValue(locale) + const originalName = harness.app.name + harness.app.name = '@deepseek-ai/dsh-desktop' + try { + await import('../src/main.ts') + await harness.preparing.promise + const commands = applicationMenuItems().filter(item => + item.role === 'hide' || item.role === 'hideOthers' || item.role === 'unhide' || item.role === 'quit') + await expect(JSON.stringify(commands, null, 2) + '\n') + .toMatchFileSnapshot(`./expected/application-menu-${locale}.json`) + expect(harness.app.name).toBe('@deepseek-ai/dsh-desktop') + } finally { harness.app.name = originalName } + }) + it('attaches Host socket credentials only to the owned application origin and window', async () => { await import('../src/main.ts') await harness.preparing.promise From a978ad1994aa27f9c69daad9436bdb547fd3caf8 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 20 Sep 2026 15:09:37 +0800 Subject: [PATCH 156/284] fix(subagent): version unknown catalog payloads locally --- ...session-local-subagent-migration.i18n.yaml | 4 +- ...-09-19-session-local-subagent-migration.md | 2 +- ...-19-session-local-subagent-migration.zh.md | 2 +- ...6-09-11-persistence-type-history.i18n.yaml | 4 +- .../2026-09-11-persistence-type-history.md | 2 +- .../2026-09-11-persistence-type-history.zh.md | 2 +- apps/web/tests/preset-migration.snapshot.ts | 2 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 54 +- docs/persistence-catalog.zh.md | 54 +- ...2026-09-20-unknown-child-catalog.i18n.yaml | 4 +- .../2026-09-20-unknown-child-catalog.md | 6 +- ...26-09-20-unknown-child-catalog.schema.json | 86 +- .../2026-09-20-unknown-child-catalog.zh.md | 6 +- docs/persistence-changes/README.i18n.yaml | 4 +- docs/persistence-changes/README.md | 6 +- docs/persistence-changes/README.zh.md | 6 +- .../historical-formats/README.i18n.yaml | 4 +- .../historical-formats/README.md | 2 +- .../historical-formats/README.zh.md | 2 +- docs/persistence-schema.json | 1076 +++++++++++------ packages/sdk/client/tests/sdk-client.spec.ts | 2 +- .../session-format-v3-to-v4/README.i18n.yaml | 4 +- .../session/session-format-v3-to-v4/README.md | 6 +- .../session-format-v3-to-v4/README.zh.md | 6 +- .../session-format-v3-to-v4/src/facts.ts | 5 +- .../session-format-v3-to-v4/src/migration.ts | 2 +- .../tests/catalog-completion.spec.ts | 10 +- .../tests/validation.spec.ts | 4 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- packages/subagent/subagent/src/catalog.ts | 24 +- .../subagent/subagent/tests/catalog.spec.ts | 16 +- python/sdk/tests/test_client.py | 2 +- scripts/no-unknown-casts.baseline.json | 2 - scripts/persistence-changes.spec.ts | 79 +- scripts/persistence-changes.ts | 45 +- 38 files changed, 1005 insertions(+), 542 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml index 8b543c4f2b..e2f0f66dda 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md -2026-09-19-session-local-subagent-migration.md: a44b6cdfe37656fc112f06187ef0c884e8b2fae1 -2026-09-19-session-local-subagent-migration.zh.md: e1c790d4e19dbc0bab962592d9ff14b2e94fbdb0 +2026-09-19-session-local-subagent-migration.md: 26911ea6e2cb31126d13e4c8c6488ff3b45563e6 +2026-09-19-session-local-subagent-migration.zh.md: 673040a3dc73d3665e42013a4704ef2d5911b23d diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md index a44b6cdfe3..26911ea6e2 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md @@ -28,6 +28,6 @@ This partially supersedes child-failure propagation in [released migrations](../ ## Consequences -Initial parent preparation still scans headers and reads direct-child bodies one at a time. It does not claim to eliminate this I/O. Published V4 parents use their catalog without rescanning children. Unknown entries are not automatically rewritten after publication. Opening a child retries its log and determines browsing capabilities from its actual descriptor, or reports the child-local error. Catalog mode adds `unknown` without changing existing alternatives or the V4 header. The persistence checker admits this scoped extension; older readers may reject records using the new mode. +Initial parent preparation still scans headers and reads direct-child bodies one at a time. It does not claim to eliminate this I/O. Published V4 parents use their catalog without rescanning children. Unknown entries are not automatically rewritten after publication. Opening a child retries its log and determines browsing capabilities from its actual descriptor, or reports the child-local error. Catalog payload v0 retains its two known modes; v1 also supports `unknown`. Readers support both versions; healthy creation and complete historical facts retain v0, while unknown migration facts use v1. The V4 Session header is unchanged, and older readers reject the new payload version. Raw and compressed tests cover catalog completion, child-local errors, source changes and repairs, unchanged child generations, and deferred child-catalog preparation. The shipped Web migration snapshot checks the parent catalog with a corrupt sibling, then opens the child independently. Its additional historical child raises the corpus budget from ten to eleven roles; the guard rejects a twelfth role. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md index e1c790d4e1..673040a3dc 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md @@ -28,6 +28,6 @@ JSONL 在发现时跳过不可读或不支持的 header。子正文或 descripto ## 后果 -首次父准备仍扫描 header 并逐个读取直属子正文,不宣称消除此 I/O。已发布 V4 父会话使用其 catalog,不重新扫描子日志。发布后不自动重写未知条目。打开子会话会重试其日志,根据实际 descriptor 确定浏览能力,或报告该子会话的错误。Catalog mode 增加 `unknown`,既有分支和 V4 header 保持不变。持久化检查允许这项限定扩展;旧读取器可能拒绝使用新模式的记录。 +首次父准备仍扫描 header 并逐个读取直属子正文,不宣称消除此 I/O。已发布 V4 父会话使用其 catalog,不重新扫描子日志。发布后不自动重写未知条目。打开子会话会重试其日志,根据实际 descriptor 确定浏览能力,或报告该子会话的错误。Catalog 载荷 v0 保留两种已知模式,v1 另支持 `unknown`。读取器支持两版;正常创建及完整历史事实继续使用 v0,未知迁移事实使用 v1。V4 Session header 不变,旧读取器拒绝新载荷版本。 原始和压缩测试覆盖目录补齐、子会话错误隔离、来源变化与修复、子代际不变和延后的子目录准备。随产品 Web 迁移快照在损坏兄弟存在时检查父目录,再独立打开子会话。额外的历史子角色使语料预算由十个增加到十一个;守卫拒绝第十二个角色。 diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml index 30c4c0961b..969ab3fd9e 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml @@ -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 .agents/notes/implemented/process/2026-09-11-persistence-type-history.md -2026-09-11-persistence-type-history.md: a5c50a871ebdb583c7e955944b03f4e082c69fe2 -2026-09-11-persistence-type-history.zh.md: 8fc42ccd9964d7eab254da8bb8c40ae055ff1152 +2026-09-11-persistence-type-history.md: 87e87041dd44c4f671409a75c1b374312736342a +2026-09-11-persistence-type-history.zh.md: cfe5c4f7bd1998d4329dc422607cfe33273d1a5a diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md index a5c50a871e..87e87041dd 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md @@ -14,7 +14,7 @@ One normalized type model supplies the readable persistence catalog, the complet The dedicated [persistence-change records](../../../../docs/persistence-changes/README.md) bind acknowledgement to the after digest of each affected root. A generated companion stores complete after schemas. The initial record covers all roots; later records name each root's predecessor. A predecessor's after schema supplies the next before schema. Verification rejects ambiguous history and requires current source to match the terminal recorded state without consulting Git history or remote services. -Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. The [catalog exception](../../../../docs/persistence-changes/README.md#compatibility-rules) admits unknown child mode with unchanged identity fields and records the older-reader limitation. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version. +Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. The [event-payload version rule](../../../../docs/persistence-changes/README.md#compatibility-rules) admits higher numeric payload versions when every old alternative remains unchanged and records the older-reader limitation. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version. The new document kind preserves the compatibility reasoning and evidence for a historical type transition. Agent Notes retain mechanism-level decisions; they do not become a growing inventory of individual acknowledgements. Machine declarations remain identical across the bilingual pair and are parsed once. diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md index 8fc42ccd99..cfe5c4f7bd 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md @@ -14,7 +14,7 @@ Status: implemented 专用的[持久化变更记录](../../../../docs/persistence-changes/README.zh.md)将确认绑定到每个受影响根的变更后摘要。生成的伴随文件保存完整的变更后 schema。初始记录覆盖所有根;后续记录为每个根指定前驱。前驱的变更后 schema 提供下一次的变更前 schema。验证拒绝有歧义的历史,并要求当前源码匹配最终记录状态,无需查询 Git 历史或远端服务。 -自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。[Catalog 例外](../../../../docs/persistence-changes/README.zh.md#compatibility-rules) 允许增加身份字段不变的未知子模式,并记录旧读取器限制。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。 +自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。[事件载荷版本规则](../../../../docs/persistence-changes/README.zh.md#compatibility-rules) 允许在原样保留所有旧分支时增加更高的数字载荷版本,并记录旧读取器限制。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。 新文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。 diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index 530a0f3ef9..42e69f65ef 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -109,7 +109,7 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t ? { ...row, data: { agentPreset: 'ptc' } } : row), { type: 'subagent/catalog', seq: rows.length, time: rows.at(-1)?.['time'], data: { - version: 0, childId: brokenId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'unknown', + version: 1, childId: brokenId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'unknown', } }, { type: 'subagent/catalog', seq: rows.length + 1, time: rows.at(-1)?.['time'], data: { version: 0, childId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child', diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 43982e1667..5f847038ca 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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 docs/persistence-catalog.md -persistence-catalog.md: 0733c6880cf65e445b35c2dea0589b7ce93a96b8 -persistence-catalog.zh.md: c4f4870fb8d712b43502579ecfd7adae8d74a535 +persistence-catalog.md: 11cea4328db6e69c1ee08994a6d36fe26e308a7c +persistence-catalog.zh.md: 6207dc17023e566f73cb1e0c7cba40533a4a96ff diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 0733c6880c..11cea4328d 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -57,7 +57,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3) | +| `event:subagent/catalog` | event | `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -900,7 +900,7 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/ 'subagent/catalog': SubagentCatalogEvent ``` -Source: [`packages/subagent/subagent/src/catalog.ts:41`](../packages/subagent/subagent/src/catalog.ts) +Source: [`packages/subagent/subagent/src/catalog.ts:43`](../packages/subagent/subagent/src/catalog.ts) @@ -4555,7 +4555,7 @@ One of: - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4563,15 +4563,17 @@ One of: ### `SubagentCatalogEvent` -SHA-256: `30a49d1a89bd03b8503fbdab94d4883e4ce839d931f29672ac070007cc0016ce` +SHA-256: `1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0` -Sources: [`packages/subagent/subagent/src/catalog.ts:24`](../packages/subagent/subagent/src/catalog.ts) +Sources: [`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) One of: - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694) -- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) @@ -5621,6 +5623,20 @@ SHA-256: `0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd` | `mode` | required | `"continuable"` | | `version` | required | `0` | + + +### `{ childCreatedAt, childId, label, mode, … }` + +SHA-256: `e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | required | `string` | +| `mode` | required | `"continuable"` | +| `version` | required | `1` | + ### `{ childCreatedAt, childId, label?, mode, … }` @@ -5635,11 +5651,11 @@ SHA-256: `5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694` | `mode` | required | `"one-shot"` | | `version` | required | `0` | - + ### `{ childCreatedAt, childId, label?, mode, … }` -SHA-256: `c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca` +SHA-256: `a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499` | Property | Presence | Type | |---|---|---| @@ -5647,7 +5663,21 @@ SHA-256: `c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca` | `childId` | required | `string` | | `label` | optional | `string` | | `mode` | required | `"unknown"` | -| `version` | required | `0` | +| `version` | required | `1` | + + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | optional | `string` | +| `mode` | required | `"one-shot"` | +| `version` | required | `1` | @@ -8244,17 +8274,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | required | `number` | | `type` | required | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3` +SHA-256: `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-30a49d1a89bd03b8503fbdab94d4883e4ce839d931f29672ac070007cc0016ce) | +| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c4f4870fb8..6207dc1702 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -59,7 +59,7 @@ | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3) | +| `event:subagent/catalog` | event | `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -902,7 +902,7 @@ export type SessionEvent = { 'subagent/catalog': SubagentCatalogEvent ``` -来源:[`packages/subagent/subagent/src/catalog.ts:41`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:43`](../packages/subagent/subagent/src/catalog.ts) @@ -4557,7 +4557,7 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4565,15 +4565,17 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` ### `SubagentCatalogEvent` -SHA-256: `30a49d1a89bd03b8503fbdab94d4883e4ce839d931f29672ac070007cc0016ce` +SHA-256: `1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0` -来源:[`packages/subagent/subagent/src/catalog.ts:24`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) 以下类型之一: - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694) -- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) @@ -5623,6 +5625,20 @@ SHA-256: `0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd` | `mode` | 必需 | `"continuable"` | | `version` | 必需 | `0` | + + +### `{ childCreatedAt, childId, label, mode, … }` + +SHA-256: `e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 必需 | `string` | +| `mode` | 必需 | `"continuable"` | +| `version` | 必需 | `1` | + ### `{ childCreatedAt, childId, label?, mode, … }` @@ -5637,11 +5653,11 @@ SHA-256: `5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694` | `mode` | 必需 | `"one-shot"` | | `version` | 必需 | `0` | - + ### `{ childCreatedAt, childId, label?, mode, … }` -SHA-256: `c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca` +SHA-256: `a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499` | 属性 | 存在性 | 类型 | |---|---|---| @@ -5649,7 +5665,21 @@ SHA-256: `c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca` | `childId` | 必需 | `string` | | `label` | 可选 | `string` | | `mode` | 必需 | `"unknown"` | -| `version` | 必需 | `0` | +| `version` | 必需 | `1` | + + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"one-shot"` | +| `version` | 必需 | `1` | @@ -8246,17 +8276,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | 必需 | `number` | | `type` | 必需 | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3` +SHA-256: `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-30a49d1a89bd03b8503fbdab94d4883e4ce839d931f29672ac070007cc0016ce) | +| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml b/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml index d393e07ce1..60ad1d8f73 100644 --- a/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml @@ -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 docs/persistence-changes/2026-09-20-unknown-child-catalog.md -2026-09-20-unknown-child-catalog.md: 58e8a8ffedb7b3b913c9e04590ce5bbabd148ba6 -2026-09-20-unknown-child-catalog.zh.md: 488899ecadcdc93910927c80b22b8fcf0d319224 +2026-09-20-unknown-child-catalog.md: ba729661d60856ff948854641a1520339ab30210 +2026-09-20-unknown-child-catalog.zh.md: 468b4c9a3d3afab56ec8a9b03359016c9c0307e0 diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.md b/docs/persistence-changes/2026-09-20-unknown-child-catalog.md index 58e8a8ffed..ba729661d6 100644 --- a/docs/persistence-changes/2026-09-20-unknown-child-catalog.md +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.md @@ -28,19 +28,19 @@ baseline: false changes: - root: "event:subagent/catalog" previous: "2026-09-11-initial" - after: "4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3" + after: "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604" decision: same-version ``` ## Compatibility -The catalog adds one unknown-mode alternative with the same identity and optional label fields as one-shot mode. Existing alternatives and the V4 header remain unchanged. New readers accept old records; older readers may reject records with unknown mode. The persistence classifier has a scoped catalog exception for this addition, while other union additions and modifications remain strict. Unknown membership grants no continuation capability. +Catalog payload v0 remains unchanged. Payload v1 adds unknown mode, and current readers accept v0 and v1. Complete facts still use v0; migration emits v1 for unknown children. Existing logs need no rewrite, and the Session header remains V4. Older readers reject v1. The classifier allows higher event payload versions only when all old payload alternatives remain unchanged; same-version widening and removal of old readers stay breaking. ## Verification -Catalog migration, restoration, projection, and Web regressions cover retained unknown membership and child-local errors. Persistence classifier tests accept the exact catalog extension and reject altered existing fields, other new modes, extra unknown fields, removals, and unrelated union additions. +Migration, restoration, projection, and Web regressions retain unknown membership and child-local errors. Classifier tests accept higher payload versions with preserved predecessors and reject same-version additions, invalid versions, changed or removed old alternatives, and header/surface changes. ## Dev Note diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json b/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json index ce4dd23ac3..14c4b254ad 100644 --- a/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json @@ -6,7 +6,7 @@ "kind": "event", "event": "subagent/catalog", "surface": false, - "digest": "4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3", + "digest": "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604", "schema": { "root": 0, "nodes": [ @@ -20,7 +20,7 @@ }, { "name": "ignorable", - "type": 11, + "type": 14, "optional": true }, { @@ -35,7 +35,7 @@ }, { "name": "type", - "type": 12, + "type": 15, "optional": false } ], @@ -46,7 +46,9 @@ "types": [ 2, 7, - 9 + 9, + 11, + 12 ] }, { @@ -65,7 +67,7 @@ { "name": "label", "type": 4, - "optional": true + "optional": false }, { "name": "mode", @@ -90,7 +92,7 @@ }, { "kind": "literal", - "value": "unknown" + "value": "continuable" }, { "kind": "literal", @@ -116,12 +118,12 @@ }, { "name": "mode", - "type": 8, + "type": 5, "optional": false }, { "name": "version", - "type": 6, + "type": 8, "optional": false } ], @@ -129,7 +131,7 @@ }, { "kind": "literal", - "value": "continuable" + "value": 1 }, { "kind": "object", @@ -166,6 +168,72 @@ "kind": "literal", "value": "one-shot" }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, { "kind": "literal", "value": true diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md b/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md index 488899ecad..468b4c9a3d 100644 --- a/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md @@ -28,19 +28,19 @@ baseline: false changes: - root: "event:subagent/catalog" previous: "2026-09-11-initial" - after: "4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3" + after: "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604" decision: same-version ``` ## 兼容性 -Catalog 增加一个 unknown 模式分支,身份与可选 label 字段和 one-shot 模式相同。已有分支及 V4 header 保持不变。新读取器接受旧记录;旧读取器可能拒绝 unknown 模式记录。持久化分类器仅为该 catalog 扩展设置限定例外,其他联合类型新增与修改仍严格检查。未知成员关系不授予继续执行能力。 +Catalog 载荷 v0 保持不变。载荷 v1 增加 unknown 模式,当前读取器支持 v0 和 v1。完整事实继续使用 v0;迁移为未知子会话写入 v1。已有日志无需改写,Session header 保持 V4。旧读取器拒绝 v1。分类器仅在所有旧载荷分支保持不变时允许增加更高的事件载荷版本;同版本扩展和移除旧版读取支持仍属于破坏性变更。 ## 验证 -Catalog 迁移、恢复、投影和 Web 回归覆盖未知成员保留与子会话局部报错。持久化分类器测试接受此 catalog 扩展,并拒绝已有字段修改、其他新增模式、未知分支额外字段、删除及无关联合类型新增。 +迁移、恢复、投影和 Web 回归覆盖未知成员保留与子会话局部报错。分类器测试接受保留前代的更高载荷版本,并拒绝同版本新增、无效版本、旧分支修改或删除,以及 header/model-surface 变更。 ## 开发备注 diff --git a/docs/persistence-changes/README.i18n.yaml b/docs/persistence-changes/README.i18n.yaml index ea39a60513..c2d80f8829 100644 --- a/docs/persistence-changes/README.i18n.yaml +++ b/docs/persistence-changes/README.i18n.yaml @@ -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 docs/persistence-changes/README.md -README.md: 1e7c7b7aec8ecf6687b37f36544dccb4c8e4c4f5 -README.zh.md: d6a315acb42a82a926fc323370b52c1a1b61065e +README.md: 2f3d2dd50adee751d9dce9ab250649ae13e45521 +README.zh.md: d2cec933b2210cc5652a7aa6c725517e1baff948 diff --git a/docs/persistence-changes/README.md b/docs/persistence-changes/README.md index 1e7c7b7aec..2f3d2dd50a 100644 --- a/docs/persistence-changes/README.md +++ b/docs/persistence-changes/README.md @@ -53,18 +53,18 @@ Every detected structural change requires an acknowledgement. Record creation an | Add an optional event-body property, including its complete subtree | `same-version` | | Make a required event-body property optional | `same-version` | | Add an ordinary event type | `same-version` | -| Add `unknown` mode to `subagent/catalog` using the one-shot fields, while preserving existing alternatives | `same-version` | +| Add a higher numeric `data.version` to an ordinary event while retaining every old payload alternative unchanged | `same-version` | | Add an explicitly qualified attribution kind to a user/developer source slot whose before and after schemas carry the same supported policy | `same-version` | | Make an optional property required, add a required property, change an existing type, or remove/rename a property or event | `version-bump` | | Change the Session header or event envelope | `version-bump` | A finalized checkpoint protects the accepted baseline without replacing these compatibility rules. At V4, optional additions, ordinary events, and qualified attribution additions can receive new same-version records. Breaking differences require a higher writer version and an acknowledgement containing its own header increase. The accepted V4 record cannot be updated to reuse its original 3→4 transition. -The catalog exception retains a historical child's identity when its descriptor is unavailable. It admits only an `unknown` alternative with the same fields as the one-shot alternative; existing alternatives still receive ordinary comparison. It does not permit other mode additions, removals, header changes, or arbitrary union extensions. Existing logs remain readable by the updated reader; older readers may reject the new mode. The [acknowledgement](2026-09-20-unknown-child-catalog.md) records this forward-read limitation. +An ordinary event may add payload alternatives with a required, nonnegative integer `data.version` greater than every existing payload version, provided all existing alternatives remain structurally unchanged. The reader must retain support for the old payloads; adding variants at an existing version, dropping old versions, and changing the Session header or envelope remain breaking. Older readers may reject the new payload version. The [catalog acknowledgement](2026-09-20-unknown-child-catalog.md) records one such transition. The extractor accepts an explicit `@persistenceSource` binding for a core-owned source property on a literal user or developer role; it does not infer a binding from an unannotated type. A producer qualifies its `MessageSourceMap` entry with `@persistenceAttribution`. Qualification promises that an unknown kind and its JSON metadata survive reading without the producer, and that the kind imposes no validation, replay, or authority requirement. A producer may inspect its own kind to resume duplicate suppression; other readers must preserve and derive the recorded messages without that projection. The recorded schema retains the binding, policy version, literal `kind` discriminator, preservation promise, and qualified kind set. Inventory format 2 stores those promises; extraction without a bound policy retains format 1. Session format versions are independent. Both compared snapshots must carry compatible policy state. Existing kind groups still receive ordinary structural comparison; removals, unmarked additions, policy changes, and unrelated breaking changes remain strict. Multiple context-form alternatives with the same wire kind form one group. -A same-version explanation states why old records can omit the addition and why older readers can ignore it without changing replay. For required-to-optional changes, it explains how readers handle an absent value. The checker validates the type classification; reviewers assess the explanation. A version-bump record includes the increasing header version in the same transition and follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md). +A same-version explanation states how old records remain readable and how older readers handle new records. Optional additions explain why older readers can ignore them without changing replay; a new payload version records the older-reader rejection. For required-to-optional changes, it explains how readers handle an absent value. The checker validates the type classification; reviewers assess the explanation. A version-bump record includes the increasing header version in the same transition and follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md). When a reader rejects a JSON property by name, declare the property as optional `never` and mark it with argument-free `@persistenceReserved`. The extractor retains the forbidden field, so allowing a JSON value later requires an existing-field type change. Required or JSON-valued properties cannot carry this marker. Unmarked optional `never` and `undefined` properties retain their existing omission behavior. diff --git a/docs/persistence-changes/README.zh.md b/docs/persistence-changes/README.zh.md index d6a315acb4..d2cec933b2 100644 --- a/docs/persistence-changes/README.zh.md +++ b/docs/persistence-changes/README.zh.md @@ -53,18 +53,18 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc | 添加可选事件体属性,包括其完整子树 | `same-version` | | 将必选事件体属性改为可选 | `same-version` | | 添加普通事件类型 | `same-version` | -| 为 `subagent/catalog` 增加使用 one-shot 字段的 `unknown` 模式,并保留既有分支 | `same-version` | +| 为普通事件增加更高的数字 `data.version`,并原样保留所有旧载荷分支 | `same-version` | | 在前后 schema 均带有相同受支持策略的 user/developer 消息源字段中,添加显式声明为归属信息的 kind | `same-version` | | 将可选属性改为必选、添加必选属性、更改已有类型,或删除/重命名属性或事件 | `version-bump` | | 更改会话头或事件封装 | `version-bump` | 定稿检查点保护已接受基线,不替换这些兼容性规则。在 V4 中,可选新增、普通事件及符合条件的归属 kind 新增可以使用新的同版本记录。破坏性差异要求更高的写入器版本,以及包含自身头版本递增的确认记录。不能更新已接受 V4 记录以复用其原有 3→4 转换。 -Catalog 例外在 descriptor 不可用时保留历史子会话身份。它仅允许增加字段与 one-shot 分支相同的 `unknown` 分支;已有分支仍执行常规比较。它不允许其他模式新增、删除、header 变更或任意联合类型扩展。新版读取器仍可读取已有日志;旧版读取器可能拒绝新模式。[确认记录](2026-09-20-unknown-child-catalog.zh.md) 说明这一向前读取限制。 +普通事件可增加具有必选、非负整数 `data.version` 的载荷分支,新版本必须高于所有已有载荷版本,且所有已有分支结构保持不变。读取器必须保留旧载荷支持;在已有版本中增加分支、移除旧版本,以及修改 Session header 或事件封装仍属于破坏性变更。旧读取器可能拒绝新载荷版本。[Catalog 确认记录](2026-09-20-unknown-child-catalog.zh.md) 记录了此类演进。 提取器接受核心拥有的 source 属性上针对 user 或 developer 字面量角色的显式 `@persistenceSource` 绑定,不会从未标记类型中推断绑定。生产者用 `@persistenceAttribution` 标记其 `MessageSourceMap` 成员。该标记承诺:读取器无需生产者即可保留未知 kind 及其 JSON 元数据,且该 kind 不引入校验、回放或权限要求。生产者可以检查自身 kind 来恢复去重状态;其他读取器必须无需该投影也能保留并派生已记录的消息。记录的 schema 保存绑定、策略版本、字面量 `kind` 判别字段、保留承诺及符合条件的 kind 集合。Inventory format 2 保存这些承诺;没有绑定策略的提取仍使用 format 1。Session 格式版本独立于此。比较双方的快照必须带有兼容的策略状态。已有 kind 分组仍进行常规结构比较;删除、未标记的添加、策略更改及无关破坏性变更继续采用严格规则。同一 wire kind 的多个上下文形式分支归为一组。 -同版本说明须解释旧记录为何可以缺少新增内容,以及旧读取器为何可以忽略它而不改变回放。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。 +同版本说明须解释旧记录如何保持可读,以及旧读取器如何处理新记录。可选新增须说明旧读取器为何可以忽略它而不改变回放;新增载荷版本须记录旧读取器的拒绝行为。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。 当读取器按属性名拒绝 JSON 字段时,将该属性声明为可选 `never`,并添加无参数的 `@persistenceReserved` 标记。提取器保留这一禁止字段,因此后续允许 JSON 值属于已有字段类型变更。必选属性或允许 JSON 值的属性不能携带该标记。未标记的可选 `never` 和 `undefined` 属性保留现有省略行为。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index f34e60f4e7..1f6bfd547a 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -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 docs/persistence-changes/historical-formats/README.md -README.md: f4e721bbef9d442021226c573280edc193792662 -README.zh.md: be4b7c86b68fa682e7f8275f85a2548f39f30df9 +README.md: a1064e815879d709fe466441bb4e8441c10b3aae +README.zh.md: 87c9d32fa6ca5248886dad663a926afc6983406f diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index f4e721bbef..a1064e8158 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 585 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 587 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index be4b7c86b6..87c9d32fa6 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 585 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 587 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 7456c002c0..59b8a86b4a 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -14850,7 +14850,7 @@ "kind": "event", "event": "subagent/catalog", "surface": false, - "digest": "4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3", + "digest": "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604", "schema": { "root": 0, "nodes": [ @@ -14864,7 +14864,7 @@ }, { "name": "ignorable", - "type": 11, + "type": 14, "optional": true }, { @@ -14879,7 +14879,7 @@ }, { "name": "type", - "type": 12, + "type": 15, "optional": false } ], @@ -14890,7 +14890,9 @@ "types": [ 2, 7, - 9 + 9, + 11, + 12 ] }, { @@ -14909,7 +14911,7 @@ { "name": "label", "type": 4, - "optional": true + "optional": false }, { "name": "mode", @@ -14934,7 +14936,7 @@ }, { "kind": "literal", - "value": "unknown" + "value": "continuable" }, { "kind": "literal", @@ -14960,12 +14962,12 @@ }, { "name": "mode", - "type": 8, + "type": 5, "optional": false }, { "name": "version", - "type": 6, + "type": 8, "optional": false } ], @@ -14973,7 +14975,7 @@ }, { "kind": "literal", - "value": "continuable" + "value": 1 }, { "kind": "object", @@ -15010,6 +15012,72 @@ "kind": "literal", "value": "one-shot" }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, { "kind": "literal", "value": true @@ -30979,6 +31047,213 @@ "names": [], "sources": [] }, + { + "digest": "1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "union", + "types": [ + 1, + 6, + 8, + 10, + 11 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 9, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 9, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 12, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + } + ] + }, + "names": [ + "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" + ], + "sources": [ + "packages/subagent/subagent/src/catalog.ts:28" + ] + }, { "digest": "1df34a6c086866ffdc2dd9e8e5c581c3751a010cdf6b8538a7327f7a3237f0b4", "schema": { @@ -33573,145 +33848,6 @@ "packages/core/tools/src/types.ts:11" ] }, - { - "digest": "30a49d1a89bd03b8503fbdab94d4883e4ce839d931f29672ac070007cc0016ce", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "union", - "types": [ - 1, - 6, - 8 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": false - }, - { - "name": "mode", - "type": 4, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "continuable" - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": true - }, - { - "name": "mode", - "type": 7, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": true - }, - { - "name": "mode", - "type": 9, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "unknown" - } - ] - }, - "names": [ - "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" - ], - "sources": [ - "packages/subagent/subagent/src/catalog.ts:24" - ] - }, { "digest": "30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f", "schema": { @@ -35422,6 +35558,248 @@ "packages/feedback/message-feedback/src/types.ts:17" ] }, + { + "digest": "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 14, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 7, + 9, + 11, + 12 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/catalog" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "3b04790e08b35241ffa0d38f6d7e4f8788aa921341f6c0d7574a329397c59408", "schema": { @@ -44255,180 +44633,6 @@ "packages/workflow/workflow/src/types.ts:13" ] }, - { - "digest": "4d6002c7eec8d76bbb6e531a35a55bc66dfb87621bc2e89e3af0641acf947bb3", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "data", - "type": 1, - "optional": false - }, - { - "name": "ignorable", - "type": 11, - "optional": true - }, - { - "name": "seq", - "type": 3, - "optional": false - }, - { - "name": "time", - "type": 3, - "optional": false - }, - { - "name": "type", - "type": 12, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "union", - "types": [ - 2, - 7, - 9 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": true - }, - { - "name": "mode", - "type": 5, - "optional": false - }, - { - "name": "version", - "type": 6, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "unknown" - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": false - }, - { - "name": "mode", - "type": 8, - "optional": false - }, - { - "name": "version", - "type": 6, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "continuable" - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": true - }, - { - "name": "mode", - "type": 10, - "optional": false - }, - { - "name": "version", - "type": 6, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "subagent/catalog" - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159", "schema": { @@ -68329,6 +68533,63 @@ "names": [], "sources": [] }, + { + "digest": "a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "a0f906856fc06f1c6f2083eb970e5a657a82b1a84c06c095445e5d6d161cda80", "schema": { @@ -71917,6 +72178,63 @@ "names": [], "sources": [] }, + { + "digest": "b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0", "schema": { @@ -73599,63 +73917,6 @@ "names": [], "sources": [] }, - { - "digest": "c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 1, - "optional": false - }, - { - "name": "childId", - "type": 2, - "optional": false - }, - { - "name": "label", - "type": 2, - "optional": true - }, - { - "name": "mode", - "type": 3, - "optional": false - }, - { - "name": "version", - "type": 4, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "unknown" - }, - { - "kind": "literal", - "value": 0 - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "c8e6f48878cc2a2f0dc59682280d81f407943fb503f32c64bf9ff3d9393c3eff", "schema": { @@ -81538,6 +81799,63 @@ "packages/schedule/schedule/src/types.ts:105" ] }, + { + "digest": "e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": false + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "e2d0367e5716bc188435e51718ebff8ce873190625fa93b1c5331102d98758fa", "schema": { diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index 41aef742aa..10a1a431e4 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/packages/sdk/client/tests/sdk-client.spec.ts @@ -648,7 +648,7 @@ describe('HarnessClient', () => { (client as unknown as { dispatchNotification(n: HarnessNotification): void }).dispatchNotification({ method, params }) } const unknownChild = { type: 'subagent/catalog', seq: 0, time: 1, - data: { version: 0, childId: 'unreadable-child', childCreatedAt: 1, mode: 'unknown' } } + data: { version: 1, childId: 'unreadable-child', childCreatedAt: 1, mode: 'unknown' } } inject('session.event', { sessionId: 'root', event: unknownChild }) inject('subagent.started', { parentSessionId: 'root', childSessionId: 'child' }) inject('subagent.started', { parentSessionId: 'child', childSessionId: 'grandchild' }) diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index b4b64bdc40..afd45897dc 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -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/session/session-format-v3-to-v4/README.md -README.md: 1c558d34cc67fc94aafcaf23405b0b1f2a0efa85 -README.zh.md: 6c079269503d5e11a0bbb03e3460d5a5432fa9e5 +README.md: 7a1b188194b54ff3ba7730388126fa0d2164fa06 +README.zh.md: 9d0f48ac375501d7dbd1e0b914bb882e43b02ac8 diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 1c558d34cc..7a1b188194 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -148,9 +148,9 @@ There is no recursive source search. Captured request text, assistant replay sta | More than one own descriptor | Retain an existing parent entry without mode/label comparison; otherwise append unknown-mode membership. | | Existing own parent entry | Retain it and its extensions; require matching child creation time and mode/label from exactly one supported own descriptor, when available. | | Missing own parent entry with complete supported evidence | Append a version-0 catalog fact with child id, creation time, mode, and optional label. | -| Missing own parent entry without complete evidence | Append `subagent/catalog` with header identity and unknown mode, without inventing a label. | +| Missing own parent entry without complete evidence | Append a version-1 `subagent/catalog` with header identity and unknown mode, without inventing a label. | -Catalog version 0 requires string `childId`, nonnegative safe-integer `childCreatedAt`, mode `continuable`, `one-shot`, or `unknown`, and a string label for continuable mode; a present label in any mode must also be a string. Duplicate own child ids are refused. Existing entries without a corresponding retained child remain in the parent. Descriptor collection does not restore a child's old continuation composition or recover deleted children from tool arguments. Unknown-mode entries retain header identity without asserting a supported child descriptor. +Catalog versions 0 and 1 require string `childId`, nonnegative safe-integer `childCreatedAt`, mode `continuable` or `one-shot` (version 1 additionally accepts `unknown`), and a string label for continuable mode; a present label in any mode must also be a string. Duplicate own child ids are refused. Existing entries without a corresponding retained child remain in the parent. Descriptor collection does not restore a child's old continuation composition or recover deleted children from tool arguments. Unknown-mode entries retain header identity without asserting a supported child descriptor. The stage considers parent catalog records only after the final inherited cut. Every inherited marker discards earlier catalog candidates without interpreting their payloads. Missing entries append after all source events, sorted by creation time then child id, with dense new sequences. Their time is the final source event's time, or header creation time for an empty log. They neither enter the model surface nor change the inherited count. @@ -243,7 +243,7 @@ Current common admission does not validate each user/tool/developer content bloc | `compaction/start`, `compaction/summary`, `compaction/end` | Match compaction id, source command, and the active turn context. Summary spans name exact current-surface nodes and exclude the protected head; successful completion has one summary. Inherited unfinished compactions expire at the end-seed marker. | | `compaction/prune` | Its span names exact current-surface nodes and excludes the protected head; it does not require a compaction transaction or its owner fields. | | Compact checkpoint replacement | Its `compact-checkpoint` source identifies the active compaction. | -| Native `subagent/catalog` | Check own version-0 payload fields and unique child ids after the inherited cut. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. | +| Native `subagent/catalog` | Check own version-0/version-1 payload fields and unique child ids after the inherited cut. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. | | Inherited cut and delivery | Apply the marker, coordinate, and generation-ownership rules stated above. | These checks are generation-owned in [relationships.ts](src/relationships.ts). Full common message/envelope acceptance and plugin-owned message projections additionally use the installed Session; the exported V4 restorer alone is not a replacement for complete catalog restoration. diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index 6c07926950..9d0f48ac37 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -148,9 +148,9 @@ V3 未知内容标签变为 `plugin:`,其他字段原样保留 | 多个自身 descriptor | 保留已有父目录项,不比较 mode/label;否则追加模式未知的目录项。 | | 已有自身父目录项 | 保留条目及其扩展;要求子创建时间一致,并在恰好一个受支持的自身 descriptor 可用时比较其 mode/label。 | | 缺少自身父目录项,且受支持证据完整 | 追加带 child id、创建时间、mode 和可选 label 的 version-0 目录事实。 | -| 缺少自身父目录项,且证据不完整 | 追加 `subagent/catalog`,记录 header 身份和未知模式,不编造标签。 | +| 缺少自身父目录项,且证据不完整 | 追加 version-1 `subagent/catalog`,记录 header 身份和未知模式,不编造标签。 | -Catalog version 0 要求字符串 `childId`、非负安全整数 `childCreatedAt`、`continuable`、`one-shot` 或 `unknown` mode,以及 continuable mode 下的字符串 label;任何 mode 下存在的 label 都必须是字符串。重复的自身 child id 被拒绝。没有对应保留子日志的已有条目仍保留在父日志中。Descriptor 收集不恢复子级的旧 continuation composition,也不从工具参数恢复已删除子级。未知模式条目保留 header 身份信息,不声明子 descriptor 受支持。 +Catalog version 0 和 1 要求字符串 `childId`、非负安全整数 `childCreatedAt`、`continuable` 或 `one-shot` mode(version 1 还接受 `unknown`),以及 continuable mode 下的字符串 label;任何 mode 下存在的 label 都必须是字符串。重复的自身 child id 被拒绝。没有对应保留子日志的已有条目仍保留在父日志中。Descriptor 收集不恢复子级的旧 continuation composition,也不从工具参数恢复已删除子级。未知模式条目保留 header 身份信息,不声明子 descriptor 受支持。 Stage 只把最终继承截点之后的父目录记录作为候选。每个 inherited marker 都会丢弃更早的目录候选,不解释其载荷。缺失项追加在所有源事件之后,按创建时间、child id 排序,并使用连续的新序号。时间取最后一个源事件的 time;空日志则取 header 创建时间。这些记录既不进入模型表面,也不改变继承计数。 @@ -243,7 +243,7 @@ System image 接纳要求非空 attachment id、PNG/JPEG/WebP/GIF MIME 类 | `compaction/start`、`compaction/summary`、`compaction/end` | 匹配 compaction id、源 command 和活动 turn 上下文。Summary 区间引用精确的当前表面节点且排除 protected head;成功完成需要一个 summary。继承的未完成 compaction 在 end-seed marker 处过期。 | | `compaction/prune` | 其区间引用精确的当前表面节点且排除 protected head;它不要求存在 compaction 事务或其所有者字段。 | | Compact checkpoint 替换 | 其 `compact-checkpoint` 来源标识活动 compaction。 | -| 原生 `subagent/catalog` | 检查继承截点之后的自身 version-0 载荷字段与 child id 唯一性。原生读取既不收集子日志,也不比较其物理事实;继承项不建立自身成员关系。 | +| 原生 `subagent/catalog` | 检查继承截点之后的自身 version-0/version-1 载荷字段与 child id 唯一性。原生读取既不收集子日志,也不比较其物理事实;继承项不建立自身成员关系。 | | 继承截点与 delivery | 应用上文的 marker、坐标及代际归属规则。 | 这些检查由 [relationships.ts](src/relationships.ts) 按代际拥有。完整的通用消息/信封接纳与插件拥有的消息投影还使用已安装 Session;单独的导出 V4 恢复器不能替代完整 catalog 恢复。 diff --git a/packages/session/session-format-v3-to-v4/src/facts.ts b/packages/session/session-format-v3-to-v4/src/facts.ts index 2fb476377b..aa99fd32fa 100644 --- a/packages/session/session-format-v3-to-v4/src/facts.ts +++ b/packages/session/session-format-v3-to-v4/src/facts.ts @@ -70,12 +70,13 @@ export function childCatalogFact(source: SessionFormatJsonObject): SessionFormat * @returns validated catalog payload. */ export function catalogFact(value: SessionFormatJsonValue, subject = 'subagent/catalog'): SessionFormatJsonObject { - if (!isSessionFormatJsonObject(value) || value['version'] !== 0 + if (!isSessionFormatJsonObject(value) || (value['version'] !== 0 && value['version'] !== 1) || typeof value['childId'] !== 'string' || (value['mode'] !== 'continuable' && value['mode'] !== 'one-shot' && value['mode'] !== 'unknown') + || (value['version'] === 0 && value['mode'] === 'unknown') || (value['mode'] === 'continuable' && typeof value['label'] !== 'string') || (value['label'] !== undefined && typeof value['label'] !== 'string')) { - throw new SessionFormatError(`${subject} requires a version 0 catalog fact`) + throw new SessionFormatError(`${subject} requires a supported versioned catalog fact`) } sessionFormatCount(value['childCreatedAt'], 'catalog child creation time') return value diff --git a/packages/session/session-format-v3-to-v4/src/migration.ts b/packages/session/session-format-v3-to-v4/src/migration.ts index 50c796a1df..c2a28e04b2 100644 --- a/packages/session/session-format-v3-to-v4/src/migration.ts +++ b/packages/session/session-format-v3-to-v4/src/migration.ts @@ -122,7 +122,7 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage { } continue } - const entry = fact ?? { version: 0, childId: id, childCreatedAt: source['childCreatedAt'] as number, mode: 'unknown' } + const entry = fact ?? { version: 1, childId: id, childCreatedAt: source['childCreatedAt'] as number, mode: 'unknown' } existingCatalogs.set(id, entry) context.emitEvent({ type: 'subagent/catalog', seq: this.nextSeq++, time: this.time, data: entry }) diff --git a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts index f533b9d502..455535ed79 100644 --- a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts @@ -67,14 +67,14 @@ describe('V3 parent catalog completion', () => { expect(migrate(source, [child], true).events).toEqual([ ...source, { type: 'subagent/catalog', seq: 4, time: 2, data: child }, ]) - expect(() => migrate([...source, { ...opaque, seq: 4 }], [], true)).toThrow('version 0') - expect(() => migrate([opaque], [])).toThrow('version 0') + expect(() => migrate([...source, { ...opaque, seq: 4 }], [], true)).toThrow('supported versioned') + expect(() => migrate([opaque], [])).toThrow('supported versioned') }) it('requires explicit corpus facts and refuses conflicting identities', () => { expect(() => sessionFormatV3ToV4.createStage({ sourceHeader: header, targetHeader: { ...header, version: 4 }, sourceKind: 'decoded', sourceInheritedEventCount: 0 })).toThrow('explicit historical child facts') - expect(() => migrate([], [{ ...child, mode: 'invalid' }])).toThrow('version 0') + expect(() => migrate([], [{ ...child, mode: 'invalid' }])).toThrow('supported versioned') expect(() => migrate([{ type: 'subagent/catalog', seq: 0, time: 2, data: child }], [{ ...child, childCreatedAt: 3 }])).toThrow('conflicts') }) @@ -120,7 +120,7 @@ describe('V3 parent catalog completion', () => { for (const data of [null, {}, { version: 4, mode: 'one-shot', provider: 'spawn' }]) { expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [{ type: 'subagent/descriptor', seq: 0, time: 1, data }] })]).events).toMatchObject([{ data: { childId: 'parent', mode: 'unknown' } }]) } - for (const value of [null, { ...child, version: 1 }, { ...child, childId: null }, + for (const value of [null, { ...child, version: 2 }, { ...child, childId: null }, { ...child, label: null }, { ...child, childCreatedAt: -1 }]) { expect(() => migrate([], [value])).toThrow() } @@ -148,7 +148,7 @@ describe('V3 parent catalog completion', () => { const unavailable = { childId: 'unavailable', childCreatedAt: 1, descriptorCount: kind === 'missing' ? 0 : kind === 'multiple' ? 2 : 1, descriptor } expect(migrate([], [child, unavailable]).events).toEqual([ - { type: 'subagent/catalog', seq: 0, time: 1, data: { version: 0, childId: 'unavailable', childCreatedAt: 1, mode: 'unknown' } }, + { type: 'subagent/catalog', seq: 0, time: 1, data: { version: 1, childId: 'unavailable', childCreatedAt: 1, mode: 'unknown' } }, { type: 'subagent/catalog', seq: 1, time: 1, data: child }, ]) }) diff --git a/packages/session/session-format-v3-to-v4/tests/validation.spec.ts b/packages/session/session-format-v3-to-v4/tests/validation.spec.ts index 7bd8b24539..7142c6ab45 100644 --- a/packages/session/session-format-v3-to-v4/tests/validation.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/validation.spec.ts @@ -115,12 +115,12 @@ describe('restoreReleasedV4Artifact', () => { it('retains unknown child membership and rejects invalid identities or duplicate child ids', () => { const known = new Set([...KNOWN, 'subagent/catalog']) - const data = { version: 0, childId: 'unreadable', childCreatedAt: 2, mode: 'unknown' } + const data = { version: 1, childId: 'unreadable', childCreatedAt: 2, mode: 'unknown' } const event = { type: 'subagent/catalog', seq: 0, time: 1, data } const valid = artifact([event]) expect(restoreReleasedV4Artifact(valid, known)).toBe(valid) expect(() => restoreReleasedV4Artifact(artifact([{ ...event, data: { ...data, label: 'retained' } }]), known)).not.toThrow() - for (const invalid of [null, { ...data, version: 1 }, { ...data, childId: 1 }, + for (const invalid of [null, { ...data, version: 0 }, { ...data, version: 2 }, { ...data, childId: 1 }, { ...data, childCreatedAt: -1 }, { ...data, mode: 'invalid' }, { ...data, label: null }]) { expect(() => restoreReleasedV4Artifact(artifact([{ ...event, data: invalid }]), known)).toThrow() } diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 25cea24568..fb26e9f540 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -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/subagent/subagent/README.md -README.md: 75237c6eda835459015d5b2f64402190d27d72af -README.zh.md: 187d26ea3a4261c4ddeed96cd6c41f50fbd48c91 +README.md: 4ed03104d3d8ab70816ee0cff7334a253196aef2 +README.zh.md: 92acf7498f282cdefea4f440d59e17f6dfffc81a diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 75237c6eda..4ed03104d3 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -109,7 +109,7 @@ A request is validated against the provider's advertised capabilities, a durable The manager reserves a child identity, resolves the durable descriptor, creates (or cold-resumes) the child Agent, installs it in an Activation, and submits the prompt. Model-authored messages cross one parent/child edge through fixed Steer scheduling; browser human prompts choose Queue or best-effort Steer through an internal adapter, while other host protocols may retain Queue for distinct turns. A Session queue command admits a live subagent-owned Agent only from its own continuable descriptor. Settlement waits for Agent activity to finish, an empty Inbox, and no owned children, then flushes final Session state with admission open. Under the child lock, the manager revalidates the wake generation, Session sequence, Inbox, and owned children; the synchronous task entry of `Agent.runMaintenance()` claims the idle phase and closes the private subagent Inbox in the same JavaScript turn before handle disposal. An absent direct-child Activation cold-resumes from the persisted session. When a resident Activation settles, the manager tells the child's direct parent in the parent's own turn stream. -Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Each child's `subagentTiming` projection accumulates post-descriptor duration and records whether its latest closed turn ended with `completed`, clearing that completion when another turn opens. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Projection state-version changes refold cached rows from the durable log. The catalog view preserves parent event order in O(D) time for D facts, and its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives. Historical migration may instead append `subagent/catalog` from a readable child header. Its `mode: 'unknown'` projection keeps the child visible without claiming continuation support; an existing complete entry remains authoritative. +Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Each child's `subagentTiming` projection accumulates post-descriptor duration and records whether its latest closed turn ended with `completed`, clearing that completion when another turn opens. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Projection state-version changes refold cached rows from the durable log. The catalog view preserves parent event order in O(D) time for D facts, and its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives. Catalog payload v0 records known modes; v1 also accepts unknown mode. Readers support both versions. Historical migration appends a v1 `subagent/catalog` from a readable child header when its descriptor is unavailable; normal creation retains v0. Its `mode: 'unknown'` projection keeps the child visible without claiming continuation support; an existing complete entry remains authoritative. ### Ownership and invariants diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 187d26ea3a..92acf7498f 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -109,7 +109,7 @@ kind: "package-reference" 管理器预留 child 身份、解析持久化描述符、创建(或冷恢复)child、把它安装进 Activation 并提交提示词。模型编写的消息通过固定 Steer 调度跨一条 parent/child 边;浏览器人类 prompt 通过内部适配器选择 Queue 或 best-effort Steer,其他 host 协议仍可保留 Queue 以创建独立轮次。Session queue command 仅根据 child 自身的 continuable descriptor 准入在线 subagent-owned Agent。Settlement 会等待 Agent 活动结束、Inbox 为空且没有所拥有子级,再在准入开放时 flush 最终 Session 状态。管理器随后在 child lock 内重新验证 wake generation、Session 序号、Inbox 与所拥有子级;`Agent.runMaintenance()` 的同步 task 入口会占用 idle 阶段,并在同一个 JavaScript turn 内关闭私有 subagent Inbox,然后才释放句柄。直接 child 不存在 Activation 时会从持久化会话冷恢复。当驻留 Activation 结算时,管理器会在 parent 自身的轮次流中告知该 child 的直接 parent。 -本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在提供方返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。每个 child 的 `subagentTiming` projection 会累加 descriptor 之后的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次打开时会清除该完成状态。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。projection 状态版本变更会从持久日志重新折叠缓存行。目录视图对 D 条事实以 O(D) 时间保留父目录事件顺序,其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。 历史迁移可根据可读子 header 追加 `subagent/catalog`。其 `mode: 'unknown'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。 +本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在提供方返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。每个 child 的 `subagentTiming` projection 会累加 descriptor 之后的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次打开时会清除该完成状态。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。projection 状态版本变更会从持久日志重新折叠缓存行。目录视图对 D 条事实以 O(D) 时间保留父目录事件顺序,其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。Catalog 载荷 v0 记录已知模式,v1 还接受未知模式,读取器支持两版。历史迁移在 descriptor 不可用时根据可读子 header 追加 v1 `subagent/catalog`;正常创建保留 v0。其 `mode: 'unknown'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。 ### 所有权与不变式 diff --git a/packages/subagent/subagent/src/catalog.ts b/packages/subagent/subagent/src/catalog.ts index 15ec24dd17..81edf47fb6 100644 --- a/packages/subagent/subagent/src/catalog.ts +++ b/packages/subagent/subagent/src/catalog.ts @@ -17,19 +17,21 @@ import type { import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { SubagentCatalogEntry } from './projection-types.ts' -/** Current payload version for `subagent/catalog` events. */ +/** Catalog payload version emitted by live child creation. */ export const SUBAGENT_CATALOG_VERSION = 0 -/** One parent-owned catalog fact with known or unknown child mode. */ +type KnownCatalogMode = + | { readonly mode: 'one-shot'; readonly label?: string } + | { readonly mode: 'continuable'; readonly label: string } + +/** Parent catalog v0 records known modes; v1 also retains children with unknown mode. */ export type SubagentCatalogEvent = & { - readonly version: 0 readonly childId: SessionId readonly childCreatedAt: number } & ( - | { readonly mode: 'one-shot'; readonly label?: string } - | { readonly mode: 'continuable'; readonly label: string } - | { readonly mode: 'unknown'; readonly label?: string } + | ({ readonly version: 0 } & KnownCatalogMode) + | ({ readonly version: 1 } & (KnownCatalogMode | { readonly mode: 'unknown'; readonly label?: string })) ) declare module '@deepseek-ai/dsh-session/types' { @@ -50,25 +52,25 @@ export interface SubagentCatalogState { const sessionIdSchema = z.string() as unknown as z.ZodType const oneShotCatalogSchema = z.object({ - version: z.literal(SUBAGENT_CATALOG_VERSION), + version: z.union([z.literal(0), z.literal(1)]), childId: sessionIdSchema, childCreatedAt: z.number().int().nonnegative(), mode: z.literal('one-shot'), label: z.string().optional(), }).strict() const continuableCatalogSchema = z.object({ - version: z.literal(SUBAGENT_CATALOG_VERSION), + version: z.union([z.literal(0), z.literal(1)]), childId: sessionIdSchema, childCreatedAt: z.number().int().nonnegative(), mode: z.literal('continuable'), label: z.string(), }).strict() -const unknownCatalogSchema = oneShotCatalogSchema.extend({ mode: z.literal('unknown') }) +const unknownCatalogSchema = oneShotCatalogSchema.extend({ version: z.literal(1), mode: z.literal('unknown') }) const eventDataSchema = z.union([ oneShotCatalogSchema, continuableCatalogSchema, unknownCatalogSchema, -]) as unknown as z.ZodType +]) as z.ZodType const viewSchema = z.array(z.union([ oneShotCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({ id: sessionIdSchema, @@ -82,7 +84,7 @@ const viewSchema = z.array(z.union([ id: sessionIdSchema, createdAt: unknownCatalogSchema.shape.childCreatedAt, }), -])) as unknown as z.ZodType +])) as z.ZodType const stateSchema: z.ZodType = z.object({ inheritedEventCount: z.number().int().nonnegative() as unknown as z.ZodType, head: chunkedListSchema(eventDataSchema).optional(), diff --git a/packages/subagent/subagent/tests/catalog.spec.ts b/packages/subagent/subagent/tests/catalog.spec.ts index 589e336c88..ffc95e1606 100644 --- a/packages/subagent/subagent/tests/catalog.spec.ts +++ b/packages/subagent/subagent/tests/catalog.spec.ts @@ -102,13 +102,26 @@ describe('subagent catalog projection', () => { .toEqual(Array.from({ length: 130 }, (_, i) => SessionId(`child-${i}`))) }) + it.each([0, 1] as const)('reads known catalog modes at payload version %s', (version) => { + const events: SessionEvent[] = [ + { type: 'subagent/catalog', seq: SessionSeq(0), time: 0, + data: { version, childId: SessionId('once'), childCreatedAt: 1, mode: 'one-shot' } }, + { type: 'subagent/catalog', seq: SessionSeq(1), time: 0, + data: { version, childId: SessionId('ongoing'), childCreatedAt: 2, mode: 'continuable', label: 'child' } }, + ] + expect(subagentCatalogProjectionDefinition.wire.view(fold(events))).toEqual([ + { id: SessionId('once'), createdAt: 1, mode: 'one-shot' }, + { id: SessionId('ongoing'), createdAt: 2, mode: 'continuable', label: 'child' }, + ]) + }) + it('validates recursive chunk state and materializes every label variant in event order', () => { const events: SessionEvent[] = [ fact(0, 'child-b', 1, { mode: 'one-shot' }), fact(1, 'child-a', 1, { mode: 'one-shot', label: 'once' }), fact(2, 'child-d', 3, { mode: 'continuable', label: 'later' }), { type: 'subagent/catalog', seq: SessionSeq(3), time: 0, - data: { version: 0, childId: SessionId('unreadable'), childCreatedAt: 4, mode: 'unknown' } }, + data: { version: 1, childId: SessionId('unreadable'), childCreatedAt: 4, mode: 'unknown' } }, ] const state = fold(events) @@ -143,6 +156,7 @@ describe('subagent catalog projection', () => { }) it.each([ + { version: 0, childId: 'child', childCreatedAt: 0, mode: 'unknown' }, { version: 9, childId: 'child', childCreatedAt: 0, mode: 'one-shot' }, { version: 0, childId: 'child', childCreatedAt: 0, mode: 'continuable' }, { version: 0, childId: 'child', childCreatedAt: -1, mode: 'one-shot' }, diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 78178c1e18..66b9b2665c 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -597,7 +597,7 @@ def test_session_subscription_keeps_descendant_relationships_across_subscription with client.subscribe_session_notifications("main") as first: unknown_child = { "type": "subagent/catalog", "seq": 0, "time": 1, - "data": {"version": 0, "childId": "unreadable-child", "childCreatedAt": 1, "mode": "unknown"}, + "data": {"version": 1, "childId": "unreadable-child", "childCreatedAt": 1, "mode": "unknown"}, } client._handle_message({ "jsonrpc": "2.0", "method": "session.event", diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index f5ecfea159..c2e5867d75 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -2131,9 +2131,7 @@ "b50b6842b3565ea7f1441afb3214ab0ccf027df0e44bb485005935849c46f8ea": 1 }, "packages/subagent/subagent/src/catalog.ts": { - "29a67bf48723006b65c652af8a0ff30a4385bb14d651d91ca3059e1ddcb44fcf": 1, "3e704e331ed2fc5d4c0855e2455b31b82924c525f2e267416e393cdbd36c251b": 1, - "c3a7c8abe19131a8d98f4fb6a495b590e67a56cbf2e741caccfe730f8261edb0": 1, "fdc5fcade0ba1abbc426ccccb07a1ad4998253799b8561815047d95178441cdf": 1 }, "packages/subagent/subagent/src/internal.ts": { diff --git a/scripts/persistence-changes.spec.ts b/scripts/persistence-changes.spec.ts index 0a3be90d70..ab6f0e50e3 100644 --- a/scripts/persistence-changes.spec.ts +++ b/scripts/persistence-changes.spec.ts @@ -397,58 +397,61 @@ function unionBody(arms: readonly (readonly SchemaProperty[])[]): PersistenceRoo return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) } } -function catalogModeRoot(unknownMode?: string, changeLabel = false, extraField = false): PersistenceRoot { - const snapshot = parsePersistenceSnapshot(JSON.parse(readFileSync( - new URL('../docs/persistence-changes/2026-09-11-initial.schema.json', import.meta.url), 'utf8', - ))) - const root = snapshot.roots.find(root => root.key === 'event:subagent/catalog')! - const nodes: SchemaNode[] = [...structuredClone(root.schema.nodes)] - const union = nodes.find(node => node.kind === 'union')! - if (union.kind !== 'union') throw new Error('catalog fixture requires mode alternatives') - const oneShot = union.types.find((index) => { - const node = nodes[index] - if (node?.kind !== 'object') return false - const mode = nodes[node.properties.find(property => property.name === 'mode')!.type] - return mode?.kind === 'literal' && mode.value === 'one-shot' - })! - const variant = nodes[oneShot]! - if (variant.kind !== 'object') throw new Error('catalog fixture requires object alternatives') - if (unknownMode !== undefined) { +function versionedEvent(variants: readonly { version: number; mode: string; extra?: boolean }[]): PersistenceRoot { + const nodes: SchemaNode[] = [ + { kind: 'object', indices: [], properties: [ + { name: 'data', type: variants.length === 1 ? 3 : 1, optional: false }, + { name: 'type', type: 3 + variants.length * 3, optional: false }, + ] }, + { kind: 'union', types: variants.map((_, index) => 3 + index * 3) }, + { kind: 'primitive', type: 'string' }, + ] + for (const variant of variants) { const index = nodes.length - nodes.push({ ...variant, properties: variant.properties.map(property => property.name === 'mode' - ? { ...property, type: index + 1 } : property) }, { kind: 'literal', value: unknownMode }) - const added = nodes[index]! - if (extraField && added.kind === 'object') nodes[index] = { ...added, - properties: [...added.properties, { name: 'extra', type: variant.properties[0]!.type, optional: false }] } - nodes[nodes.indexOf(union)] = { ...union, types: [...union.types, index] } + nodes.push({ kind: 'object', indices: [], properties: [ + { name: 'version', type: index + 1, optional: false }, + { name: 'mode', type: index + 2, optional: false }, + ...(variant.extra ? [{ name: 'extra', type: 2, optional: false }] : []), + ] }, { kind: 'literal', value: variant.version }, { kind: 'literal', value: variant.mode }) } - if (changeLabel) nodes[oneShot] = { ...variant, properties: variant.properties.filter(property => property.name !== 'label') } + nodes.push({ kind: 'literal', value: 'example/value' }) const schema = canonicalizeSchema(nodes, 0) - return { ...root, schema, digest: schemaDigest(schema) } + return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) } } describe('persistence change classification', () => { - it('acknowledges unknown catalog mode at V4 while retaining the accepted checkpoint', () => { + it.each([1, 2])('acknowledges a higher payload version while preserving %s old variant(s) and the V4 checkpoint', (count) => { const root = fixture() - const before = { ...inventory({}, 4), roots: [...inventory({}, 4).roots.filter(root => root.kind !== 'event'), catalogModeRoot()] } + const variants = [{ version: 0, mode: 'one-shot' }, { version: 0, mode: 'continuable' }].slice(0, count) + const oldEvent = versionedEvent(variants) + const nextEvent = versionedEvent([...variants, { version: 1, mode: 'unknown', extra: true }]) + const before = { ...inventory({}, 4), roots: [...inventory({}, 4).roots.filter(root => root.kind !== 'event'), oldEvent] } finalize(root, before) - const after = { ...before, roots: before.roots.map(root => root.kind === 'event' ? catalogModeRoot('unknown') : root) } - expect(classifyPersistenceChange(catalogModeRoot(), catalogModeRoot('unknown'))) - .toEqual([expect.objectContaining({ path: 'event:subagent/catalog.data', requiresVersionBump: false })]) + const after = { ...before, roots: before.roots.map(root => root.kind === 'event' ? nextEvent : root) } + expect(classifyPersistenceChange(oldEvent, nextEvent)) + .toEqual([expect.objectContaining({ kind: 'payload-version-added', requiresVersionBump: false })]) runPersistenceChanges(['--record', COMPATIBLE_ID, '--prose', proseFile(root)], root, () => after) verifyPersistenceChanges(root, after) }) - it('keeps other catalog modes, altered fields, classification changes, and removals breaking', () => { - const before = catalogModeRoot() - for (const after of [catalogModeRoot('other'), catalogModeRoot('unknown', true), catalogModeRoot('unknown', false, true)]) { - expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true) - } - const expanded = catalogModeRoot('unknown') + it('rejects changes to old payloads, missing versions, non-increasing versions, and removal of old readers', () => { + const variants = [{ version: 0, mode: 'one-shot' }, { version: 0, mode: 'continuable' }] + const before = versionedEvent(variants) + for (const after of [ + versionedEvent([...variants, { version: 0, mode: 'unknown' }]), + versionedEvent([...variants, { version: -1, mode: 'unknown' }]), + versionedEvent([...variants, { version: 0.5, mode: 'unknown' }]), + versionedEvent([{ version: 0, mode: 'one-shot', extra: true }, variants[1]!, { version: 1, mode: 'unknown' }]), + versionedEvent([{ version: 1, mode: 'unknown' }]), + ]) expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true) + const expanded = versionedEvent([...variants, { version: 1, mode: 'unknown' }]) expect(classifyPersistenceChange(expanded, before).some(change => change.requiresVersionBump)).toBe(true) - expect(classifyPersistenceChange(before, { ...expanded, surface: true }).some(change => change.requiresVersionBump)).toBe(true) - expect(classifyPersistenceChange({ ...before, key: 'event:other' }, { ...expanded, key: 'event:other' }) + expect(classifyPersistenceChange({ ...before, surface: true }, { ...expanded, surface: true }) .some(change => change.requiresVersionBump)).toBe(true) + expect(classifyPersistenceChange({ ...before, kind: 'header' }, { ...expanded, kind: 'header' }) + .some(change => change.requiresVersionBump)).toBe(true) + const unversioned = unionBody([[{ name: 'mode', type: 2, optional: false }]]) + expect(classifyPersistenceChange(unversioned, expanded).some(change => change.requiresVersionBump)).toBe(true) }) it('treats a new optional payload subtree as one additive change even with required descendants', () => { diff --git a/scripts/persistence-changes.ts b/scripts/persistence-changes.ts index e53092e921..d1f38225a9 100644 --- a/scripts/persistence-changes.ts +++ b/scripts/persistence-changes.ts @@ -66,6 +66,7 @@ const CHANGE_DESCRIPTIONS = { 'index-signature-changed': 'index signature changed', 'tuple-length-changed': 'tuple length changed', 'tuple-element-cardinality-changed': 'tuple element cardinality changed', + 'payload-version-added': 'event payload version added', 'union-variants-changed': 'union variants changed', 'source-policy-changed': 'source compatibility policy changed', 'attribution-kind-added': 'attribution-only source kind added', @@ -405,6 +406,27 @@ export function classifyPersistenceChange(before: PersistenceRoot | null, after: } const oldNode = oldRoot.schema.nodes[oldIndex] as SchemaNode const newNode = newRoot.schema.nodes[newIndex] as SchemaNode + if (oldRoot.kind === 'event' && oldRoot.surface === false && scope === 'body' && path === `${key}.data`) { + const oldTypes = oldNode.kind === 'union' ? oldNode.types : [oldIndex] + const newTypes = newNode.kind === 'union' ? newNode.types : [newIndex] + const payloadVersion = (schema: CanonicalSchema, index: number): number | undefined => { + const node = schema.nodes[index] + if (node?.kind !== 'object') return undefined + const property = node.properties.find(property => property.name === 'version' && !property.optional) + const version = schema.nodes[property?.type ?? -1] + return version?.kind === 'literal' && typeof version.value === 'number' + && Number.isSafeInteger(version.value) && version.value >= 0 ? version.value : undefined + } + const versions = oldTypes.map(index => payloadVersion(oldRoot.schema, index)) + const oldHashes = new Set(oldTypes.map(index => fingerprint(oldRoot.schema, index, 0))) + const newHashes = new Set(newTypes.map(index => fingerprint(newRoot.schema, index, 1))) + const added = newTypes.filter(index => !oldHashes.has(fingerprint(newRoot.schema, index, 1))) + if (versions.every(version => version !== undefined) && added.length > 0 + && [...oldHashes].every(hash => newHashes.has(hash)) + && added.every(index => (payloadVersion(newRoot.schema, index) ?? -1) > Math.max(...versions))) { + return [describe(path, 'payload-version-added', false)] + } + } if (oldNode.kind !== newNode.kind) return [describe(path, 'type-changed')] if (oldNode.kind === 'object' && newNode.kind === 'object') { const oldProps = new Map(oldNode.properties.map(property => [property.name, property])) @@ -471,29 +493,6 @@ export function classifyPersistenceChange(before: PersistenceRoot | null, after: function compareAlternatives( oldTypes: readonly number[], newTypes: readonly number[], path: string, scope: Scope, active: ReadonlySet, ): PersistenceTypeChange[] { - if (key === 'event:subagent/catalog' && path === `${key}.data` && scope === 'body' - && oldTypes.length === 2 && newTypes.length === 3) { - const modeNode = (index: number): number | undefined => { - const node = newRoot.schema.nodes[index] - return node?.kind === 'object' ? node.properties.find(property => property.name === 'mode' && !property.optional)?.type : undefined - } - const mode = (index: number): string | number | boolean | null | undefined => { - const node = newRoot.schema.nodes[modeNode(index) ?? -1] - return node?.kind === 'literal' ? node.value : undefined - } - const unknown = newTypes.find(index => mode(index) === 'unknown') - const oneShot = newTypes.find(index => mode(index) === 'one-shot') - if (unknown !== undefined && oneShot !== undefined) { - // The catalog exception admits only the one-shot fields with unknown mode; - // existing branches still pass the ordinary structural comparison below. - const normalized = { ...newRoot.schema, nodes: newRoot.schema.nodes.map((node, index) => - index === modeNode(unknown) ? { kind: 'literal' as const, value: 'one-shot' } : node) } - if (subDigest(normalized, unknown) === fingerprint(newRoot.schema, oneShot, 1)) { - return [describe(path, 'union-variants-changed', false), - ...compareAlternatives(oldTypes, newTypes.filter(index => index !== unknown), path, scope, active)] - } - } - } if (oldTypes.length !== newTypes.length) return [describe(path, 'union-variants-changed')] const candidates = oldTypes.map(oldType => newTypes.map(newType => compare(oldType, newType, path, scope, active))) const matching = matchUnionVariants(candidates.map(row => row.flatMap((candidate, index) => From 21ec132c0e1dd1e258de5d3e03f819748442f047 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 20 Sep 2026 15:15:43 +0800 Subject: [PATCH 157/284] test(preview): expect versioned unknown child catalog entries --- .../webworker-packer/tests/preview.spec.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/experimental/webworker-packer/tests/preview.spec.ts b/packages/experimental/webworker-packer/tests/preview.spec.ts index 2c5c381059..f3b5937ab9 100644 --- a/packages/experimental/webworker-packer/tests/preview.spec.ts +++ b/packages/experimental/webworker-packer/tests/preview.spec.ts @@ -65,7 +65,7 @@ describe('Node preparation of Preview Session data', () => { expect(packVfsOverlay(fixture.trees).files).toEqual(original.files) }) - it.each(['missing-descriptor', 'other-root', 'other-origin', 'current-child'] as const)('adds catalog facts only for supported direct subagent children in the same root: %s', (mode) => { + it.each(['missing-descriptor', 'other-root', 'other-origin', 'current-child'] as const)('catalogs direct subagent children in the same root, retaining missing descriptors as unknown: %s', (mode) => { const fixture = source() const parent = fixture.put(3) const childDirectory = join(fixture.trees[0]!.directory, 'sessions', mode === 'other-root' ? 'other' : 'project', 'child') @@ -84,8 +84,12 @@ describe('Node preparation of Preview Session data', () => { const packed = packPreviewFixture(fixture.trees) const restored = restore(packed.files[`home/sessions/project/example/${sessionFormatLogFilename(currentVersion)}`]) expect(restored.inheritedEventCount).toBe(0) - expect(restored.events).toEqual(mode === 'current-child' ? [{ type: 'subagent/catalog', seq: 0, time: 1, - data: { version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot' } }] : []) + const catalog = mode === 'current-child' + ? [{ version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot' }] + : mode === 'missing-descriptor' + ? [{ version: 1, childId: 'child', childCreatedAt: 2, mode: 'unknown' }] + : [] + expect(restored.events).toEqual(catalog.map(data => ({ type: 'subagent/catalog', seq: 0, time: 1, data }))) for (const [path, bytes] of Object.entries(original.files)) expect(packed.files[path], path).toEqual(bytes) expect(readFileSync(parent)).toEqual(before) expect(readFileSync(childPath)).toEqual(childBefore) From 69ac1490903ba4fd168c648a7d714496006c279b Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:20:04 +0800 Subject: [PATCH 158/284] fix(desktop): isolate mandatory update actions and handle main-window lifecycle --- ...indows-embedded-mandatory-update.i18n.yaml | 6 ++ ...09-20-windows-embedded-mandatory-update.md | 25 +++++++ ...20-windows-embedded-mandatory-update.zh.md | 25 +++++++ ...-desktop-mandatory-update-client.i18n.yaml | 4 +- ...6-09-11-desktop-mandatory-update-client.md | 6 +- ...9-11-desktop-mandatory-update-client.zh.md | 6 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- .../renderer/mandatory-update-frame.js | 14 ++-- apps/desktop/src/main.ts | 2 +- apps/desktop/src/mandatory-update-window.ts | 31 +++++++-- apps/desktop/src/preload-mandatory-overlay.ts | 42 ++++++++---- .../tests/fixtures/mandatory-frame.mjs | 3 +- .../tests/fixtures/workspace-updates.mjs | 3 +- apps/desktop/tests/main-startup.spec.ts | 35 ++++++---- .../tests/mandatory-update-window.spec.ts | 21 ++++++ apps/desktop/tests/preload-app.spec.ts | 14 +++- .../tests/preload-mandatory-overlay.spec.ts | 66 +++++++++++++++---- 19 files changed, 246 insertions(+), 65 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.i18n.yaml new file mode 100644 index 0000000000..259202f1a0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.md +2026-09-20-windows-embedded-mandatory-update.md: 99577a4d85adc3fe13ea0ae262d5aa1af6128237 +2026-09-20-windows-embedded-mandatory-update.zh.md: abe4a9577d8e11d8de2089dc53ef07433e10bbdb diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.md b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.md new file mode 100644 index 0000000000..99577a4d85 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.md @@ -0,0 +1,25 @@ +# Agent Note: Windows embedded mandatory update + +Status: implemented + +English | [中文](2026-09-20-windows-embedded-mandatory-update.zh.md) + +## Problem + +A second native Windows window does not resize atomically with its parent, producing a detached overlay during maximization (#4566). Native modality also disables the parent controls that users need to exit. + +## Decision + +Windows uses one native window and a shell-origin frame below the caption. This partially supersedes the presentation decision in the [mandatory-update client](../feature/2026-09-11-desktop-mandatory-update-client.md); that note retains policy and installation ownership. macOS retains its native overlay. + +The isolated application preload transfers a private MessageChannel endpoint directly to the shell frame. Only messages on the paired private port authorize update IPC; synthetic window events cannot invoke actions. The main process validates the current application main frame and rebinds state delivery when the main window changes. Destroyed windows receive neither state nor focus operations. + +## Alternatives considered + +**Synchronize a second native window.** Parent movement, maximization and restoration expose independently scheduled geometry updates; removing the second window removes that synchronization problem. + +**Treat source and origin on window events as authentication.** Product scripts can construct those event fields. A private port keeps action authority outside the shared product document. + +## Consequences + +Caption menus and Web controls remain reachable. The DOM overlay blocks ordinary background input but cannot prevent product scripts from hiding or imitating presentation; it is not a tamper-proof security surface. Main-process policy and installation authorization remain separate from that display. Regression coverage requires forged-event rejection, real channel delivery, caption keyboard operation, and destroyed/replacement-window lifecycle cases. diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md new file mode 100644 index 0000000000..abe4a9577d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Windows 内嵌强更弹窗 + +Status: implemented + +[English](2026-09-20-windows-embedded-mandatory-update.md) | 中文 + +## 问题 + +Windows 的第二个原生窗口无法与父窗口原子地缩放,最大化时会出现脱离的蒙层(#4566)。原生模态还会禁用用户退出应用所需的父窗口控件。 + +## 决策 + +Windows 使用一个原生窗口和顶栏下方的壳源站 frame。此决策部分取代[强更客户端](../feature/2026-09-11-desktop-mandatory-update-client.zh.md)的展示决策;该说明继续负责策略与安装归属。macOS 保留原生蒙层。 + +隔离的应用预加载将私有 MessageChannel 端点直接传给壳 frame。只有配对私有端口上的消息才能授权更新 IPC;合成的 window 事件不能调用操作。主进程校验当前应用主 frame,并在主窗口变化时重新绑定状态投递。已销毁窗口不再接收状态或焦点操作。 + +## 考虑过的替代方案 + +**同步第二个原生窗口。** 父窗口移动、最大化与还原会暴露独立调度的几何更新;去掉第二个窗口即可消除这一同步问题。 + +**把 window 事件的 source 和 origin 当作身份认证。** 产品脚本可以构造这些事件字段。私有端口将操作权限保留在共享产品文档之外。 + +## 后果 + +顶栏菜单和 Web 控件仍可触达。DOM 蒙层阻止普通背景输入,但不能阻止产品脚本隐藏或仿造展示;它不是防篡改的安全界面。主进程策略和安装授权与展示相互独立。回归覆盖要求包括拒绝伪造事件、真实通道投递、顶栏键盘操作,以及窗口销毁和替换的生命周期用例。 diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml index 16d943e2a3..32ae05e725 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md -2026-09-11-desktop-mandatory-update-client.md: d66d3257360270a72d5e774282d9f7c61981929f -2026-09-11-desktop-mandatory-update-client.zh.md: fd6e751a4082578cc4d54746e0fc584104a89226 +2026-09-11-desktop-mandatory-update-client.md: ff55f30836a37e49229adac6414cb3a98bca4d6c +2026-09-11-desktop-mandatory-update-client.zh.md: 6c17a9fbd0ce2741b635b742be682d4c5fd9f8c6 diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md index d66d325736..ff55f30836 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md @@ -14,11 +14,11 @@ The shell polls the guest protocol in the [API proposal](../../proposed/feature/ A coordinator owns one immutable installed-client identity, one in-flight request, a deadline, interval jitter, and bounded failure backoff. Manual checks bypass scheduling but join in-flight work. Disposal aborts the request, awaits settlement, and prevents late publication. A flattened `40005` establishes blocking independently of optional title, detail, and download-page fields; localized fallback text and a retry action remain available, while an absent or unapproved page is not offered. Transport, JSON, business-code, or HTTP failures cannot clear a known block. Only a valid fresh no-force response clears it. Same-version offline persistence is not implemented because its product behavior remains undecided. -User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory window. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows. +User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory dialog. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows. -A shell-owned modal uses a separate sandboxed preload with dependency-free IPC names. The main process verifies its exact document URL and WebContents identity. Server title/detail are text, never HTML. Close, Esc, ordinary updater prompts, plugin-window entry, plugin mutations, and recovery actions cannot bypass the policy. The Host keeps existing work running; the existing updater's task-aware confirmation alone authorizes installation teardown. Closing the application remains available. +On macOS a shell-owned modal uses a separate sandboxed preload, and the main process verifies its exact document URL and WebContents identity. Windows embeds the shell-origin dialog in the main document below the caption so maximization and resizing have one native owner; a second native window caused visibly unsynchronized resizing (#4566). The isolated application preload owns a private MessageChannel to the frame and forwards actions to main-process IPC, which verifies the current app origin and main-frame identity. Product scripts share the DOM and can hide or imitate presentation, but cannot obtain the privileged port, clear main-process policy, or approve task teardown through window message events. The caption and its Web controls remain reachable by design. Server title/detail are text, never HTML. Esc and ordinary updater prompts cannot dismiss the block; main-process guards refuse plugin-window entry and plugin mutations while it is active. The Host keeps existing work running; task-aware installation confirmation remains separate from policy. Closing the application remains available. Windows presentation and its trust limits are owned by the [embedded-update decision](../bug-fix/2026-09-20-windows-embedded-mandatory-update.md). -The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned document and main frame; closing, aborting, or losing an ordinary renderer cancels its approval. +The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned IPC sender; closing, aborting, or losing an ordinary renderer cancels its approval. The recovery layout keeps localized errors and folded technical details inside the block. Page actions appear only for failure or absent-artifact recovery, not beside the normal action. Exact HTTPS origins are checked before browser or clipboard use. A browser request immediately exposes copy recovery; its completion cannot prove external page loading. Only copy failure reveals the complete validated address for manual copying. Navigation and copy results are independent of updater errors and ignore completion after a changed policy destination. Neither browser opening, readiness, nor installer handoff clears policy or changes the updater target. diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md index fd6e751a40..6c17a9fbd0 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md @@ -14,11 +14,11 @@ Status: implemented 协调器拥有一份不可变的已安装客户端身份、单一在途请求、截止时间、间隔抖动和有上限的失败退避。手动检查绕过调度,但复用在途请求。dispose(资源释放)中止请求、等待结算,并阻止迟到发布。扁平化 `40005` 建立阻塞不依赖可选的标题、详情和下载页面字段;本地化兜底文案与重试操作仍可使用,缺失或未获批准的页面地址不会展示。传输、JSON、业务码或 HTTP 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。 -用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更窗口。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。 +用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更弹窗。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。 -壳拥有的模态窗口使用独立沙箱预加载和无依赖的 IPC 名称。主进程校验其精确文档 URL 和 WebContents 身份。服务端标题/详情按文本展示,绝不作为 HTML。关闭、Esc、常规更新弹窗、插件窗口入口、插件修改和恢复操作都不能绕过策略。Host 保持现有工作运行;只有现有 updater 的任务影响确认才能授权安装收尾。仍可退出应用。 +macOS 的壳模态窗口使用独立沙箱预加载,主进程校验其精确文档 URL 和 WebContents 身份。Windows 将壳源站弹窗嵌入主文档顶栏下方,让最大化与缩放只有一个原生窗口负责;第二个原生窗口曾造成可见的缩放不同步(#4566)。隔离的应用预加载拥有通向 frame 的私有 MessageChannel,将操作转发至校验当前应用源站和主 frame 身份的主进程 IPC。产品脚本共享 DOM,可以隐藏或仿造展示,但不能获得特权端口、清除主进程策略,或通过 window 消息事件批准任务收尾。顶栏及其中的 Web 控件按设计保持可触达。服务端标题/详情按文本展示,绝不作为 HTML。Esc 和常规更新弹窗不能解除阻塞;阻塞期间主进程拒绝插件窗口入口和插件修改。Host 保持现有工作运行,任务影响安装确认独立于策略。仍可退出应用。 Windows 展示及其信任限制由[内嵌强更决策](../bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md)负责。 -模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属文档与主 frame;关闭、中止或失去常规渲染进程会取消其批准。 +模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属 IPC 发送者;关闭、中止或失去常规渲染进程会取消其批准。 恢复界面在阻塞窗口内保留本地化错误和折叠技术详情。页面操作仅在失败或没有可用产物时出现,不与正常操作并列。浏览器或剪贴板使用前校验精确 HTTPS 源站。请求打开浏览器后立即提供复制恢复入口;请求完成不能证明外部网页加载。仅在复制失败时展示完整、经过校验的地址供手动复制。导航和复制结果独立于 updater 错误,策略目标地址改变后忽略迟到结果。打开浏览器、下载就绪和安装器交接均不清除策略或改变 updater 目标。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 226e25d24c..d7d5a46819 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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 apps/desktop/README.md -README.md: e0c855b5e674fbc445bf7f9362fbd9e8810182f9 -README.zh.md: 52c4db17b125462518235ab580ddcf7a9db8b481 +README.md: 3e28e562a4fdc8b77ddd57a82e4764f8f1a3f6cb +README.zh.md: 774115309cfc2ea5c2c581c394202180ec0d6fed diff --git a/apps/desktop/README.md b/apps/desktop/README.md index e0c855b5e6..3e28e562a4 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -320,7 +320,7 @@ With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENT Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified. -A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. +A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 52c4db17b1..774115309c 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -54,7 +54,7 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 -Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 +Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离预加载和壳 frame 之间的私有 MessageChannel 传递。 macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。 diff --git a/apps/desktop/renderer/mandatory-update-frame.js b/apps/desktop/renderer/mandatory-update-frame.js index af490336a1..098059e237 100644 --- a/apps/desktop/renderer/mandatory-update-frame.js +++ b/apps/desktop/renderer/mandatory-update-frame.js @@ -1,12 +1,12 @@ /** The embedded shell page delegates actions only to its owning application preload. */ if (window.parent !== window && window.dshMandatoryUpdate === undefined) { let state + let port let sequence = 0 const listeners = new Set() const requests = new Map() const initial = Promise.withResolvers() - window.addEventListener('message', event => { - if (event.source !== window.parent || event.origin !== 'dsh-app://app') return + const receive = event => { const message = event.data if (message?.type === 'dsh-mandatory-state' && message.state) { state = message.state @@ -20,7 +20,14 @@ if (window.parent !== window && window.dshMandatoryUpdate === undefined) { if (message.ok) request.resolve() else request.reject(new Error('Update action failed')) } + } + window.addEventListener('message', event => { + if (!event.isTrusted || event.source !== window.parent || event.origin !== 'dsh-app://app' + || event.data?.type !== 'dsh-mandatory-connect' || event.ports.length !== 1 || port) return + port = event.ports[0] + port.onmessage = receive }) + window.addEventListener('pagehide', () => { port?.close() }, { once: true }) window.dshMandatoryUpdate = { status: () => state ? Promise.resolve(state) : initial.promise, subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener) }, @@ -28,9 +35,8 @@ if (window.parent !== window && window.dshMandatoryUpdate === undefined) { const id = ++sequence const request = Promise.withResolvers() requests.set(id, request) - window.parent.postMessage({ type: 'dsh-mandatory-action', id, action, version, revision }, 'dsh-app://app') + port.postMessage({ type: 'dsh-mandatory-action', id, action, version, revision }) return request.promise }, } - window.parent.postMessage({ type: 'dsh-mandatory-ready' }, 'dsh-app://app') } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e5aa563c6e..304840e647 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -410,7 +410,7 @@ async function main(): Promise { protocol.handle(SCHEME, (request) => { const url = new URL(request.url) // Serves shell URLs declared in update-dialog.ts and mandatory-update-window.ts plus their renderer assets. - // Removing this route leaves those modal windows empty while their parent remains blocked. + // Removing this route leaves those shell windows and embedded documents empty while their parent remains blocked. if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer')) if (url.hostname === 'app') { if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/') diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts index 068961b577..cfca36679b 100644 --- a/apps/desktop/src/mandatory-update-window.ts +++ b/apps/desktop/src/mandatory-update-window.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import type { DesktopLocale } from './locale.ts' -import type { DesktopUpdateState } from './ipc.ts' +import { assertDesktopSender, type DesktopUpdateState } from './ipc.ts' import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts' import { MANDATORY_IPC } from './mandatory-update-ipc.ts' import { createUpdateOverlay } from './update-overlay.ts' @@ -49,8 +49,9 @@ const page = 'dsh-app://shell/mandatory-update.html' /** Shell-owned update presentation; Windows embeds it in the main document without a child window. */ export class DesktopMandatoryUpdateWindow { private readonly embedded = process.platform === 'win32' - private readonly embeddedParent: BrowserWindow | undefined - private readonly publishEmbedded = (): void => { this.sync() } + private embeddedParent: BrowserWindow | undefined + private embeddedBlocking = false + private readonly publishEmbedded = (): void => { this.embeddedBlocking = false; this.sync() } private window: BrowserWindow | undefined private closing: ReturnType | undefined private disposed = false @@ -143,6 +144,18 @@ export class DesktopMandatoryUpdateWindow { /** Publish current status, create the block immediately, or close it only after policy clearance. */ sync(): void { if (this.disposed) return + if (this.embedded) { + const parent = this.options.parent() + if (parent !== this.embeddedParent) { + if (this.embeddedParent !== undefined && !this.embeddedParent.isDestroyed()) { + this.embeddedParent.webContents.off('did-finish-load', this.publishEmbedded) + } + this.embeddedBlocking = false + this.embeddedParent = parent + if (parent !== undefined && !parent.isDestroyed()) parent.webContents.on('did-finish-load', this.publishEmbedded) + } + if (parent === undefined || parent.isDestroyed()) return + } if (!this.options.policy().blocking) { this.finishConfirmation(false) this.attention.reset() @@ -159,7 +172,10 @@ export class DesktopMandatoryUpdateWindow { }, 150) } this.error = undefined - if (this.embedded) this.embeddedParent?.webContents.send(MANDATORY_IPC.state, this.view()) + if (this.embedded && this.embeddedBlocking) { + this.embeddedBlocking = false + this.embeddedParent?.webContents.send(MANDATORY_IPC.state, this.view()) + } return } clearTimeout(this.closing) @@ -167,6 +183,7 @@ export class DesktopMandatoryUpdateWindow { if (this.navigationUrl !== this.options.policy().page) this.clearNavigation() if (this.options.update().phase === 'error') this.restart = undefined if (this.embedded) { + this.embeddedBlocking = true this.embeddedParent?.webContents.send(MANDATORY_IPC.state, this.view()) return } @@ -198,6 +215,7 @@ export class DesktopMandatoryUpdateWindow { focus(): void { this.sync() const parent = this.options.parent() + if (parent?.isDestroyed()) return if (parent?.isMinimized()) parent.restore() parent?.show() if (this.embedded) parent?.focus() @@ -276,10 +294,11 @@ export class DesktopMandatoryUpdateWindow { private assertSender(event: IpcMainInvokeEvent): void { if (this.embedded) { const parent = this.options.parent() - if (parent === undefined || event.sender !== parent.webContents || event.senderFrame !== parent.webContents.mainFrame - || !['dsh-app://app/', 'dsh-app://app/index.html'].includes(event.senderFrame.url)) { + if (parent === undefined || parent.isDestroyed() || event.sender !== parent.webContents + || event.senderFrame !== parent.webContents.mainFrame) { throw new Error('desktop policy: rejected unowned renderer') } + assertDesktopSender(event, ['app']) return } if (event.sender !== this.window?.webContents || event.senderFrame !== this.window.webContents.mainFrame diff --git a/apps/desktop/src/preload-mandatory-overlay.ts b/apps/desktop/src/preload-mandatory-overlay.ts index a2f3aae3c8..6c4076e091 100644 --- a/apps/desktop/src/preload-mandatory-overlay.ts +++ b/apps/desktop/src/preload-mandatory-overlay.ts @@ -4,22 +4,26 @@ import { MANDATORY_IPC } from './mandatory-update-ipc.ts' import type { MandatoryUpdateView } from './mandatory-update-window.ts' import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' -/** Keep privileged update actions in the isolated preload, accepting messages only from its owned shell frame. */ +/** Keep update actions on a private channel to the shell frame; the shared product DOM is not a tamper-proof display. */ export function installMandatoryUpdateOverlay(): void { + let disposed = false let state: MandatoryUpdateView | undefined let host: HTMLDivElement | undefined let frame: HTMLIFrameElement | undefined + let port: MessagePort | undefined let closing: ReturnType | undefined - const publish = (): void => { frame?.contentWindow?.postMessage({ type: 'dsh-mandatory-state', state }, 'dsh-app://shell') } - const remove = (): void => { host?.remove(); host = undefined; frame = undefined } + const publish = (): void => { port?.postMessage({ type: 'dsh-mandatory-state', state }) } + const remove = (): void => { port?.close(); port = undefined; host?.remove(); host = undefined; frame = undefined } const render = (): void => { if (state === undefined || document.readyState === 'loading') return - clearTimeout(closing) if (!state.policy.blocking) { + if (frame === undefined || closing !== undefined) return publish() closing = setTimeout(remove, 150) return } + clearTimeout(closing) + closing = undefined if (frame === undefined) { host = document.createElement('div') host.style.cssText = `position:fixed;top:${WINDOWS_TITLEBAR_HEIGHT}px;left:0;right:0;bottom:0;z-index:2147483647;backdrop-filter:blur(2px)` @@ -30,39 +34,51 @@ export function installMandatoryUpdateOverlay(): void { frame.src = 'dsh-app://shell/mandatory-update.html' shadow.append(frame) document.documentElement.append(host) - frame.addEventListener('load', () => { publish(); frame?.focus() }) + frame.addEventListener('load', () => { + port?.close() + const channel = new MessageChannel() + port = channel.port1 + port.onmessage = message + frame?.contentWindow?.postMessage({ type: 'dsh-mandatory-connect' }, 'dsh-app://shell', [channel.port2]) + publish() + frame?.focus() + }) } publish() } const receive = (_event: Electron.IpcRendererEvent, next: MandatoryUpdateView): void => { state = next; render() } const message = (event: MessageEvent): void => { - if (frame === undefined || frame.contentWindow === null || event.source !== frame.contentWindow || event.origin !== 'dsh-app://shell') return const value = event.data if (typeof value !== 'object' || value === null || !('type' in value)) return - if (value.type === 'dsh-mandatory-ready') { publish(); return } if (value.type !== 'dsh-mandatory-action' || !('id' in value) || !Number.isSafeInteger(value.id) || !('action' in value) || !('version' in value) || !('revision' in value)) return - const target = frame.contentWindow + const target = port + if (target === undefined) return void ipcRenderer.invoke(MANDATORY_IPC.action, value.action, value.version, value.revision).then( - () => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: true }, 'dsh-app://shell') }, - () => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: false }, 'dsh-app://shell') }, + () => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: true }) }, + () => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: false }) }, ) } const blockBackgroundKey = (event: KeyboardEvent): void => { - if (frame === undefined || state?.policy.blocking !== true) return + if (frame === undefined || state?.policy.blocking !== true + || event.composedPath().some(target => target instanceof HTMLElement && target.hasAttribute('data-windows-menu'))) return event.preventDefault() event.stopImmediatePropagation() frame.focus() } ipcRenderer.on(MANDATORY_IPC.state, receive) - window.addEventListener('message', message) + void ipcRenderer.invoke(MANDATORY_IPC.status).then((initial: MandatoryUpdateView) => { + if (!disposed && state === undefined) { state = initial; render() } + }, () => { + // Development may omit policy configuration; a later main-frame load still resynchronizes configured policy. + }) window.addEventListener('keydown', blockBackgroundKey, true) window.addEventListener('DOMContentLoaded', render, { once: true }) window.addEventListener('pagehide', () => { + disposed = true clearTimeout(closing) remove() ipcRenderer.off(MANDATORY_IPC.state, receive) - window.removeEventListener('message', message) window.removeEventListener('keydown', blockBackgroundKey, true) window.removeEventListener('DOMContentLoaded', render) }, { once: true }) diff --git a/apps/desktop/tests/fixtures/mandatory-frame.mjs b/apps/desktop/tests/fixtures/mandatory-frame.mjs index 95e507eda4..797d3373f7 100644 --- a/apps/desktop/tests/fixtures/mandatory-frame.mjs +++ b/apps/desktop/tests/fixtures/mandatory-frame.mjs @@ -1,3 +1,4 @@ +import { WINDOWS_TITLEBAR_HEIGHT } from '../../lib/types/windows-layout.js' import { setTimeout as delay } from 'node:timers/promises' export async function mandatoryFrame(parent) { @@ -19,7 +20,7 @@ export async function mandatoryFrameDriver(parent) { if (typeof event.y !== 'number') return target.sendInputEvent(event) return target.debugger.sendCommand('Input.dispatchMouseEvent', { type: event.type === 'mouseDown' ? 'mousePressed' : event.type === 'mouseUp' ? 'mouseReleased' : 'mouseMoved', - x: event.x, y: event.y + 40, button: event.button ?? 'none', clickCount: event.clickCount ?? 0, + x: event.x, y: event.y + WINDOWS_TITLEBAR_HEIGHT, button: event.button ?? 'none', clickCount: event.clickCount ?? 0, }) } const value = Reflect.get(target, key) diff --git a/apps/desktop/tests/fixtures/workspace-updates.mjs b/apps/desktop/tests/fixtures/workspace-updates.mjs index b95154f6d9..51e930cb2f 100644 --- a/apps/desktop/tests/fixtures/workspace-updates.mjs +++ b/apps/desktop/tests/fixtures/workspace-updates.mjs @@ -1,3 +1,4 @@ +import { WINDOWS_TITLEBAR_HEIGHT } from '../../lib/types/windows-layout.js' /** Real Electron main entry, preload, shared Web Host, and local updater; no installer executes. */ import { mandatoryFrameDriver } from './mandatory-frame.mjs' import assert from 'node:assert/strict' @@ -298,7 +299,7 @@ async function qualify() { if (process.platform === 'win32') { const viewport = await mandatory.webContents.executeJavaScript('({ width: innerWidth, height: innerHeight })') const bounds = mainWindow.getContentBounds() - assert.deepEqual(viewport, { width: bounds.width, height: bounds.height - 40 }) + assert.deepEqual(viewport, { width: bounds.width, height: bounds.height - WINDOWS_TITLEBAR_HEIGHT }) } mandatory.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) mandatory.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 171e3855f1..b6a0b67ed7 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -62,7 +62,9 @@ const harness = await vi.hoisted(async () => { getZoomFactor: () => 1, focus: vi.fn(), sendInputEvent: vi.fn(), - send: vi.fn(), + send: vi.fn((channel: string, state: { policy?: { blocking: boolean } }) => { + if (channel === 'dsh-desktop:mandatory-state' && state.policy?.blocking) policyBlocked.resolve() + }), }) readonly show = vi.fn() readonly hide = vi.fn() @@ -71,7 +73,7 @@ const harness = await vi.hoisted(async () => { readonly setSize = vi.fn() readonly setTitleBarOverlay = vi.fn() constructor(readonly options: { show: boolean; modal?: boolean }) { - super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this); if (options.modal) policyBlocked.resolve() + super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this) } isDestroyed() { return this.destroyed } fullscreen = false @@ -468,7 +470,7 @@ describe('desktop main startup', () => { expect(testAuth.login).not.toHaveBeenCalled() }) - it('retains the same blocking window and running Host after expired test login is cancelled', async () => { + it('retains the embedded block and running Host after expired test login is cancelled', async () => { harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'], allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 } const request = vi.fn().mockImplementation(async () => @@ -479,7 +481,7 @@ describe('desktop main startup', () => { const host = await readyForUpdate() await harness.policyBlocked.promise await vi.advanceTimersByTimeAsync(1000) - const modal = harness.windows.find(window => window.options.modal)! + const modal = harness.windows[0]! const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } harness.dialog.showMessageBox.mockResolvedValue({ response: 0 }) testAuth.login.mockResolvedValueOnce('cancelled') @@ -851,14 +853,15 @@ describe('desktop main startup', () => { vi.stubGlobal('fetch', request) const host = await readyForUpdate() await harness.policyBlocked.promise - const modal = harness.windows.find(window => window.options.modal)! + const modal = harness.windows[0]! expect(modal).toBeDefined() + expect(harness.windows).toHaveLength(1) const status = harness.handlers.get(MANDATORY_IPC.status)! const action = harness.handlers.get(MANDATORY_IPC.action)! const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } expect(status(owned)).toMatchObject({ policy: { blocking: true } }) const unowned = [ - { ...owned, sender: harness.windows[0]!.webContents }, + { ...owned, sender: {} }, { ...owned, senderFrame: { url: 'dsh-app://app/index.html' } }, { ...owned, senderFrame: { url: 'https://untrusted.example.com/' } }, ] @@ -883,7 +886,9 @@ describe('desktop main startup', () => { await vi.advanceTimersByTimeAsync(20_000) expect(modal.isDestroyed()).toBe(false) await vi.advanceTimersByTimeAsync(150) - expect(modal.isDestroyed()).toBe(true) + expect(modal.isDestroyed()).toBe(false) + expect(modal.webContents.send).toHaveBeenLastCalledWith(MANDATORY_IPC.state, + expect.objectContaining({ policy: expect.objectContaining({ blocking: false }) })) expect(host.stop).not.toHaveBeenCalled() expect(request.mock.calls[0]![1]!.headers).toMatchObject({ 'x-client-bundle-id': 'com.deepseek.dsh', 'x-client-version': '1.0.0' }) }) @@ -1099,7 +1104,7 @@ describe('desktop main startup', () => { }) async function answerMandatory(action: 'install' | 'later') { - const modal = harness.windows.find(window => window.options.modal && !window.isDestroyed())! + const modal = harness.windows[0]! const event = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } await vi.waitFor(() => { expect(harness.handlers.get(MANDATORY_IPC.status)!(event)).toHaveProperty('confirmation') @@ -1108,14 +1113,14 @@ describe('desktop main startup', () => { await harness.handlers.get(MANDATORY_IPC.action)!(event, action, view.confirmation.version, view.confirmation.revision) } - it('releases the mandatory modal when the confirmed installer quits Electron', async () => { + it('closes the main window when the confirmed installer quits Electron', async () => { harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: { show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/', } }))) const host = await readyForUpdate() await harness.policyBlocked.promise - const modal = harness.windows.find(window => window.options.modal)! + const modal = harness.windows[0]! const preparing = harness.prepareUpdate() await answerMandatory('install') await host.stopping.promise @@ -1159,7 +1164,10 @@ describe('desktop main startup', () => { await expect(retry).resolves.toBe(false) expect(replacement.updateTasks.mock.calls).toEqual([['inspect']]) expect(replacement.stop).not.toHaveBeenCalled() - if (mandatory) expect(harness.windows.find(window => window.options.modal)?.isDestroyed()).toBe(false) + if (mandatory) { + expect(harness.windows).toHaveLength(1) + expect(harness.windows[0]!.isDestroyed()).toBe(false) + } }) it.each([false, true])('restores a confirmed non-graceful exit without approving installation, mandatory: %s', async (mandatory) => { @@ -1193,7 +1201,10 @@ describe('desktop main startup', () => { await expect(retry).resolves.toBe(false) expect(replacement.updateTasks.mock.calls).toEqual([['inspect']]) expect(replacement.stop).not.toHaveBeenCalled() - if (mandatory) expect(harness.windows.find(window => window.options.modal)?.isDestroyed()).toBe(false) + if (mandatory) { + expect(harness.windows).toHaveLength(1) + expect(harness.windows[0]!.isDestroyed()).toBe(false) + } }) it('does not replace a Host whose failed stop has not confirmed process exit', async () => { diff --git a/apps/desktop/tests/mandatory-update-window.spec.ts b/apps/desktop/tests/mandatory-update-window.spec.ts index a3edbc2212..587a5113fa 100644 --- a/apps/desktop/tests/mandatory-update-window.spec.ts +++ b/apps/desktop/tests/mandatory-update-window.spec.ts @@ -48,6 +48,7 @@ it('releases IPC after the Windows main window has already been destroyed', () = setup('win32') vi.spyOn(window, 'isDestroyed').mockReturnValue(true) Object.defineProperty(window, 'webContents', { get() { throw new Error('Object has been destroyed') } }) + expect(() => { ui!.sync(); ui!.focus() }).not.toThrow() expect(() => { ui!.dispose() }).not.toThrow() expect(native.handlers.size).toBe(0) expect(() => { ui!.dispose() }).not.toThrow() @@ -179,3 +180,23 @@ it('publishes Windows overlays into the main document without creating or destro expect(window.destroy).not.toHaveBeenCalled() expect(window.webContents.listenerCount('did-finish-load')).toBe(0) }) + +it('rebinds policy updates and reloads to a replacement Windows main window', () => { + setup('win32') + const previous = window + window = fakeWindow() + window.webContents.mainFrame.url = 'dsh-app://app/?recovery=1#home' + ui!.sync() + expect(previous.webContents.listenerCount('did-finish-load')).toBe(0) + expect(window.webContents.send).toHaveBeenCalledWith(MANDATORY_IPC.state, + expect.objectContaining({ policy: expect.objectContaining({ blocking: true }) })) + window.webContents.send.mockClear() + window.webContents.emit('did-finish-load') + expect(window.webContents.send).toHaveBeenCalledOnce() + expect(() => native.handlers.get(MANDATORY_IPC.status)!({ + sender: previous.webContents, senderFrame: previous.webContents.mainFrame, + })).toThrow(/unowned/) + expect(() => native.handlers.get(MANDATORY_IPC.status)!({ + sender: window.webContents, senderFrame: window.webContents.mainFrame, + })).not.toThrow() +}) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index 1fa225e297..fb633e8a4a 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -1,4 +1,5 @@ import { afterEach, expect, it, vi } from 'vitest' +import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts' import { syncWindowsAppearance } from '../src/preload-windows.ts' import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts' @@ -12,7 +13,7 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() })) vi.mock('../src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() })) -afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() }) +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() }) it('limits product documents to update status and a native confirmation action', async () => { vi.stubGlobal('location', new URL('dsh-app://app/index.html')) @@ -77,3 +78,14 @@ it.each(['dsh-app://app/', 'dsh-app://shell/plugin-manager.html', 'https://examp expect(syncWindowsAppearance).toHaveBeenCalledTimes(url === 'dsh-app://app/' ? 1 : 0) }, ) + +it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only in the Windows app document (%s)', async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + for (const url of ['dsh-app://app/', 'dsh-app://shell/mandatory-update.html', 'https://example.com/']) { + vi.resetModules() + vi.mocked(installMandatoryUpdateOverlay).mockClear() + vi.stubGlobal('location', new URL(url)) + await import('../src/preload-app.ts') + expect(installMandatoryUpdateOverlay).toHaveBeenCalledTimes(platform === 'win32' && url === 'dsh-app://app/' ? 1 : 0) + } +}) diff --git a/apps/desktop/tests/preload-mandatory-overlay.spec.ts b/apps/desktop/tests/preload-mandatory-overlay.spec.ts index bfbde8461f..85f8f55f16 100644 --- a/apps/desktop/tests/preload-mandatory-overlay.spec.ts +++ b/apps/desktop/tests/preload-mandatory-overlay.spec.ts @@ -1,3 +1,4 @@ +import { MessageChannel } from 'node:worker_threads' import { JSDOM } from 'jsdom' import { afterEach, expect, it, vi } from 'vitest' import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts' @@ -8,9 +9,12 @@ import type { MandatoryUpdateView } from '../src/mandatory-update-window.ts' const ipc = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn(), invoke: vi.fn(async () => {}) })) vi.mock('electron', () => ({ ipcRenderer: ipc })) let dom: JSDOM +let channel: MessageChannel afterEach(() => { dom.window.dispatchEvent(new dom.window.Event('pagehide')) dom.window.close() + channel?.port1.close() + channel?.port2.close() vi.restoreAllMocks() vi.unstubAllGlobals() vi.clearAllMocks() @@ -18,48 +22,82 @@ afterEach(() => { }) function setup() { - dom = new JSDOM('', { url: 'dsh-app://app/' }) + dom = new JSDOM('
    ', { url: 'dsh-app://app/' }) vi.stubGlobal('window', dom.window) vi.stubGlobal('document', dom.window.document) + vi.stubGlobal('HTMLElement', dom.window.HTMLElement) + vi.stubGlobal('MessageChannel', function createChannel() { channel = new MessageChannel(); return channel }) vi.spyOn(dom.window.document, 'readyState', 'get').mockReturnValue('complete') const shadow = vi.spyOn(dom.window.HTMLElement.prototype, 'attachShadow') installMandatoryUpdateOverlay() - const publish = ipc.on.mock.calls.find(([channel]) => channel === MANDATORY_IPC.state)![1] as + const publish = ipc.on.mock.calls.find(([name]) => name === MANDATORY_IPC.state)![1] as (event: unknown, state: MandatoryUpdateView) => void const view: MandatoryUpdateView = { locale: resolveDesktopLocale('zh-CN'), policy: { blocking: true, checking: false }, update: { phase: 'available', version: '2.0.0' }, deferred: false } publish({}, view) + ipc.invoke.mockClear() const root = shadow.mock.results[0]!.value as ShadowRoot const frame = root.querySelector('iframe')! - // JSDOM does not create browsing contexts for frames inside a shadow root. + // JSDOM has no browsing context for a shadow-root iframe; transport uses real paired Node ports. + const post = vi.spyOn(dom.window, 'postMessage').mockImplementation(() => {}) Object.defineProperty(frame, 'contentWindow', { value: dom.window }) + vi.spyOn(frame, 'focus').mockImplementation(() => {}) + frame.dispatchEvent(new dom.window.Event('load')) + expect(post).toHaveBeenCalledWith({ type: 'dsh-mandatory-connect' }, 'dsh-app://shell', [channel.port2]) return { publish, view, root, frame, host: root.host as HTMLElement } } -it('uses an in-page frame below the caption, reuses it on updates, and never blurs the main body', () => { +it('uses an in-page frame below the caption and reuses it on updates', () => { const f = setup() expect(f.host.style.top).toBe('40px') expect(f.host.style.bottom).toBe('0px') - expect(f.host.style.position).toBe('fixed') expect(f.frame.src).toBe('dsh-app://shell/mandatory-update.html') expect(dom.window.document.body.style.filter).toBe('') f.publish({}, { ...f.view, update: { phase: 'downloading', version: '2.0.0', percent: 30 } }) expect(f.root.querySelector('iframe')).toBe(f.frame) }) -it('accepts actions only from the owned shell frame and leaves no overlay after clearance or navigation', async () => { - vi.useFakeTimers() +it('rejects forged window events and accepts actions only through the transferred private port', async () => { const f = setup() const data = { type: 'dsh-mandatory-action', id: 1, action: 'download', version: '2.0.0', revision: undefined } - const send = (source: Window | null, origin: string) => dom.window.dispatchEvent(new dom.window.MessageEvent('message', { source, origin, data })) - send(null, 'dsh-app://shell') - send(f.frame.contentWindow, 'https://other.example') + dom.window.dispatchEvent(new dom.window.MessageEvent('message', { source: f.frame.contentWindow, origin: 'dsh-app://shell', data })) expect(ipc.invoke).not.toHaveBeenCalled() - send(f.frame.contentWindow, 'dsh-app://shell') - await Promise.resolve() + const result = new Promise(resolve => channel.port2.on('message', (message) => { + if (message.type === 'dsh-mandatory-result') resolve(message) + })) + channel.port2.postMessage(data) + await expect(result).resolves.toEqual({ type: 'dsh-mandatory-result', id: 1, ok: true }) expect(ipc.invoke).toHaveBeenCalledExactlyOnceWith(MANDATORY_IPC.action, 'download', '2.0.0', undefined) - f.publish({}, { ...f.view, policy: { blocking: false, checking: false } }) - vi.advanceTimersByTime(150) +}) + +it('keeps caption menu keyboard events available while blocking background shortcuts', () => { + const f = setup() + const host = dom.window.document.querySelector('[data-windows-menu]')! + const shadow = host.attachShadow({ mode: 'open' }) + const button = dom.window.document.createElement('button') + shadow.append(button) + const received = vi.fn() + button.addEventListener('keydown', received) + for (const key of ['Enter', 'ArrowDown', 'ArrowRight']) { + const event = new dom.window.KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true }) + button.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + } + expect(received).toHaveBeenCalledTimes(3) + const background = new dom.window.KeyboardEvent('keydown', { key: 'n', bubbles: true, cancelable: true }) + dom.window.document.body.dispatchEvent(background) + expect(background.defaultPrevented).toBe(true) + expect(f.frame.focus).toHaveBeenCalled() +}) + +it('clears once without postponing removal on progress ticks, and closes the channel on navigation', () => { + vi.useFakeTimers() + const f = setup() + const cleared = { ...f.view, policy: { blocking: false, checking: false } } + f.publish({}, cleared) + vi.advanceTimersByTime(100) + f.publish({}, cleared) + vi.advanceTimersByTime(50) expect(f.host.isConnected).toBe(false) dom.window.dispatchEvent(new dom.window.Event('pagehide')) expect(ipc.off).toHaveBeenCalledWith(MANDATORY_IPC.state, expect.any(Function)) From 015e60ff40db849cae83c9b116b875b1f0f62e04 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:23:36 +0800 Subject: [PATCH 159/284] test(desktop): satisfy strict lint in mandatory update regressions --- apps/desktop/tests/main-startup.spec.ts | 3 +-- apps/desktop/tests/mandatory-update-window.spec.ts | 3 +-- apps/desktop/tests/preload-mandatory-overlay.spec.ts | 10 +++++----- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index b6a0b67ed7..bdca98115d 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -887,8 +887,7 @@ describe('desktop main startup', () => { expect(modal.isDestroyed()).toBe(false) await vi.advanceTimersByTimeAsync(150) expect(modal.isDestroyed()).toBe(false) - expect(modal.webContents.send).toHaveBeenLastCalledWith(MANDATORY_IPC.state, - expect.objectContaining({ policy: expect.objectContaining({ blocking: false }) })) + expect(modal.webContents.send.mock.calls.at(-1)).toMatchObject([MANDATORY_IPC.state, { policy: { blocking: false } }]) expect(host.stop).not.toHaveBeenCalled() expect(request.mock.calls[0]![1]!.headers).toMatchObject({ 'x-client-bundle-id': 'com.deepseek.dsh', 'x-client-version': '1.0.0' }) }) diff --git a/apps/desktop/tests/mandatory-update-window.spec.ts b/apps/desktop/tests/mandatory-update-window.spec.ts index 587a5113fa..a1c218852a 100644 --- a/apps/desktop/tests/mandatory-update-window.spec.ts +++ b/apps/desktop/tests/mandatory-update-window.spec.ts @@ -188,8 +188,7 @@ it('rebinds policy updates and reloads to a replacement Windows main window', () window.webContents.mainFrame.url = 'dsh-app://app/?recovery=1#home' ui!.sync() expect(previous.webContents.listenerCount('did-finish-load')).toBe(0) - expect(window.webContents.send).toHaveBeenCalledWith(MANDATORY_IPC.state, - expect.objectContaining({ policy: expect.objectContaining({ blocking: true }) })) + expect(window.webContents.send.mock.calls.at(-1)).toMatchObject([MANDATORY_IPC.state, { policy: { blocking: true } }]) window.webContents.send.mockClear() window.webContents.emit('did-finish-load') expect(window.webContents.send).toHaveBeenCalledOnce() diff --git a/apps/desktop/tests/preload-mandatory-overlay.spec.ts b/apps/desktop/tests/preload-mandatory-overlay.spec.ts index 85f8f55f16..8416e89fac 100644 --- a/apps/desktop/tests/preload-mandatory-overlay.spec.ts +++ b/apps/desktop/tests/preload-mandatory-overlay.spec.ts @@ -41,10 +41,10 @@ function setup() { // JSDOM has no browsing context for a shadow-root iframe; transport uses real paired Node ports. const post = vi.spyOn(dom.window, 'postMessage').mockImplementation(() => {}) Object.defineProperty(frame, 'contentWindow', { value: dom.window }) - vi.spyOn(frame, 'focus').mockImplementation(() => {}) + const focus = vi.spyOn(frame, 'focus').mockImplementation(() => {}) frame.dispatchEvent(new dom.window.Event('load')) expect(post).toHaveBeenCalledWith({ type: 'dsh-mandatory-connect' }, 'dsh-app://shell', [channel.port2]) - return { publish, view, root, frame, host: root.host as HTMLElement } + return { publish, view, root, frame, focus, host: root.host as HTMLElement } } it('uses an in-page frame below the caption and reuses it on updates', () => { @@ -62,8 +62,8 @@ it('rejects forged window events and accepts actions only through the transferre const data = { type: 'dsh-mandatory-action', id: 1, action: 'download', version: '2.0.0', revision: undefined } dom.window.dispatchEvent(new dom.window.MessageEvent('message', { source: f.frame.contentWindow, origin: 'dsh-app://shell', data })) expect(ipc.invoke).not.toHaveBeenCalled() - const result = new Promise(resolve => channel.port2.on('message', (message) => { - if (message.type === 'dsh-mandatory-result') resolve(message) + const result = new Promise(resolve => channel.port2.on('message', (message: unknown) => { + if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'dsh-mandatory-result') resolve(message) })) channel.port2.postMessage(data) await expect(result).resolves.toEqual({ type: 'dsh-mandatory-result', id: 1, ok: true }) @@ -87,7 +87,7 @@ it('keeps caption menu keyboard events available while blocking background short const background = new dom.window.KeyboardEvent('keydown', { key: 'n', bubbles: true, cancelable: true }) dom.window.document.body.dispatchEvent(background) expect(background.defaultPrevented).toBe(true) - expect(f.frame.focus).toHaveBeenCalled() + expect(f.focus).toHaveBeenCalled() }) it('clears once without postponing removal on progress ticks, and closes the channel on navigation', () => { From bea5bde6cc3273dec1ea8cd892eb005d77aedce3 Mon Sep 17 00:00:00 2001 From: winewill Date: Sun, 20 Sep 2026 15:27:06 +0800 Subject: [PATCH 160/284] docs(desktop): clarify localized native menu behavior --- .../bug-fix/2026-09-16-desktop-window-menus.i18n.yaml | 4 ++-- .../implemented/bug-fix/2026-09-16-desktop-window-menus.md | 6 +++--- .../bug-fix/2026-09-16-desktop-window-menus.zh.md | 6 +++--- apps/desktop/src/main.ts | 1 + 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml index a5158a3994..9c2cdd061a 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md -2026-09-16-desktop-window-menus.md: 85ac42aa287b1cd9e595ade7cb44b965dea787dd -2026-09-16-desktop-window-menus.zh.md: 1376b90058ceafa7e378b4fd543d261064f5d8aa +2026-09-16-desktop-window-menus.md: 8a5343404e0cb3d2e7c4e20248a8b29f3bfbec47 +2026-09-16-desktop-window-menus.zh.md: d6ee6412462aecf49557a7994a683d37bf8e7253 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md index 85ac42aa28..8a5343404e 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md @@ -12,7 +12,7 @@ The Desktop shell replaces Electron's default application menu with a custom tem On macOS the template declares `{ role: 'fileMenu' }` before the Edit menu and `{ role: 'windowMenu' }` after it, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus. -Electron's role labels are English string literals inside Electron (`lib/browser/api/menu-item-roles.ts`, `filemenu`, `windowmenu`, `close`, `minimize`, `hide`) with no locale lookup, and Electron re-applies them to the native menu items before the menu is shown, so a non-English Desktop shows them in English; the existing Edit menu behaves the same way. No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role. +Electron supplies English defaults for the File, Window, and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role. ## Alternatives considered @@ -28,8 +28,8 @@ Electron's role labels are English string literals inside Electron (`lib/browser ## Consequences -macOS regains the window and application commands the custom menu suppressed, at the cost of four menu roles. The labels stay English on a non-English Desktop. +macOS regains the window and application commands the custom menu suppressed. File, Window, and Edit retain Electron’s default English labels; application commands use the Desktop locale. ## Testing -A `apps/desktop/tests/main-startup.spec.ts` case pins the declared menu roles per platform, including the macOS hide commands. Role-based menu items execute natively, so a programmatic `click()` and the vitest Electron mock cannot exercise the shortcuts; a real Electron 44 run of the same template showed the standard File, Window, and application items present only after this change, with the same English labels while `app.getLocale()`, `getSystemLocale()`, and `getPreferredSystemLanguages()` all reported `zh-CN`. +`apps/desktop/tests/main-startup.spec.ts` verifies the declared menu roles per platform and records English and Chinese application-label snapshots. Role-based menu items execute natively, so a programmatic `click()` and the Vitest Electron mock cannot exercise the shortcuts. The isolated Electron 44 demo confirms that explicit application labels appear in native menus; the template snapshots do not qualify native shortcut execution. diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md index 1376b90058..d6ee641246 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md @@ -12,7 +12,7 @@ Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该 macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。 -这些 role 的标签是 Electron 内部的英文常量(`lib/browser/api/menu-item-roles.ts` 的 `filemenu`、`windowmenu`、`close`、`minimize`、`hide`),没有语言查询,而且 Electron 在菜单显示前会把这些标签重新写到本机菜单项上,因此在非英文的 Desktop 上它们仍是英文;现有的 Edit 菜单同样如此。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。 +Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。 ## 考虑过的替代方案 @@ -28,8 +28,8 @@ macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之 ## 影响 -macOS 恢复了自定义菜单压掉的窗口和应用命令,代价是四个菜单 role。在非英文的 Desktop 上这些标签仍是英文。 +macOS 恢复了自定义菜单压掉的窗口和应用命令。File、Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。 ## 测试 -`apps/desktop/tests/main-startup.spec.ts` 中的一个用例固定了各平台声明的菜单 role,包括 macOS 的隐藏命令。基于 role 的菜单项由本机执行,因此程序化的 `click()` 和 vitest 的 Electron mock 都无法触达这些快捷键;用真实 Electron 44 运行同一模板显示,只有在本改动之后才出现标准的 File、Window 和应用菜单项,并且在 `app.getLocale()`、`getSystemLocale()` 和 `getPreferredSystemLanguages()` 都报告 `zh-CN` 时标签保持不变。 +`apps/desktop/tests/main-startup.spec.ts` 验证各平台声明的菜单 role,并记录中英文应用标签快照。基于 role 的菜单项由本机执行,因此程序化的 `click()` 和 Vitest 的 Electron mock 都无法触达这些快捷键。独立 Electron 44 demo 确认显式应用标签会显示在原生菜单中;模板快照不能验证原生快捷键的执行。 diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 10634b7626..63680a5c0b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -608,6 +608,7 @@ async function main(): Promise { }) // A custom application menu replaces Electron's default menu, so macOS needs // its standard menus and application hide commands declared explicitly. + // Keep app.name stable: Electron derives its default userData directory from it. const darwin = process.platform === 'darwin' const platformMenus: MenuItemConstructorOptions[] = darwin ? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }] From db7ee5f487a560a54c39b679aec688f150f4b70b Mon Sep 17 00:00:00 2001 From: winewill Date: Sun, 20 Sep 2026 15:27:25 +0800 Subject: [PATCH 161/284] build(desktop): parallelize Windows signature cache hits --- ...-windows-runtime-signature-cache.i18n.yaml | 4 +- ...6-09-17-windows-runtime-signature-cache.md | 2 + ...9-17-windows-runtime-signature-cache.zh.md | 2 + apps/desktop/.env.windows.example | 3 + apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 4 +- apps/desktop/README.zh.md | 4 +- .../scripts/desktop-package-environment.mjs | 4 +- apps/desktop/scripts/package-target.ts | 1 + apps/desktop/scripts/sign-primary-runtime.ts | 5 +- .../scripts/windows-package-settings.d.mts | 6 ++ .../scripts/windows-package-settings.mjs | 14 ++++ .../scripts/windows-runtime-signature.d.mts | 7 +- .../scripts/windows-runtime-signature.mjs | 28 ++++++- .../scripts/windows-signature-cache.d.mts | 12 ++- .../scripts/windows-signature-cache.mjs | 10 ++- .../tests/desktop-package-environment.spec.ts | 25 ++++++ .../tests/package-target-stages.spec.ts | 7 +- .../tests/primary-runtime-signing.spec.ts | 80 +++++++++++++++++++ .../tests/windows-signature-cache.spec.ts | 20 ++++- 20 files changed, 220 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/scripts/windows-package-settings.d.mts create mode 100644 apps/desktop/scripts/windows-package-settings.mjs diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml index b2f71a9f4d..0ace5db1f2 100644 --- a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml @@ -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 .agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md -2026-09-17-windows-runtime-signature-cache.md: 911e0277199bac55165fbc4adebb22e2599043b9 -2026-09-17-windows-runtime-signature-cache.zh.md: bb8cdd6bdca445c94e8e77cddb68008d7b27f7ad +2026-09-17-windows-runtime-signature-cache.md: e9229e47929664984a92499e54f87bb2166bde9d +2026-09-17-windows-runtime-signature-cache.zh.md: 53b6182170d807843345fa90f962ee18807f2cf2 diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md index 911e027719..e9229e4792 100644 --- a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md @@ -20,6 +20,8 @@ Serialize each complete signing stage and cache-maintenance operation with an ex Group public-key inspection into bounded batches of 32 files with at most four processes. Require one ordered result for every requested path and await every process in a failed batch. Preserve verification immediately after each hardware signature, before the next signing request. +Within the stage lock, restore distinct cache-hit targets with a bounded worker pool and complete their post-verification before serially signing misses. Stop dispatch on the first restore, verification or audit failure and drain active workers before releasing ownership. Four workers are the configurable default: warm-cache experiments on 134 primary-runtime and 228 application files reduce combined median restore-plus-post-verification time from 226.18 seconds at one worker to 63.22 seconds at four; eight takes 42.18 seconds but increases measured CPU by 25.5% and peak committed memory from 387 to 631 MiB. These three-repeat measurements use exact reconstructed unsigned inputs, real Windows verification and a rejecting hardware callback; they exclude full packaging, cold trust state and hardware misses. + The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own runtime contents, vendor-signature preservation and execution checks. The [release decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md) continues to own release identity and signing. Neither is superseded. The cache must preserve their supervised preflight, per-user signing interlock, single-file hardware calls, signed inventory and final packaged-runtime validation. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md index bb8cdd6bdc..53b6182170 100644 --- a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md @@ -20,6 +20,8 @@ Windows 打包每次都会重新生成未签名的原生依赖。为未变化的 公钥验签按每批 32 个文件分组,最多同时运行四个进程。要求每个请求路径都有一条顺序对应的结果,并等待失败批次中的所有进程退出。每次硬件签名后仍立即验签,通过后才开始下一个签名请求。 +在阶段锁内,使用有界工作池恢复不同的缓存命中目标,完成后置验签后才串行签署未命中项。首次恢复、验签或审计失败时停止派发,并在释放所有权前等待在途任务结束。可配置的默认并发为四:对 134 个主要运行时文件及 228 个应用文件的热缓存实验中,恢复加后置验签的合计中位耗时从单任务的 226.18 秒降至四任务的 63.22 秒;八任务为 42.18 秒,但实测 CPU 消耗增加 25.5%,峰值提交内存从 387 增至 631 MiB。这些三轮测量使用精确重建的未签名输入、真实 Windows 验签和拒绝硬件调用的回调;不包含完整打包、冷信任状态或硬件未命中。 + [主要运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md) 继续定义运行时内容、厂商签名保留和执行检查。[发布决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 继续定义发布身份和签名。两者均未被取代。缓存必须保留受监督预检、每用户签名互锁、单文件硬件调用、已签名内容清单和最终打包运行时验证。 ## Alternatives considered diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index 9b38d46df9..28e35a06d2 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -20,6 +20,9 @@ DSH_DESKTOP_WINDOWS_TOKEN_PIN= # Optional absolute, private local directory; defaults to %USERPROFILE%\.dsh-desktop-signing\signature-cache\v1. # DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=C:\Users\build\.dsh-desktop-signing\signature-cache\v1 +# Cache-hit restore and verification workers (1-8). Hardware signing remains serial. +DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 + # Optional upload configuration; packaging does not require these credentials. # DOWNLOAD_TEST_COS_BUCKET= # DOWNLOAD_TEST_COS_SECRET_ID= diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 455501862e..1187d5f1a7 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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 apps/desktop/README.md -README.md: 6c9a6a8784732e6fa1369df5632391b789786b22 -README.zh.md: a0f2184d911eeab46902670cf992b6847e2bca31 +README.md: 8885703f33223d381633c29a11516619d52fd711 +README.zh.md: b5a3f0433d874b3fc51ef16805d7ab6df20d9059 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6c9a6a8784..8885703f33 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -232,7 +232,9 @@ Runtime signing shares complete signed files across the current Windows account' Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery. -Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. +Cache-hit copying, hashing and per-file trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). All restores and their post-verification finish before cache misses enter serial hardware signing. A restore or verification failure stops new dispatch and drains active work before releasing the stage lock; hardware signing, runtime smoke checks and final integrity validation remain required. + +Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time. Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from ` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory ` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a0f2184d91..b5a3f0433d 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -232,7 +232,9 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI 预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。 -每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 +缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。 + +每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。 使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from ` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory ` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。 diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index c8a794e3c7..f07b9653fa 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -10,10 +10,11 @@ import { createWindowsTokenSigner } from './windows-sign.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' import { resolveMacOSPackageSettings } from './macos-package-settings.mjs' import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' +import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u -const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR)$/u +const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK'] @@ -79,6 +80,7 @@ export function validateDesktopPackageEnvironment(environment, target, options = resolveDesktopAppId(environment) resolveDesktopPolicyEnvironment(environment) if (target.platform === 'darwin') resolveMacOSPackageSettings(environment) + else resolveWindowsPackageSettings(environment) if (options.unsigned) return if (!options.prepareOnly) resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch) if (target.platform === 'win32') { diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 651bd64cce..688d3d9671 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -30,6 +30,7 @@ const WINDOWS_SIGNING_ENV_NAMES = [ 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'DSH_DESKTOP_WINDOWS_TOKEN_PIN', 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR', + 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY', ] as const const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([ 'DOWNLOAD_TEST_COS_SECRET_ID', diff --git a/apps/desktop/scripts/sign-primary-runtime.ts b/apps/desktop/scripts/sign-primary-runtime.ts index d48711b4a0..d9a9b36c25 100644 --- a/apps/desktop/scripts/sign-primary-runtime.ts +++ b/apps/desktop/scripts/sign-primary-runtime.ts @@ -5,6 +5,7 @@ import { join, resolve } from 'node:path' import { createWindowsTokenSigner } from './windows-sign.mjs' import { inspectWindowsRuntimeSignature, signWindowsCode, type WindowsCodeSigningOptions } from './windows-runtime-signature.mjs' import { createCachedSigner, signatureCacheIdentity } from './windows-signature-cache.mjs' +import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' import { withWindowsSigningStage } from './windows-signing-stage.mjs' import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs' @@ -46,6 +47,7 @@ export async function signWindowsDesktopRuntime(root: string, version: string, async function main(): Promise { if (process.platform !== 'win32' || resolveDesktopBuildTarget() !== 'win-x64') throw new Error('primary runtime signing requires Windows x64') + const { signatureCacheConcurrency } = resolveWindowsPackageSettings(process.env) const runDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR if (!runDirectory) throw new Error('primary runtime signing requires a supervised packaging run') await readFile(join(runDirectory, 'run.json')) @@ -67,7 +69,8 @@ async function main(): Promise { await prepareWindowsSignatureCacheDirectory(cacheRoot) const cachedSign = createCachedSigner({ root: cacheRoot, identity, thumbprint, sign, inspect: inspectWindowsRuntimeSignature, record }) - const options = { thumbprint, sign: cachedSign, record } + const options = { thumbprint, sign: cachedSign, record, + cache: { restore: cachedSign.restore, concurrency: signatureCacheConcurrency } } record({ type: 'signature-cache-open', root: cachedSign.summary().root, identity }) try { if (process.argv.includes('--dsh')) { diff --git a/apps/desktop/scripts/windows-package-settings.d.mts b/apps/desktop/scripts/windows-package-settings.d.mts new file mode 100644 index 0000000000..a7627e3b7d --- /dev/null +++ b/apps/desktop/scripts/windows-package-settings.d.mts @@ -0,0 +1,6 @@ +/** + * Validate the file-owned Windows cache worker limit before packaging starts. + * @param environment Windows packaging settings loaded from .env.windows. + * @returns Worker limit from one to eight, defaulting to four when omitted; invalid values throw. + */ +export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number } diff --git a/apps/desktop/scripts/windows-package-settings.mjs b/apps/desktop/scripts/windows-package-settings.mjs new file mode 100644 index 0000000000..847849af97 --- /dev/null +++ b/apps/desktop/scripts/windows-package-settings.mjs @@ -0,0 +1,14 @@ +/** Resolve file-owned Windows signature-cache concurrency before packaging starts. */ + +/** + * Validate the cache worker limit without accessing signing hardware. + * @param {NodeJS.ProcessEnv} environment File-owned Windows packaging settings. + * @returns {{signatureCacheConcurrency: number}} Worker limit, defaulting to four when omitted. + */ +export function resolveWindowsPackageSettings(environment) { + const value = environment.DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY ?? '4' + if (!/^[1-8]$/u.test(value)) { + throw new Error('desktop package: DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY must be an integer from 1 to 8') + } + return { signatureCacheConcurrency: Number(value) } +} diff --git a/apps/desktop/scripts/windows-runtime-signature.d.mts b/apps/desktop/scripts/windows-runtime-signature.d.mts index 9c6e207a17..f47e306885 100644 --- a/apps/desktop/scripts/windows-runtime-signature.d.mts +++ b/apps/desktop/scripts/windows-runtime-signature.d.mts @@ -31,6 +31,11 @@ export interface WindowsCodeSigningOptions { sign: ReturnType inspect?: typeof inspectWindowsRuntimeSignature record: (event: object) => void + /** Hardware-free restores on distinct targets; concurrency is a resolved positive integer. All restores and their verification drain before serial signing starts. */ + cache?: { + restore: import('./windows-signature-cache.mjs').WindowsCachedSigner['restore'] + concurrency: number + } } /** @@ -44,7 +49,7 @@ export function windowsRuntimeCode(root: string): Promise * Preserve valid signatures and sign unsigned PE files with a supervised signer. * @param root Materialized directory to sign. * @param options Signer, certificate identity and audit sink. - * @returns Resolves after verified sequential signing; rejects without retries. + * @returns Resolves after verified parallel cache restores and sequential signing; stops dispatch and drains active restores on failure, without retries. */ export function signWindowsCode(root: string, options: WindowsCodeSigningOptions): Promise diff --git a/apps/desktop/scripts/windows-runtime-signature.mjs b/apps/desktop/scripts/windows-runtime-signature.mjs index ee70330fd7..8780f182ca 100644 --- a/apps/desktop/scripts/windows-runtime-signature.mjs +++ b/apps/desktop/scripts/windows-runtime-signature.mjs @@ -101,7 +101,7 @@ async function inspectWindowsCode(files, inspect) { * Preserve valid signatures and sign unsigned PE files with a supervised signer. * @param {string} root - Owned final runtime directory. * @param {import('./windows-runtime-signature.mjs').WindowsCodeSigningOptions} options - Supervised signer, certificate identity, audit sink. - * @returns {Promise} Resolves only after sequential signatures and verification; no retries. + * @returns {Promise} Resolves after bounded cache restores and sequential hardware signing, each with verification; failures drain active restores before rejecting. */ export async function signWindowsCode(root, options) { const inspect = options.inspect ?? inspectWindowsRuntimeSignature @@ -116,14 +116,36 @@ export async function signWindowsCode(root, options) { else if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`) } options.record({ type: 'windows-code-signing-plan', files: files.length, unsigned: unsigned.length }) - for (const path of unsigned) { - await options.sign({ path, hash: 'sha256', isNest: false }) + async function verifySigned(path) { const signature = await inspect(path) if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) { throw new Error(`Windows code: signing verification failed: ${path}`) } options.record({ type: 'windows-code-signature-verified', path, ...signature }) } + const restored = new Set() + if (options.cache) { + let next = 0 + let failure + const worker = async () => { + while (failure === undefined && next < unsigned.length) { + const path = unsigned[next++] + try { + if (await options.cache.restore({ path, hash: 'sha256', isNest: false })) { + await verifySigned(path) + restored.add(path) + } + } catch (error) { failure ??= { error } } + } + } + await Promise.all(Array.from({ length: Math.min(options.cache.concurrency, unsigned.length) }, worker)) + if (failure !== undefined) throw failure.error + } + for (const path of unsigned) { + if (restored.has(path)) continue + await options.sign({ path, hash: 'sha256', isNest: false }) + await verifySigned(path) + } } /** diff --git a/apps/desktop/scripts/windows-signature-cache.d.mts b/apps/desktop/scripts/windows-signature-cache.d.mts index e68abdf345..dcc2301371 100644 --- a/apps/desktop/scripts/windows-signature-cache.d.mts +++ b/apps/desktop/scripts/windows-signature-cache.d.mts @@ -18,7 +18,7 @@ export interface WindowsSignatureCacheOptions { */ export function signatureCacheIdentity(files: readonly string[]): Promise -/** Counters include failed attempts; verification covers both restored and newly signed files, while restore time includes its verification and staging cleanup. */ +/** Counters include failed attempts; times sum per-file work. Verification covers restored and newly signed files; restore time includes its verification and staging cleanup. */ export interface WindowsSignatureCacheSummary { root: string identity: string @@ -34,8 +34,14 @@ export interface WindowsSignatureCacheSummary { signingMs: number } -/** Serial cache callback with a snapshot of this instance's completed and failed operations. */ +/** Serial signing callback with a separate hardware-free restore operation and shared counters. */ export type WindowsCachedSigner = ReturnType & { + /** + * Restore a hit without accessing hardware; distinct targets may run concurrently under the stage lock. + * @param configuration Unsigned target owned exclusively by the caller. + * @returns True after verified replacement, false for an absent entry without counting a signing miss; corrupt entries reject. The caller drains all restores before signing or reporting failure. + */ + restore(configuration: Parameters>[0]): Promise /** @returns Current counters and elapsed times; does not wait for queued operations. */ summary(): WindowsSignatureCacheSummary } @@ -43,7 +49,7 @@ export type WindowsCachedSigner = ReturnType & /** * Reuse complete signed files only after input, integrity, identity and timestamp checks. * @param options Cache policy, supervised signer, public-key verifier and audit sink. - * @returns Serial signer that stops its queue on any failure and never repairs invalid cache entries. + * @returns Serial fail-stop signer with hardware-free restores; invalid cache entries are never repaired. */ export function createCachedSigner(options: WindowsSignatureCacheOptions): WindowsCachedSigner diff --git a/apps/desktop/scripts/windows-signature-cache.mjs b/apps/desktop/scripts/windows-signature-cache.mjs index 1df9cd955f..39f75da374 100644 --- a/apps/desktop/scripts/windows-signature-cache.mjs +++ b/apps/desktop/scripts/windows-signature-cache.mjs @@ -125,7 +125,7 @@ export async function maintainSignatureCache(root, clear = false) { /** * Wrap a supervised signer with immutable entries keyed by unsigned bytes and signing policy. * @param {import('./windows-signature-cache.mjs').WindowsSignatureCacheOptions} options Cache policy and existing supervised operations. - * @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop callback with counters; cache hits still verify trust and timestamp. + * @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop signer plus hardware-free restores; cache hits still verify trust and timestamp. */ export function createCachedSigner(options) { let pending = Promise.resolve() @@ -142,7 +142,7 @@ export function createCachedSigner(options) { if (record.inputDigest !== inputDigest || record.identity !== options.identity) throw new Error('signature cache: invalid input record') return record } - async function sign(configuration) { + async function sign(configuration, restoreOnly = false) { if (configuration.hash !== 'sha256' || configuration.isNest) throw new Error('signature cache: only unsigned SHA-256 runtime files are supported') const inputDigest = digest(await regularFile(configuration.path)) const key = digest(JSON.stringify({ version: 1, inputDigest, identity: options.identity })) @@ -173,8 +173,9 @@ export function createCachedSigner(options) { } finally { statistics.restoreMs += performance.now() - start } statistics.hits++ options.record({ type: 'signature-cache-hit', key, path: configuration.path }) - return + return true } + if (restoreOnly) return false statistics.misses++ options.record({ type: 'signature-cache-miss', key, path: configuration.path }) const start = performance.now() @@ -210,9 +211,10 @@ export function createCachedSigner(options) { } finally { await rm(staging, { recursive: true, force: true }) } } const signer = configuration => { - pending = pending.then(() => sign(configuration)) + pending = pending.then(async () => { await sign(configuration) }) return pending } + signer.restore = configuration => sign(configuration, true) signer.summary = () => ({ root: options.root, identity: options.identity, ...statistics, avoidedSigningCalls: statistics.hits }) return signer } diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index 7153f135a3..6335fe8eaa 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' +import { resolveWindowsPackageSettings } from '../scripts/windows-package-settings.mjs' const WINDOWS = { platform: 'win32', arch: 'x64' } as const const MACOS = { platform: 'darwin', arch: 'arm64' } as const @@ -21,6 +22,30 @@ async function withDirectory(action: (directory: string) => Promise): Prom } describe('Desktop local packaging configuration', () => { + it('takes cache concurrency from the Windows file and defaults to four without ambient overrides', async () => { + await withDirectory(async (directory) => { + const parent = { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: '8' } + await writeFile(join(directory, '.env.windows'), '') + expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', parent, directory)).signatureCacheConcurrency).toBe(4) + await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=2\n') + expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', parent, directory)).signatureCacheConcurrency).toBe(2) + }) + }) + + it.each(['1', '2', '4', '8'])('accepts cache concurrency %s', (value) => { + const settings = resolveWindowsPackageSettings({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value }) + expect(settings.signatureCacheConcurrency).toBe(Number(value)) + }) + + it.each(['', '0', '-1', '9', '1.5', 'Infinity', '01'])('rejects invalid cache concurrency %s', (value) => { + expect(() => resolveWindowsPackageSettings({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value })).toThrow('integer from 1 to 8') + for (const options of [{ unsigned: true }, { prepareOnly: true }]) { + expect(() => { + validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value }, WINDOWS, options) + }).toThrow('integer from 1 to 8') + } + }) + it('selects the platform file, preserves literal secrets, and excludes stale ambient release settings', async () => { await withDirectory(async (directory) => { await writeFile(join(directory, '.env.windows'), '\uFEFFDSH_DESKTOP_APP_ID=com.example.windows\r\nDSH_DESKTOP_WINDOWS_TOKEN_PIN=" #!$%&literal "\r\nDSH_DESKTOP_WINDOWS_CER_FILE="keys/public certificate.cer"\r\n') diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index 5b8880ce6a..410c853fc8 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -29,7 +29,8 @@ vi.mock('node:fs', async importOriginal => ({ afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks() }) const environment = { DSH_DESKTOP_APP_ID: 'com.example.test', DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', - DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' } + DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin', + DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: '2' } function supervisor(failure?: string) { vi.stubEnv('npm_execpath', 'fixture-pnpm.cjs') @@ -56,6 +57,10 @@ it('requires one signing preflight before building, then records only the comple for (const call of run.run.mock.calls) { if (call[0].startsWith('run prepare:') || call[0].includes('smoke-packaged-runtime')) { expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN') + expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY') + } + if (call[0].startsWith('run sign:primary-runtime')) { + expect(call[3].env).toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY', '2') } } expect(writeFileSync).toHaveBeenCalledOnce() diff --git a/apps/desktop/tests/primary-runtime-signing.spec.ts b/apps/desktop/tests/primary-runtime-signing.spec.ts index 242c93f339..5c7b4aa080 100644 --- a/apps/desktop/tests/primary-runtime-signing.spec.ts +++ b/apps/desktop/tests/primary-runtime-signing.spec.ts @@ -26,6 +26,86 @@ async function fixture(names = ['a.exe', 'b.pyd', 'vendor.dll']): Promise { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) +it('bounds overlapping cache restores and verifies all hits before serial hardware misses', async () => { + const root = await fixture(['a.exe', 'b.exe', 'c.exe', 'd.exe']) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const signed = new Set() + const verified = new Set() + let active = 0 + let peak = 0 + let hits = 0 + const sign = vi.fn(async ({ path }: { path: string }) => { + expect(active).toBe(0) + expect(hits).toBe(2) + expect(verified.has(join(root, 'a.exe')) && verified.has(join(root, 'b.exe'))).toBe(true) + signed.add(path) + }) + const operation = signWindowsCode(root, { thumbprint, record: () => {}, sign, + inspect: async (path) => { + if (!signed.has(path)) return unsigned + verified.add(path) + return valid + }, + cache: { concurrency: 2, restore: async ({ path }) => { + active++ + peak = Math.max(peak, active) + if (active === 2) entered.resolve(undefined) + await release.promise + active-- + if (path.endsWith('a.exe') || path.endsWith('b.exe')) { signed.add(path); hits++; return true } + return false + } }, + }) + try { + await Promise.race([entered.promise, operation]) + expect(sign).not.toHaveBeenCalled() + } finally { release.resolve(undefined); await operation } + expect(peak).toBe(2) + expect(sign.mock.calls.map(([request]) => request.path)).toEqual(['c.exe', 'd.exe'].map(name => join(root, name))) +}) + +it.each(['restore', 'verification', 'audit'])('drains active cache restores after %s failure without dispatching more work', async (failure) => { + const root = await fixture(['a.exe', 'b.exe', 'c.exe']) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const failed = Promise.withResolvers() + const restored = new Set() + const calls: string[] = [] + const sign = vi.fn(async () => {}) + let settled = false + const operation = signWindowsCode(root, { thumbprint, sign, + record: (event) => { + if (failure === 'audit' && 'type' in event && event.type === 'windows-code-signature-verified') { + failed.resolve(undefined) + throw new Error('audit failed') + } + }, + inspect: async (path) => { + if (!restored.has(path)) return unsigned + if (failure === 'verification' && path.endsWith('a.exe')) { failed.resolve(undefined); throw new Error('verification failed') } + return valid + }, + cache: { concurrency: 2, restore: async ({ path }) => { + calls.push(path) + if (path.endsWith('b.exe')) { entered.resolve(undefined); await release.promise; return false } + await entered.promise + if (failure === 'restore') { failed.resolve(undefined); throw new Error('restore failed') } + restored.add(path) + return true + } }, + }).then(() => { settled = true; return undefined }, (error: unknown) => { settled = true; return error }) + try { + await Promise.race([failed.promise, operation]) + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) + expect(sign).not.toHaveBeenCalled() + } finally { release.resolve(undefined); await operation } + expect(await operation).toEqual(new Error(`${failure} failed`)) + expect(calls).toEqual(['a.exe', 'b.exe'].map(name => join(root, name))) + expect(sign).not.toHaveBeenCalled() +}) + it('selects real PE code, including .node, without signing foreign native modules or data', async () => { const root = await fixture(['runtime.node', 'python.exe', 'extensionless', 'custom.binary']) await mkdir(join(root, 'nested')) diff --git a/apps/desktop/tests/windows-signature-cache.spec.ts b/apps/desktop/tests/windows-signature-cache.spec.ts index 975feb6fbe..9514b102e5 100644 --- a/apps/desktop/tests/windows-signature-cache.spec.ts +++ b/apps/desktop/tests/windows-signature-cache.spec.ts @@ -23,6 +23,22 @@ async function fixture(t: TestContext) { return { root, path, cache, state, options, request } } +test('restore misses leave inputs and hardware untouched, then share a cached payload across targets', async (t) => { + const f = await fixture(t) + const signer = createCachedSigner(f.options) + assert.equal(await signer.restore(f.request), false) + assert.equal(f.state.hardwareCalls, 0) + assert.equal(signer.summary().misses, 0) + assert.equal(await readFile(f.path, 'utf8'), 'original') + await signer(f.request) + const targets = [join(f.root, 'first.node'), join(f.root, 'second.node')] + await Promise.all(targets.map(path => writeFile(path, 'original'))) + assert.deepEqual(await Promise.all(targets.map(path => signer.restore({ ...f.request, path }))), [true, true]) + assert.deepEqual(await Promise.all(targets.map(path => readFile(path, 'utf8'))), ['signed:original', 'signed:original']) + assert.equal(f.state.hardwareCalls, 1) + assert.equal(signer.summary().hits, 2) +}) + test('unchanged bytes restored in another file avoid another hardware call', async (t) => { const f = await fixture(t) await createCachedSigner(f.options)(f.request) @@ -71,7 +87,7 @@ test('different content or signing policy misses the cache', async (t) => { assert.equal(f.state.hardwareCalls, 3) }) -for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input`, async (t) => { +for (const restoreOnly of [false, true]) for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input (restore-only: ${restoreOnly})`, async (t) => { const f = await fixture(t) await createCachedSigner(f.options)(f.request) await writeFile(f.path, 'original') @@ -79,7 +95,7 @@ for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops assert(entry) await writeFile(join(f.cache, entry, corrupt), 'corrupt') const signer = createCachedSigner(f.options) - await assert.rejects(signer(f.request)) + await assert.rejects(restoreOnly ? signer.restore(f.request) : signer(f.request)) assert.equal(signer.summary().validationFailures, 1) assert.equal(signer.summary().avoidedSigningCalls, 0) assert.equal(f.state.hardwareCalls, 1) From d4c5a1723f9b9debaa7755ef85550dfeb82b80ff Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:36:49 +0800 Subject: [PATCH 162/284] test(desktop): isolate update UI in directory-picker preload integration --- .../tests/client-flow.client.spec.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index 11f6083a2a..2037b3edd7 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -23,8 +23,9 @@ vi.mock(desktopIpc.electron, () => ({ vi.mock('../../../../apps/desktop/src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn(), syncWindowFullscreen: vi.fn() })) vi.mock('../../../../apps/desktop/src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) vi.mock('../../../../apps/desktop/src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() })) +vi.mock('../../../../apps/desktop/src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() })) -afterEach(() => { cleanup(); vi.unstubAllGlobals() }) +afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); desktopIpc.invoke.mockReset() }) const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const @@ -164,7 +165,9 @@ describe('directory-picker-native client half', () => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) - it('consumes the actual Desktop preload bridge and sends its directory-pick IPC', async () => { + it.each(['win32', 'darwin'] as const)('consumes the actual Desktop preload directory bridge on %s', async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + vi.resetModules() vi.stubGlobal('location', new URL('dsh-app://app/')) // Desktop's preload is typechecked by its own compiler program. const preload = '../../../../apps/desktop/src/preload-app.ts' From 8b8f10a8c6f8e181b4a3bb681d25d0f4bd24a890 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 15:39:48 +0800 Subject: [PATCH 163/284] docs(ui-sidebar): scope the Windows caption bubble claim The previous sentence claimed every caption icon bubble opens below the caption, but only the sidebar toggle and New Session derive side=bottom; an occupying sidebar.toggle.badge keeps the default side. --- packages/client/ui-sidebar/README.i18n.yaml | 4 ++-- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index e45ccfc67e..9703fded4a 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -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/ui-sidebar/README.md -README.md: beedede7fa15bbd1e60b3b688302a95c3c9d5f41 -README.zh.md: adc3dfff1ec946e07bc5a4790f691123a5a32ed0 +README.md: 70489f5f4a8892996f455d21a9493a865990dc49 +README.zh.md: c9691315284c62b126043d070ec071d410fe38ce diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index beedede7fa..70489f5f4a 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -41,7 +41,7 @@ The top expand button hosts the optional, non-interactive `sidebar.toggle.badge` During a live collapse, the expanded content fades out at its current width, the upper controls share one fade and leftward translation into the 56px rail, and the layout's column slide ends the motion. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation. -On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region. Their hover bubbles open below the caption, where the Desktop-owned menu text cannot cover them. +On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region. The sidebar toggle and New Session bubbles open below the caption, where the Desktop-owned menu text cannot cover them; a `sidebar.toggle.badge` occupant keeps its default side. ### macOS desktop diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index adc3dfff1e..c969131528 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -41,7 +41,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 实时收起时,展开内容在当前宽度淡出,上方控件共用同一段透明度渐变,并向左平移进入 56px 轨道,由布局的栏滑动结束整段动画。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。固定在底部的 `sidebar.settings` 控件共用相同的透明度渐变时序,但不发生横向位移。 -在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。其悬停提示在顶栏下方展开,Desktop 菜单文字不会盖住它们。 +在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。侧栏开关与新建会话的悬停提示在顶栏下方展开,Desktop 菜单文字不会盖住它们;占用 `sidebar.toggle.badge` 的控件保持默认展开方向。 ### macOS 桌面 From e44efedeb00498e1522160578e788f98d597569a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 20 Sep 2026 15:44:52 +0800 Subject: [PATCH 164/284] fix(session): isolate corrupt compressed headers during discovery --- apps/web/tests/preset-migration.snapshot.ts | 8 ++++ .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../session-persistence-jsonl/src/index.ts | 22 +++++------ .../tests/catalog-migration.spec.ts | 39 ++++++++++++++----- .../tests/zstd.spec.ts | 10 +++-- 7 files changed, 60 insertions(+), 27 deletions(-) diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index 42e69f65ef..37fdd06073 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -51,8 +51,14 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t data: { turn: 1, reason: { kind: 'completed' } } }) + '\n')), ])) const brokenSource = await readFile(brokenPath) + const corruptHeaderId = SessionId('preset-migration-corrupt-header') + const corruptHeaderPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, corruptHeaderId, 2, 'zstd') + const corruptHeaderSource = Buffer.from('invalid Zstandard header frame') + await mkdir(dirname(corruptHeaderPath), { recursive: true }) + await writeFile(corruptHeaderPath, corruptHeaderSource) const listed = await scaffold.ctx.sessionController.list({}, new AbortController().signal) expect(listed.items.map(item => item.sessionId)).toEqual(expect.arrayContaining([id, childId, brokenId])) + expect(listed.items.map(item => item.sessionId)).not.toContain(corruptHeaderId) const reader = await scaffold.ctx.sessionPersistence.open(id, 'read') try { @@ -123,6 +129,8 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t const context = { sessionIds: [id], cwd: scaffold.workspaceCwd } expect(normalizeSessionSnapshots([published], context)).toEqual(normalizeSessionSnapshots([expected], context)) expect(await readFile(predecessor)).toEqual(source) + await expect(scaffold.ctx.sessionPersistence.open(corruptHeaderId, 'read')).rejects.toThrow(/invalid frame magic/) + expect(await readFile(corruptHeaderPath)).toEqual(corruptHeaderSource) expect((await readdir(dirname(predecessor))).filter(name => name.endsWith('.jsonl.zstd')).sort()) .toEqual(['session.v2.jsonl.zstd', `session.v${SESSION_FORMAT_VERSION}.jsonl.zstd`]) } finally { diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 4f0d2c2fe3..4b42d7fa5e 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -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/session/session-persistence-jsonl/README.md -README.md: 609368aa42f1ba75d818a74877fcd574bf9d5ddf -README.zh.md: 07c0dc94270ddb69812ec8443688a505269eba59 +README.md: 5846404961caaef7a91fb4dda562655d15fd2be9 +README.zh.md: b7dd646e57b75fe47f3683d2b230dace628f8eee diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 609368aa42..5846404961 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -81,7 +81,7 @@ The current-generation scanner applies the current codec owner’s structural ad `open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. -Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It finds candidate direct children from headers, reads each child's own descriptor through historical codecs, and retains compact evidence plus source revisions. It neither prepares child catalogs nor publishes child successors. Unreadable or unsupported headers are omitted from discovery. Child decoding and descriptor-field failures produce warnings naming the child path and retain header identity through `subagent/catalog` when the parent has no complete entry; healthy children and existing parent catalog entries remain available. Opening a damaged child still reports that child's error. Missing, unsupported, or multiple descriptors likewise produce unknown-mode membership without inventing a label. Published unknown entries remain browsable; child history reads retry the actual log and resolve its mode from a valid descriptor. Preparation rechecks membership and inspected source revisions before returning, reuse, and publication, including failed child reads so a repaired child invalidates stale preparation. Source drift retries a read open once and refuses a write open. Cancellation still aborts the operation. Current V4 opens bypass discovery and validate catalog fields, uniqueness, and current delivery ownership before exposing events. +Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It finds candidate direct children from headers, reads each child's own descriptor through historical codecs, and retains compact evidence plus source revisions. It neither prepares child catalogs nor publishes child successors. Unreadable or unsupported headers, including corrupt Zstandard header frames, are omitted from discovery and `list()`. Direct access to a corrupt compressed header still rejects; header I/O and cancellation errors propagate. Child decoding and descriptor-field failures produce warnings naming the child path and retain header identity through `subagent/catalog` when the parent has no complete entry; healthy children and existing parent catalog entries remain available. Opening a damaged child still reports that child's error. Missing, unsupported, or multiple descriptors likewise produce unknown-mode membership without inventing a label. Published unknown entries remain browsable; child history reads retry the actual log and resolve its mode from a valid descriptor. Preparation rechecks membership and inspected source revisions before returning, reuse, and publication, including failed child reads so a repaired child invalidates stale preparation. Source drift retries a read open once and refuses a write open. Cancellation still aborts the operation. Current V4 opens bypass discovery and validate catalog fields, uniqueness, and current delivery ownership before exposing events. Historical `stat` and `list` revisions require metadata work proportional to the root’s Session count. Fresh body preparation scans all selected headers and decodes direct-child bodies; memo reuse still scans membership and checks revisions. Read-only access never publishes an upgrade, so cold processes and evicted preparations repeat that work. Current V4 body reads and revisions avoid the historical corpus scan. See the [measured costs and diagnostic command](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md#catalog-scan-measurements). diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 07c0dc9427..b7dd646e57 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -81,7 +81,7 @@ kind: "package-reference" `open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;每个嵌套对象和数组都会冻结,句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 -历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:从 header 找到候选直属子 Session,通过历史编解码器逐个读取其自身 descriptor,保留紧凑证据与来源修订。此过程不准备子目录,也不发布子后继。不可读或不支持的 header 不参与发现。子日志解码或 descriptor 字段失败会产生带子路径的警告;父目录没有完整条目时,通过 `subagent/catalog` 保留 header 身份信息。健康子项和已有父目录项仍可使用。打开损坏子 Session 时仍报告该子会话的错误。缺失、不支持或多个 descriptor 同样生成模式未知的目录项,不编造标签。已发布的未知条目仍可浏览;读取子历史时会重试实际日志,并从有效 descriptor 确定模式。准备返回、复用与发布前会重新检查成员集合及已检查来源的修订,也包括读取失败的子日志,使修复后的子日志能够使旧准备缓存失效。来源变化时只读打开重试一次,写打开拒绝发布。取消仍会中止操作。当前 V4 打开跳过发现,并在暴露事件前校验目录字段、唯一性及当前投递归属。 +历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:从 header 找到候选直属子 Session,通过历史编解码器逐个读取其自身 descriptor,保留紧凑证据与来源修订。此过程不准备子目录,也不发布子后继。不可读或不支持的 header(包括损坏的 Zstandard header 帧)不参与发现,也不出现在 `list()` 中。直接访问损坏的压缩 header 仍会失败;header 的 I/O 错误与取消错误继续传播。子日志解码或 descriptor 字段失败会产生带子路径的警告;父目录没有完整条目时,通过 `subagent/catalog` 保留 header 身份信息。健康子项和已有父目录项仍可使用。打开损坏子 Session 时仍报告该子会话的错误。缺失、不支持或多个 descriptor 同样生成模式未知的目录项,不编造标签。已发布的未知条目仍可浏览;读取子历史时会重试实际日志,并从有效 descriptor 确定模式。准备返回、复用与发布前会重新检查成员集合及已检查来源的修订,也包括读取失败的子日志,使修复后的子日志能够使旧准备缓存失效。来源变化时只读打开重试一次,写打开拒绝发布。取消仍会中止操作。当前 V4 打开跳过发现,并在暴露事件前校验目录字段、唯一性及当前投递归属。 历史格式的 `stat` 与 `list` 修订号需要与根目录 Session 数量成正比的元数据工作。首次正文准备扫描所有所选 header 并解码直属子正文;复用准备缓存仍扫描成员集合并检查修订。只读访问从不发布升级,因此冷进程与被淘汰的准备缓存会重复这些工作。当前 V4 正文读取和修订号避开历史全库扫描。见[实测成本与诊断命令](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md#catalog-scan-measurements)。 diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 24ee0d013e..1a89b76d29 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -1067,7 +1067,7 @@ class JsonlSessionPersistence extends SessionPersistence { try { header = await this.readGenerationHeader(selected, undefined, signal) } catch (error: unknown) { - if (error instanceof SessionFormatUnsupportedError) continue + if (error instanceof SessionFormatUnsupportedError || error instanceof SessionPersistenceCorruptionError) continue throw error } if (header === undefined) { @@ -1406,7 +1406,7 @@ class JsonlSessionPersistence extends SessionPersistence { } } - /** Read and validate only the independently compressed header frame. */ + /** Read only the header frame; compression failures reject as corruption, while I/O and cancellation propagate. */ private async readFirstZstdLine(path: string, signal?: AbortSignal): Promise { signal?.throwIfAborted() const handle = await open(path, 'r') @@ -1422,21 +1422,21 @@ class JsonlSessionPersistence extends SessionPersistence { signal?.throwIfAborted() content = Buffer.concat([content, chunk.subarray(0, bytesRead)]) signal?.throwIfAborted() - const first = scanZstdFrames(content, 1).frames[0] - signal?.throwIfAborted() - if (first === undefined) continue - let plaintext: Buffer try { + const first = scanZstdFrames(content, 1).frames[0] + if (first === undefined) continue + const plaintext = await decompressZstdFrame(content.subarray(first.start, first.end)) signal?.throwIfAborted() - plaintext = await decompressZstdFrame(content.subarray(first.start, first.end)) + assertZstdHeaderFrame(plaintext) + return plaintext.subarray(0, -1).toString('utf8') } catch (error) { /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */ if (signal?.aborted) signal.throwIfAborted() - throw new Error('corrupt Zstandard session log: header frame failed validation', { cause: error }) + throw new SessionPersistenceCorruptionError( + `corrupt Zstandard session log: header frame failed validation: ${String(error)} (raw log: ${path})`, + { cause: error }, + ) } - signal?.throwIfAborted() - assertZstdHeaderFrame(plaintext) - return plaintext.subarray(0, -1).toString('utf8') } } finally { await handle.close() diff --git a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts index e4c6d677a9..6abb14c0db 100644 --- a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts @@ -58,15 +58,36 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', await expect(f.ctx.sessionPersistence.open(f.parent, access)).rejects.toThrow('wrong Session') }) - it('opens the parent when another Session header cannot be read', async () => { - const f = await fixture() - const path = await f.write('child', [], true) - await writeFile(path, compression === 'none' ? '{broken header\n' : await compressZstdFrame('{broken header\n')) - expect(await f.read()).toEqual([]) - const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') - await writer.close() - expect(await readFile(generationLogPath(f.root, undefined, f.parent, 4, compression))).not.toHaveLength(0) - }) + it.each(compression === 'none' ? ['json'] as const : ['json', 'magic', 'checksum', 'lines'] as const)( + 'opens the parent when another Session header has corrupt %s', async (damage) => { + const f = await fixture() + await f.write('child', [f.descriptor], true) + const path = await f.write('unrelated', []) + let damaged: Buffer + if (damage === 'json') { + damaged = compression === 'none' ? Buffer.from('{broken header\n') : await compressZstdFrame('{broken header\n') + } else if (damage === 'lines') { + damaged = await compressZstdFrame('{}\n{}\n') + } else { + damaged = await readFile(path) + const offset = damage === 'magic' ? 0 : damaged.length - 1 + damaged[offset] = damaged[offset]! ^ 0xFF + } + await writeFile(path, damaged) + expect((await f.ctx.sessionPersistence.list()).map(row => row.header.id).sort()).toEqual(['child', f.parent]) + const expected = [{ type: 'subagent/catalog', data: { childId: 'child', mode: 'continuable', label: 'old child' } }] + expect(await f.read()).toMatchObject(expected) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + try { expect((await writer.read()).events).toMatchObject(expected) } finally { await writer.close() } + expect(await readFile(generationLogPath(f.root, undefined, f.parent, 4, compression))).not.toHaveLength(0) + for (const access of ['read', 'write'] as const) { + await expect(f.ctx.sessionPersistence.open(SessionId('unrelated'), access)).rejects.toThrow() + } + expect(await readFile(path)).toEqual(damaged) + await expect(readFile(generationLogPath(f.root, undefined, SessionId('unrelated'), 4, compression))) + .rejects.toMatchObject({ code: 'ENOENT' }) + }, + ) it('invalidates lightweight revisions on child membership and contents while retaining stable tokens', async () => { const f = await fixture() diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index 4ec279ac61..f7b2f98628 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -782,7 +782,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { expect((await readAll(ctx.sessionPersistence, header.id)).events).toEqual([...oneTurnLog(), ...secondTurn]) }) - it('skips empty, incomplete, and non-header compressed artifacts while rejecting malformed header frames', async () => { + it('skips unreadable compressed headers during discovery while rejecting targeted reads', async () => { const root = await freshRoot() for (const [id, content] of [ ['empty', Buffer.alloc(0)], @@ -803,7 +803,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { JSON.stringify({ type: 'turn/start' }), '', ].join('\n'))) - await expect(ctx.sessionPersistence.list()).rejects.toThrow(/first frame is not exactly one header line/) + expect(await ctx.sessionPersistence.list()).toEqual([]) await expect(ctx.sessionPersistence.open(twoLinesId, 'read')) .rejects.toThrow(/first frame is not exactly one header line/) }) @@ -824,7 +824,11 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { .rejects.toThrow(/empty or header-less Zstandard session log/) await expect(ctx.sessionPersistence.open(SessionId('empty-header'), 'read')) .rejects.toThrow(/first frame is not exactly one header line/) - await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header frame failed validation/) + expect(await ctx.sessionPersistence.list()).toEqual([]) + await expect(ctx.sessionPersistence.stat(SessionId('bad-checksum'))) + .rejects.toThrow(/header frame failed validation/) + await expect(ctx.sessionPersistence.open(SessionId('bad-checksum'), 'read')) + .rejects.toThrow(/frame at byte 0 failed validation/) }) }) From 78dcbc8587b8e0f89bc7044ff254fb0ef2340c9b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 20 Sep 2026 15:50:41 +0800 Subject: [PATCH 165/284] feat(infra): unify Web and Desktop launch commands Web and Desktop now share one command pair: start:* launches the artifacts of a prior complete build and dev:* builds first, then launches. start:web is new and spells out the same source launch vector as `pnpm dsh web`; dev:web runs `pnpm run build`, the three rebuild watchers, and `dsh web` in one terminal, with --skip-build and --no-serve for the previous two-terminal flow. The launcher gate pins both scripts to the dsh CLI, and a root Makefile names the same commands as make web|dev-web|desktop|dev-desktop|build. The dev-web stage supervisor waits for stages the terminal already interrupted, escalates only survivors, forwards SIGTERM once, and reports a stage that exits on its own. Signal listeners are persistent because the tsdown watchers bundle signal-exit, which re-raises a signal when it finds no other listener. --- AGENTS.md | 2 + Makefile | 31 ++ apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/web/tests/hmr-live.e2e.ts | 8 +- apps/web/vite.config.ts | 2 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 5 +- docs/api-gateway.zh.md | 5 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 15 +- docs/development.zh.md | 15 +- package.json | 1 + packages/client/hmr/README.i18n.yaml | 4 +- packages/client/hmr/README.md | 2 +- packages/client/hmr/README.zh.md | 2 +- scripts/dev-web.spec.ts | 99 +++++- scripts/dev-web.ts | 325 ++++++++++++++---- .../verify-application-entrypoints.spec.ts | 24 +- scripts/verify-application-entrypoints.ts | 35 +- 27 files changed, 482 insertions(+), 125 deletions(-) create mode 100644 Makefile diff --git a/AGENTS.md b/AGENTS.md index 7a128b6167..aa83ae8c08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,8 @@ pnpm run test:docs # quick documentation checks (no build; doc-quick aggreg pnpm run website:build # VitePress build (doubles as dead-link check) pnpm dsh --profile headless "task" # run one task from source (needs DEEPSEEK_API_KEY) pnpm run demo:ptc -- "task" # headless PTC mode run (needs key) +pnpm run dev:web | dev:desktop # build, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build +make web|dev-web|desktop|dev-desktop|build # the same commands; ARGS='--no-open' forwards options ``` ### Host sandbox failures diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..4b95df6766 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +# Short names for the Web and Desktop application commands. The package.json +# scripts they call stay the source of truth; docs/development.md documents both. +.DEFAULT_GOAL := help +.PHONY: help build web desktop dev-web dev-desktop + +PNPM ?= pnpm +ARGS ?= + +help: + @echo "make build pnpm run build complete repository build" + @echo "make web pnpm run start:web serve the built Web artifacts from source" + @echo "make desktop pnpm run start:desktop launch the built Desktop artifacts" + @echo "make dev-web pnpm run dev:web build, serve, and rebuild Web on source edits" + @echo "make dev-desktop pnpm run dev:desktop build, then launch Desktop" + @echo "ARGS='--no-open --port 3081' forwards options to the launched application;" + @echo "the Web commands accept dsh web flags, the Desktop launcher accepts none." + +build: + $(PNPM) run build + +web: + $(PNPM) run start:web $(ARGS) + +desktop: + $(PNPM) run start:desktop $(ARGS) + +dev-web: + $(PNPM) run dev:web $(ARGS) + +dev-desktop: + $(PNPM) run dev:desktop $(ARGS) diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 0cff0e5cdb..cb0eddf183 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -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 apps/cli/reference/README.md -README.md: cf0e1400f2dc8cd29cc59cefbdda379df2e0b899 -README.zh.md: a01818f59ccf54f3f826c68505bfb830a9316664 +README.md: 33465e8c96df9cbfbecc038616caf297146ec971 +README.zh.md: d25d27851ca789a3db358157368b97c3669ed79d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index cf0e1400f2..33465e8c96 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -85,7 +85,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web profile -`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles. +`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index a01818f59c..d25d27851c 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -87,7 +87,7 @@ dsh --profile tui ## Web Profile -`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。 +`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。 ```sh dsh web diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 455501862e..a37d3e8d49 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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 apps/desktop/README.md -README.md: 6c9a6a8784732e6fa1369df5632391b789786b22 -README.zh.md: a0f2184d911eeab46902670cf992b6847e2bca31 +README.md: 2d7882ec6bf8827dc65e2e9653c407908c0fbab5 +README.zh.md: 2a0277fa824ce43e45e8408df0ac029bb38c16a5 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6c9a6a8784..2d7882ec6b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -93,7 +93,7 @@ After an explicit build, `start:desktop` reconstructs the disposable project and pnpm run start:desktop ``` -Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. +The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. ## Package diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a0f2184d91..2a0277fa82 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -93,7 +93,7 @@ pnpm run dev:desktop pnpm run start:desktop ``` -Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 +Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 ## 打包 diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 716176a176..87adea12cc 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -1,4 +1,4 @@ -/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */ +/** Built dsh web + the `pnpm run dev:web --no-serve` watchers → browser HMR, with no page reload. */ import { existsSync, globSync, statSync } from 'node:fs' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -108,12 +108,14 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a const failures: unknown[] = [] try { subprocessFiber = await subprocessCtx.plugin(LocalSubprocessRuntime) + // Watchers only: the built `dsh web` below is the server under test, and the + // built tree is this lane's precondition rather than something to rebuild. watcher = subprocessCtx.subprocess.spawn(spawnSpec( - ['pnpm', 'run', 'dev:web'], + ['pnpm', 'run', 'dev:web', '--skip-build', '--no-serve'], REPO_ROOT, { ...clientBuildEnvironment }, )) - await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web') + await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web --skip-build --no-serve') host = subprocessCtx.subprocess.spawn(spawnSpec( [process.execPath, binPath, 'web', '--no-open', '--port', '0'], world, diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ddf3683c59..9984bf38de 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,7 +10,7 @@ import { productWebBundleIsolation } from './product-isolation.ts' const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)) const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. ' + 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. ' - + 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.' + + 'For client-plugin HMR, run `pnpm run dev:web`, which starts `dsh web` and the rebuild watchers together.' const DEFAULT_CLIENT_TITLE = 'DSH Local Build' /** Escape build-time text before placing it in the HTML title element. */ diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index fa62039df7..673807013b 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -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 docs/api-gateway.md -api-gateway.md: 2cf7b2f345991fd20639e1294662cc64fc02093d -api-gateway.zh.md: 5b80db08de49591b27751e187aa6278384a7444e +api-gateway.md: aa8339219a563901d5d132f4f401325ca30a3d53 +api-gateway.zh.md: 99b2d9866d38e520880e5d13115c2e6370fca474 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 2cf7b2f345..aa8339219a 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -138,14 +138,13 @@ SRC solves only dispatch for a Host process running from source. The Client does ## Development mode -Web development prepares current Host, Client, and Web artifacts with `pnpm run build`, then runs the source Host and the Client plugin watcher in separate terminals: +Web development runs one command, which builds current Host, Client, and Web artifacts, starts the source Host, and keeps Client bundles rebuilt: ```sh -pnpm dsh web pnpm run dev:web ``` -`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dsh.client` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS. +`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; the `dev:web` watchers rebuild the client-face type emit, Client plugin and library bundles, and the Web shell on source edits. They do not analyze Host decorators or generate Remote Client DTS; only the complete build that `dev:web` runs first (or `pnpm run build`) does. Changing only a Remote method's implementation body without changing its contract does not require regenerating the Typert files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, rerun the ordered lib build so the Host generates the strict contract before the Client compiles and bundles the new contribution: diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 5b80db08de..99b2d9866d 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -138,14 +138,13 @@ SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Ho ## 开发模式 -Web 开发先使用 `pnpm run build` 准备当前 Host、Client 与 Web 产物,然后在两个终端中分别运行源码 Host 和 Client plugin watcher: +Web 开发只需一条命令:它先构建当前 Host、Client 与 Web 产物,再启动源码 Host,并持续重建 Client bundle: ```sh -pnpm dsh web pnpm run dev:web ``` -`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dsh.client` 声明的 Client 插件并重写其 `lib/client.js`,它不会分析 Host decorator,也不会生成 Remote Client DTS。 +`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 的 watcher 会在源码修改时重建 client 侧类型输出、Client 插件与库 bundle 以及 Web 壳层。它们不会分析 Host decorator,也不会生成 Remote Client DTS;只有 `dev:web` 首先执行的完整构建(或 `pnpm run build`)才会。 只修改 Remote 方法实现体而不改变约定时,无需重新生成 Typert 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,重新执行有序 lib 构建,让 Host 先生成严格约定,再让 Client 编译并打包新的贡献: diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 3d9fba9385..66b457c8fa 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 docs/architecture.md -architecture.md: 0bbf5ef7f4c0444635178ba0ba85fbb9527d76ec -architecture.zh.md: 0d28dc349fd9c655529bcc43deabc47055e0d394 +architecture.md: 5e0bb09770e013349bcc1326bea1eab9cdc3e595 +architecture.zh.md: a146c93c14ef0f563babf98f7311f1e5686895cc diff --git a/docs/architecture.md b/docs/architecture.md index 0bbf5ef7f4..5e0bb09770 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ Composition mechanics are in [app-boot](../packages/boot/app-boot/README.md#prof Supported Node applications launch through named `dsh` profiles. The shipped profiles are `web`, `headless`, `sdk`, `sdk-minimal`, and `acp`, selected with `dsh --profile ` or `dsh `. `plugin` names the management command; a profile with that name requires `--profile plugin`. The TypeScript SDK resolves its same-version `dsh` dependency and selects `sdk`; custom plugin composition remains a profile plus ordered patch files, not another executable or inline application tree. `sdk-minimal` is a repository-owned standalone bundle behind the same launcher, not a caller-supplied Cordis tree. -Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, and root demo in an explicit class and rejects a Node application path that bypasses `dsh`. +Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, root demo, and the root `start:web` and `dev:web` scripts in an explicit class and rejects a Node application path that bypasses `dsh`. The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime--`, and the client launches `dsh --profile sdk` with an explicit Harness home by default. The minimal example selects the shipped `sdk-minimal` profile. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 0d28dc349f..a146c93c14 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -44,7 +44,7 @@ dsh --profile web --dump-config 受支持的 Node 应用通过具名 `dsh` profile 启动。随附 profile 为 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp`,可通过 `dsh --profile ` 或 `dsh ` 选择。`plugin` 表示管理命令;同名 profile 必须用 `--profile plugin` 选择。TypeScript SDK 会解析其同版本 `dsh` 依赖并选择 `sdk`;自定义插件组合继续由 profile 与有序 patch 文件表达,而不是另一个可执行文件或内联应用树。`sdk-minimal` 是位于同一 launcher 后的仓库自有独立组合包,而不是由调用方提供的 Cordis 配置树。 -Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码与根 demo 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。 +Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码、根 demo 以及根脚本 `start:web` 与 `dev:web` 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。 Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime--`,客户端默认以显式 Harness home 启动 `dsh --profile sdk`。极简示例选择随附的 `sdk-minimal` profile。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 8959b05a1d..fd6198a8ca 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -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 docs/development.md -development.md: ce8f1e7746562e696c96e6359ec9fcfde596d6e2 -development.zh.md: 85e95946dfd7210201f0807880d5ed1a92859356 +development.md: 7e8d0ded70ca880ce986cb3879d959fbe2a04304 +development.zh.md: 84d0d76d8c76f829c0ed928d28336df3e58ed7f0 diff --git a/docs/development.md b/docs/development.md index ce8f1e7746..7e8d0ded70 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,7 +83,7 @@ Both tsdown passes use the same complete workspace match. They neither scan buil Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. -`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` still requires the artifact tree from a prior complete build, but it samples the current version and Git state once at startup and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. +`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` runs that complete build first (`--skip-build` reuses an existing artifact tree instead), then samples the current version and Git state once and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. @@ -156,6 +156,19 @@ The PTC mode demo runs the same headless profile with code presentation enabled: pnpm run demo:ptc -- "summarize this workspace" ``` +### Application commands + +Web and Desktop share one command pair. `start:*` launches the artifacts of a prior `pnpm run build`; `dev:*` runs that build first and then launches. Web additionally keeps client bundles rebuilt on source edits, because its Host runs from source while the browser loads built bundles: + +```sh +pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) +pnpm run dev:web # build, serve, and rebuild Web client bundles on source edits +pnpm run start:desktop # launch built Desktop artifacts +pnpm run dev:desktop # build, then launch Desktop +``` + +Arguments after a Web command reach `dsh web`, for example `pnpm run dev:web --no-open --port 3081`; `dev:web` also accepts `--skip-build` to reuse the existing artifact tree and `--no-serve` to run only the rebuild watchers beside a server started elsewhere. Both Web commands use the normal Harness home, while the Desktop commands use the isolated development home described in the [Desktop README](../apps/desktop/README.md). The root `Makefile` names the same commands as `make web`, `make dev-web`, `make desktop`, `make dev-desktop`, and `make build`; `ARGS='--no-open'` forwards options. + ### TODO markers Use one of three comment tags to flag known issues in the code, ordered by urgency: diff --git a/docs/development.zh.md b/docs/development.zh.md index 85e95946df..84d0d76d8c 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -87,7 +87,7 @@ pnpm run build:web Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。 -`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 仍需要先执行完整构建来准备产物树,但会在启动时读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 +`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 会先执行这次完整构建(`--skip-build` 则复用现有产物树),再读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。 @@ -160,6 +160,19 @@ PTC mode 演示启用代码式工具展示,并运行同一个 headless profile pnpm run demo:ptc -- "summarize this workspace" ``` +### 应用命令 + +Web 与 Desktop 共用同一对命令。`start:*` 启动上一次 `pnpm run build` 的产物;`dev:*` 先执行该构建再启动。Web 还会在源码修改时持续重建 client bundle,因为它的 Host 从源码运行,而浏览器加载的是构建产物: + +```sh +pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) +pnpm run dev:web # build, serve, and rebuild Web client bundles on source edits +pnpm run start:desktop # launch built Desktop artifacts +pnpm run dev:desktop # build, then launch Desktop +``` + +Web 命令后面的参数会传给 `dsh web`,例如 `pnpm run dev:web --no-open --port 3081`;`dev:web` 还接受 `--skip-build` 复用现有产物树,以及 `--no-serve` 只运行重建 watcher、配合别处启动的服务器。两个 Web 命令使用正常的 Harness home,而 Desktop 命令使用 [Desktop README](../apps/desktop/README.zh.md) 描述的隔离开发 home。根目录 `Makefile` 以 `make web`、`make dev-web`、`make desktop`、`make dev-desktop` 和 `make build` 命名同一套命令;`ARGS='--no-open'` 用于转发参数。 + ### TODO 标记 请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序: diff --git a/package.json b/package.json index 4d36479376..eb4b2b92c9 100644 --- a/package.json +++ b/package.json @@ -196,6 +196,7 @@ "demo:ptc": "node scripts/demo-ptc.mjs", "demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.source.patch.yml", "mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts", + "start:web": "node --import tsx/esm apps/cli/src/bin.ts web", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" }, diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml index 07ad3d545d..f6861208ea 100644 --- a/packages/client/hmr/README.i18n.yaml +++ b/packages/client/hmr/README.i18n.yaml @@ -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/hmr/README.md -README.md: e459da10332ffdb3af422b75f639ce25df512487 -README.zh.md: 54e2f3af63472dedbfc75c5b4400b5d93e14f311 +README.md: 5dae1463ceed315c3409bf97de80649f10e83fa8 +README.zh.md: a77befcf63cd78be66b7e5b636d86367af6fdcdf diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index e459da1033..5dae1463ce 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -29,7 +29,7 @@ The shipped Web composition mounts this transport for live plugin changes. Durin ### Starting the reload chain -Run `pnpm run dev:web` (or a watch process using the shared Client tsdown preset) against the same host; rebuilt plugins are then swapped into the running browser automatically, one at a time. The preset stamps `lib/client.js` after all package-local chunks are written, so a chunk-only rebuild also advances the package revision without Host-side chunk scanning. +Run `pnpm run dev:web`, which starts the host and the rebuild watchers together (`--no-serve` attaches only the watchers to a host started elsewhere, as does any watch process using the shared Client tsdown preset); rebuilt plugins are then swapped into the running browser automatically, one at a time. The preset stamps `lib/client.js` after all package-local chunks are written, so a chunk-only rebuild also advances the package revision without Host-side chunk scanning. ### What a reload does diff --git a/packages/client/hmr/README.zh.md b/packages/client/hmr/README.zh.md index 54e2f3af63..a77befcf63 100644 --- a/packages/client/hmr/README.zh.md +++ b/packages/client/hmr/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 启动重载链路 -对同一个宿主运行 `pnpm run dev:web`(或使用共享 Client tsdown 预设的 watch 进程);重建后的插件随后会被自动逐个替换进运行中的浏览器。该预设会在所有包内 chunk 写完后标记 `lib/client.js`,因此仅 chunk 发生重建也会推进包 revision,无需 Host 扫描 chunk。 +运行 `pnpm run dev:web`,它会同时启动宿主与重建 watcher(`--no-serve` 则只把 watcher 接到别处启动的宿主上,使用共享 Client tsdown 预设的任何 watch 进程亦然);重建后的插件随后会被自动逐个替换进运行中的浏览器。该预设会在所有包内 chunk 写完后标记 `lib/client.js`,因此仅 chunk 发生重建也会推进包 revision,无需 Host 扫描 chunk。 ### 一次重载做什么 diff --git a/scripts/dev-web.spec.ts b/scripts/dev-web.spec.ts index 76dbf82860..df1274c57c 100644 --- a/scripts/dev-web.spec.ts +++ b/scripts/dev-web.spec.ts @@ -1,15 +1,112 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import type { TsdownBundle } from 'tsdown' import { writeClientBuildRecord } from './client-build-environment.ts' import { devWebBuildEnvironment, discoverLibraryDirs, discoverPluginDirs, + parseDevWebArguments, + StageSupervisor, watchClientPlugins, } from './dev-web.ts' +import type { StageHandle } from './dev-web.ts' + +/** A stage double that exits only on the listed signals or an explicit exit code. */ +function fakeStage(name: string, exitOn: readonly NodeJS.Signals[]): { + handle: StageHandle + signals: NodeJS.Signals[] + exit: (code: number) => void +} { + const exit = Promise.withResolvers() + const signals: NodeJS.Signals[] = [] + return { + handle: { + name, + exited: exit.promise, + kill(signal) { + signals.push(signal) + if (exitOn.includes(signal)) exit.resolve(null) + }, + }, + signals, + exit: (code) => { exit.resolve(code) }, + } +} + +describe('StageSupervisor', () => { + it('reports a stage that exits on its own', async () => { + const stale: [string, number | null][] = [] + const supervisor = new StageSupervisor((name, code) => { stale.push([name, code]) }) + const tsc = fakeStage('tsc', ['SIGTERM']) + supervisor.add(tsc.handle) + tsc.exit(2) + await tsc.handle.exited + expect(stale).toEqual([['tsc', 2]]) + }) + + it('forwards one signal, escalates survivors after the grace period, and reports none of them', async () => { + const stale: string[] = [] + const supervisor = new StageSupervisor((name) => { stale.push(name) }) + const polite = fakeStage('vite', ['SIGTERM']) + const stubborn = fakeStage('dsh web', ['SIGKILL']) + supervisor.add(polite.handle) + supervisor.add(stubborn.handle) + await supervisor.stop({ signal: 'SIGTERM', graceMs: 20 }) + expect(polite.signals).toEqual(['SIGTERM']) + expect(stubborn.signals).toEqual(['SIGTERM', 'SIGKILL']) + expect(stale).toEqual([]) + }) + + it('waits for stages the terminal already interrupted before signaling survivors', async () => { + const supervisor = new StageSupervisor(() => { throw new Error('unexpected stale report') }) + const interrupted = fakeStage('tsdown', []) + const survivor = fakeStage('dsh web', ['SIGTERM']) + supervisor.add(interrupted.handle) + supervisor.add(survivor.handle) + const stopping = supervisor.stop({ graceMs: 20 }) + interrupted.exit(130) + await stopping + expect(interrupted.signals).toEqual([]) + expect(survivor.signals).toEqual(['SIGTERM']) + }) +}) + +describe('parseDevWebArguments', () => { + it('builds, serves, and watches natively by default', () => { + expect(parseDevWebArguments([])).toEqual({ skipBuild: false, serve: true, pollInterval: undefined, appArgs: [] }) + }) + + it('reads the polling interval with its 500ms default', () => { + expect(parseDevWebArguments(['--poll']).pollInterval).toBe(500) + expect(parseDevWebArguments(['--poll=250']).pollInterval).toBe(250) + }) + + it.each(['--poll=abc', '--poll=0', '--poll=-5', '--poll=1.5'])('rejects the polling interval %s', (flag) => { + expect(() => parseDevWebArguments([flag])).toThrow(`invalid --poll interval "${flag}"`) + }) + + it('separates its own flags from the arguments forwarded to dsh web', () => { + expect(parseDevWebArguments(['--skip-build', '--poll', '--no-open', '--port', '8080'])).toEqual({ + skipBuild: true, serve: true, pollInterval: 500, appArgs: ['--no-open', '--port', '8080'], + }) + }) + + it('runs only the rebuild watchers with --no-serve', () => { + expect(parseDevWebArguments(['--no-serve', '--skip-build'])).toMatchObject({ serve: false, skipBuild: true }) + }) + + it('ignores the separator pnpm run forwards verbatim', () => { + expect(parseDevWebArguments(['--', '--no-open']).appArgs).toEqual(['--no-open']) + expect(parseDevWebArguments(['--no-serve', '--'])).toMatchObject({ serve: false, appArgs: [] }) + }) + + it('rejects dsh web arguments when no server is started', () => { + expect(() => parseDevWebArguments(['--no-serve', '--no-open'])).toThrow('--no-serve leaves no dsh web process for --no-open') + }) +}) it('samples one local environment at startup without validating watcher outputs', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-environment-')) diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index 35500eca13..dd282d773c 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -1,28 +1,38 @@ /** - * Watch-build for the web dev loop: rebuilds every artifact the browser reads - * from a source edit. Reload signaling is not this script's business — the host - * webserver stat-polls the bundles it serves and broadcasts `rebuilt` frames - * itself (`dsh web`), so any process that rewrites `lib/client.js` files - * triggers reloads; this script is merely the convenient way to keep them all - * rebuilt on source change. + * The Web development loop: build once, serve through `dsh web`, and keep every + * browser-side artifact rebuilt on source edits, in one terminal. * - * Three stages, because the compile shell links built lib products rather than - * sources: `tsc -b tsconfig.client.json` emits `lib/types` (the tsdown lib - * entries are that emit, not `src`), tsdown bundles `lib/index.js` and - * `lib/client.js`, and `vite build` rewrites `apps/web/dist`, which `dsh web` - * serves. A missing stage does not fail — it silently shows the previous - * artifact, so an edit appears to do nothing. + * Stages, in order: `pnpm run build` (skipped by `--skip-build`), three + * long-lived watchers, then `dsh web` (skipped by `--no-serve`). The watchers + * exist because the compile shell links built lib products rather than + * sources: `tsc -b tsconfig.client.json --watch` emits `lib/types` (the tsdown + * lib entries are that emit, not `src`), tsdown watch bundles `lib/index.js` + * and `lib/client.js`, and `vite build --watch` rewrites `apps/web/dist`, which + * `dsh web` serves. A missing watcher does not fail — it silently shows the + * previous artifact, so an edit appears to do nothing; any stage exiting on its + * own therefore stops the loop with exit code 1. * - * MUST NOT run concurrently with `pnpm run build`: both write the same - * `lib/` and `apps/web/dist/` trees. + * Reload signaling is not this script's business — the host webserver + * stat-polls the bundles it serves and broadcasts `rebuilt` frames itself, so + * any process that rewrites `lib/client.js` files triggers reloads. * - * Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires one prior - * `pnpm run build`: every stage is incremental over the previous stage's output - * and none of them bootstraps a missing tree. `--poll` switches the source - * watchers to polling (default 500ms): network mounts (weka) deliver no inotify - * events, so native watching sees the initial build only and never a source - * change. Polling has to reach tsc too — a native-watching tsc never re-emits - * `lib/types`, which strands the other two stages on stale input. + * MUST NOT run beside `pnpm run build`: both write the same `lib/` and + * `apps/web/dist/` trees. The build stage here finishes before any watcher starts. + * + * Usage: `pnpm run dev:web [--skip-build] [--no-serve] [--poll[=ms]] [dsh web arguments]`. + * `--skip-build` requires the artifact tree from a prior complete build: every + * watcher is incremental over the previous stage's output and none of them + * bootstraps a missing tree. `--no-serve` keeps only the watchers, for a + * `dsh web` started elsewhere. `--poll` switches the source watchers to polling + * (default 500ms): network mounts (weka) deliver no inotify events, so native + * watching sees the initial build only and never a source change. Polling has + * to reach tsc too — a native-watching tsc never re-emits `lib/types`, which + * strands the other two stages on stale input. + * + * Shutdown: Ctrl+C reaches every stage through the terminal's process group, so + * the script only waits for them and escalates survivors; SIGTERM is addressed + * to this process alone and is forwarded once. The exit code is 130 after + * SIGINT, 0 after SIGTERM, and 1 when a stage exited on its own. * * Each package keeps its own tsdown.config.ts untouched: this script layers * `watch` through API-level inline config (tsdown workspace mode fills inline @@ -39,6 +49,7 @@ import { clientBuildProcessEnvironment, repositoryClientBuildEnvironment, } from './client-build-environment.ts' +import { pnpmInvocation } from './pnpm-invocation.ts' const repoRoot = fileURLToPath(new URL('..', import.meta.url)) @@ -67,6 +78,51 @@ export function devWebBuildEnvironment( return clientBuildProcessEnvironment(environment, repositoryClientBuildEnvironment(root, environment)) } +/** Resolved `dev-web` command line: the script's own flags plus the arguments forwarded to `dsh web`. */ +export interface DevWebArguments { + /** Skip the complete `pnpm run build` that otherwise precedes the watchers. */ + readonly skipBuild: boolean + /** Start `dsh web`; false keeps only the rebuild watchers beside an already running server. */ + readonly serve: boolean + /** Source-watcher polling interval in milliseconds; undefined selects native watching. */ + readonly pollInterval: number | undefined + /** Arguments forwarded verbatim to `dsh web`, in order. */ + readonly appArgs: readonly string[] +} + +/** Polling interval selected by a bare `--poll`. */ +const DEFAULT_POLL_INTERVAL = 500 + +/** + * Parse the script's command line. `--skip-build`, `--no-serve`, and `--poll[=ms]` + * belong to this script; a bare `--` is dropped; every other token is forwarded + * to `dsh web`. + * @param argv - arguments after the script path. + * @returns the resolved flags and forwarded arguments. + * @throws Error when `--poll` carries a non-positive or non-integer interval, or + * when `--no-serve` leaves forwarded arguments without a `dsh web` process. + */ +export function parseDevWebArguments(argv: readonly string[]): DevWebArguments { + let skipBuild = false + let serve = true + let pollInterval: number | undefined + const appArgs: string[] = [] + for (const arg of argv) { + // `pnpm run` forwards a `--` separator verbatim; it carries no meaning here. + if (arg === '--') continue + if (arg === '--skip-build') skipBuild = true + else if (arg === '--no-serve') serve = false + else if (arg === '--poll' || arg.startsWith('--poll=')) { + pollInterval = arg === '--poll' ? DEFAULT_POLL_INTERVAL : Number(arg.slice('--poll='.length)) + if (!Number.isInteger(pollInterval) || pollInterval <= 0) throw new Error(`dev-web: invalid --poll interval "${arg}"`) + } else appArgs.push(arg) + } + if (!serve && appArgs.length > 0) { + throw new Error(`dev-web: --no-serve leaves no dsh web process for ${appArgs[0] ?? ''}`) + } + return { skipBuild, serve, pollInterval, appArgs } +} + /** * Discover the watch workspace by declaration: every packages// * whose package.json carries `dsh.client` with platform "web" is a client @@ -153,46 +209,166 @@ export async function watchClientPlugins( return bundles } -/** - * Live watcher processes to terminate when this script is interrupted. Stages - * register themselves as they start, so the set is complete from the first - * spawn: an interrupt during a later stage's startup still tears down the - * earlier ones instead of orphaning them. - */ -const stages: StageHandle[] = [] +/** One long-lived stage process as the supervisor observes it. */ +export interface StageHandle { + /** Command label used in diagnostics. */ + readonly name: string + /** Settles with the exit code, or null when a signal ended the stage. */ + readonly exited: Promise + /** + * Deliver one signal; a no-op once the stage has exited. + * @param signal - the signal to send. + */ + kill(signal: NodeJS.Signals): void +} + +/** How a requested stop reaches the stages. */ +export interface StageStop { + /** Signal sent to every live stage first; omitted when the terminal already delivered SIGINT to them. */ + readonly signal?: NodeJS.Signals | undefined + /** Milliseconds to wait for exits before escalating to SIGTERM, and again before SIGKILL. */ + readonly graceMs: number +} /** - * Spawn one watcher stage, inheriting stdio, registering it for teardown, and - * failing loud if it ever exits: a dead stage leaves the artifact chain silently - * stale, which reads as "my edit did nothing" — the one failure this script - * exists to prevent. - * @param stage - command label used in the exit diagnostic. - * @param command - executable, resolved from the workspace bin when local. + * Own the live stages: report a stage that exits before a stop was requested, + * and on a requested stop signal, wait, and escalate until every stage has exited. + */ +export class StageSupervisor { + private readonly live = new Set() + private stopping = false + + /** + * @param onStale - receives the stage name and exit code when a stage exits before a stop was requested. + */ + constructor(private readonly onStale: (stage: string, code: number | null) => void) {} + + /** + * Track one started stage until it exits. + * @param stage - the stage to supervise. + */ + add(stage: StageHandle): void { + this.live.add(stage) + void stage.exited.then((code) => { + this.live.delete(stage) + if (!this.stopping) this.onStale(stage.name, code) + }) + } + + /** + * Stop every live stage and resolve once all of them have exited. + * @param stop - initial signal and the grace period between escalation steps. + */ + async stop(stop: StageStop): Promise { + this.stopping = true + const escalation: (NodeJS.Signals | undefined)[] = [stop.signal] + if (stop.signal !== 'SIGTERM') escalation.push('SIGTERM') + for (const signal of escalation) { + if (this.live.size === 0) return + if (signal !== undefined) for (const stage of this.live) stage.kill(signal) + await this.settled(stop.graceMs) + } + for (const stage of this.live) stage.kill('SIGKILL') + await Promise.all([...this.live].map(stage => stage.exited)) + } + + private async settled(graceMs: number): Promise { + let timer: NodeJS.Timeout | undefined + await Promise.race([ + Promise.all([...this.live].map(stage => stage.exited)), + new Promise((resolve) => { timer = setTimeout(resolve, graceMs) }), + ]) + clearTimeout(timer) + } +} + +/** + * Grace period per escalation step. `dsh web` gives its plugin tree five seconds + * to dispose after the first signal; escalating earlier would turn the graceful + * drain into a forced exit. + */ +const STOP_GRACE_MS = 6_000 + +/** + * Spawn one long-lived stage with inherited stdio and hand it to the supervisor. + * @param supervisor - owner that reports an unexpected exit and stops the stage on shutdown. + * @param name - command label used in diagnostics. + * @param command - executable, resolved from the workspace bins when `local` is set. * @param args - command arguments. * @param local - whether to resolve `command` from the workspace's installed bins. */ -function spawnStage(stage: string, command: string, args: readonly string[], local: boolean): void { +function spawnStage( + supervisor: StageSupervisor, + name: string, + command: string, + args: readonly string[], + local: boolean, +): void { const child = execa(command, [...args], { cwd: repoRoot, stdio: 'inherit', preferLocal: local, reject: false, }) - stages.push({ kill: () => { child.kill() } }) - void child.then((result) => { - console.error(`dev-web: ${stage} exited (code ${String(result.exitCode)}); the artifact chain is now stale`) - process.exit(1) + supervisor.add({ + name, + exited: child.then(result => result.exitCode ?? null), + kill(signal) { child.kill(signal) }, }) } -/** The only capability this script needs from a live watcher process. */ -interface StageHandle { - readonly kill: () => void +/** + * Run the complete repository build with the launch environment untouched: + * `scripts/build.ts` samples the public client values itself. + * @returns the build's exit code, or null when a signal ended it. + */ +async function runBuild(): Promise { + const invocation = pnpmInvocation(['run', 'build']) + const result = await execa(invocation.command, invocation.args, { cwd: repoRoot, stdio: 'inherit', reject: false }) + return result.exitCode ?? null } const invokedPath = process.argv[1] const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href if (isMain) { + let options: DevWebArguments + try { + options = parseDevWebArguments(process.argv.slice(2)) + } catch (error) { + console.error(error instanceof Error ? error.message : error) + console.error('dev-web: usage: pnpm run dev:web [--skip-build] [--no-serve] [--poll[=ms]] [dsh web arguments]') + process.exit(1) + } + + // Shutdown is requested once, by a terminal signal or by a stage exiting on + // its own; `signal` stays unset for SIGINT because the terminal's process + // group already delivered it to every stage. + const shutdown = Promise.withResolvers() + const requested: { code?: number; signal?: NodeJS.Signals } = {} + const shutdownRequested = (): boolean => requested.code !== undefined + const requestShutdown = (code: number, signal: NodeJS.Signals | undefined): void => { + if (shutdownRequested()) return + requested.code = code + if (signal !== undefined) requested.signal = signal + shutdown.resolve(code) + } + const supervisor = new StageSupervisor((stage, code) => { + console.error(`dev-web: ${stage} exited (code ${String(code)}); stopping the other stages`) + requestShutdown(1, 'SIGTERM') + }) + // Persistent listeners, not `once`: the tsdown watchers bundle signal-exit, + // which re-raises a signal whenever it finds no other listener left and would + // terminate this process in the middle of the teardown below. + process.on('SIGINT', () => { requestShutdown(130, undefined) }) + process.on('SIGTERM', () => { requestShutdown(0, 'SIGTERM') }) + + const buildExit = options.skipBuild ? 0 : await runBuild() + if (shutdownRequested()) process.exit(await shutdown.promise) + if (buildExit !== 0) { + console.error(`dev-web: pnpm run build exited (code ${String(buildExit)})`) + process.exit(1) + } + const buildEnvironment = devWebBuildEnvironment(repoRoot, process.env) for (const name of Object.keys(process.env)) { if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith('DSH_CLIENT_')) { @@ -214,32 +390,14 @@ if (isMain) { process.exit(1) } - const args = process.argv.slice(2) - const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll=')) - if (args.some(a => a !== pollArg)) { - console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]') - process.exit(1) - } - const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500') - if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) { - console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`) - process.exit(1) - } - - // Registered before any stage starts: `stages` is read at signal time, so an - // interrupt during tsdown's initial builds still kills whatever is running. - const stop = (): void => { for (const stage of stages) stage.kill() } - process.once('SIGINT', stop) - process.once('SIGTERM', stop) - // tsc has no polling interval flag, so `--poll` selects its fixed-interval // watchers rather than an interval. Dropping that translation leaves tsc // natively watching on a network mount where inotify never fires: it stops // re-emitting lib/types, and the two later stages then rebuild forever from // stale input without printing anything. - spawnStage(`tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [ + spawnStage(supervisor, `tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [ '-b', CLIENT_TYPE_PROGRAM, '--watch', '--preserveWatchOutput', - ...pollInterval !== undefined + ...options.pollInterval !== undefined ? ['--watchFile', 'fixedPollingInterval', '--watchDirectory', 'fixedPollingInterval'] : [], ], true) @@ -248,19 +406,34 @@ if (isMain) { // first build reads current lib bundles rather than whatever the last full // build left. Its own watch then covers later lib rewrites — those files are // in its module graph. - await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], pollInterval) - // Through the shell's own `watch` script rather than vite's API: vite is not a - // repository-root dependency, and more importantly the vite root is its - // working directory — `resolve.dedupe` resolves react from that root, so - // running vite from anywhere but apps/web silently switches which react copy - // the bundle gets. - spawnStage('vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false) + const bundles = await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], options.pollInterval) + if (!shutdownRequested()) { + // Through the shell's own `watch` script rather than vite's API: vite is not a + // repository-root dependency, and more importantly the vite root is its + // working directory — `resolve.dedupe` resolves react from that root, so + // running vite from anywhere but apps/web silently switches which react copy + // the bundle gets. + spawnStage(supervisor, 'vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false) + // The same launch vector as the root `dsh` script, so the served Host runs + // from source exactly as `pnpm dsh web` would. + if (options.serve) { + spawnStage(supervisor, 'dsh web', process.execPath, [ + '--import', 'tsx/esm', 'apps/cli/src/bin.ts', 'web', ...options.appArgs, + ], false) + } + console.log( + `dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages` + + ` and ${String(libraryDirs.length)} statically linked library packages` + + (options.pollInterval !== undefined ? ` (polling ${String(options.pollInterval)}ms)` : '') + + `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build` + + (options.serve ? ', serving through dsh web' : '') + + ':\n ' + + [...pluginDirs, ...libraryDirs].join('\n '), + ) + } - console.log( - `dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages` - + ` and ${String(libraryDirs.length)} statically linked library packages` - + (pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : '') - + `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build:\n ` - + [...pluginDirs, ...libraryDirs].join('\n '), - ) + const exitCode = await shutdown.promise + await supervisor.stop({ signal: requested.signal, graceMs: STOP_GRACE_MS }) + for (const bundle of bundles) await bundle[Symbol.asyncDispose]() + process.exit(exitCode) } diff --git a/scripts/verify-application-entrypoints.spec.ts b/scripts/verify-application-entrypoints.spec.ts index 0c84043dfc..6ff6cb673a 100644 --- a/scripts/verify-application-entrypoints.spec.ts +++ b/scripts/verify-application-entrypoints.spec.ts @@ -99,8 +99,28 @@ describe('application entrypoints', () => { write(root, 'scripts/demo-ptc.mjs', "spawn('node', ['packages/example/app/src/bin.ts'])\n") expect(applicationEntrypointViolations(root)).toEqual([ - 'scripts/demo-ptc.mjs: application demo wrapper must launch apps/cli/src/bin.ts', - 'scripts/demo-ptc.mjs: application demo wrapper must not launch a package entry directly', + 'scripts/demo-ptc.mjs: application launcher wrapper must launch apps/cli/src/bin.ts', + 'scripts/demo-ptc.mjs: application launcher wrapper must not launch a package entry directly', + ]) + }) + + it('rejects a start:web script that bypasses the dsh launcher', () => { + const root = fixture() + write(root, 'package.json', JSON.stringify({ scripts: { 'start:web': 'node packages/example/app/src/bin.ts web' } })) + + expect(applicationEntrypointViolations(root)).toEqual([ + 'package.json scripts.start:web: application launcher script must launch apps/cli/src/bin.ts', + 'package.json scripts.start:web: application launcher script must not launch a package entry directly', + ]) + }) + + it('rejects a dev:web wrapper that serves without the dsh launcher', () => { + const root = fixture() + write(root, 'package.json', JSON.stringify({ scripts: { 'dev:web': 'tsx scripts/dev-web.ts --poll' } })) + write(root, 'scripts/dev-web.ts', "execa('vite', ['build', '--watch'])\n") + + expect(applicationEntrypointViolations(root)).toEqual([ + 'scripts/dev-web.ts: application launcher wrapper must launch apps/cli/src/bin.ts', ]) }) diff --git a/scripts/verify-application-entrypoints.ts b/scripts/verify-application-entrypoints.ts index 728dd0832f..b629e7e9ab 100644 --- a/scripts/verify-application-entrypoints.ts +++ b/scripts/verify-application-entrypoints.ts @@ -18,7 +18,7 @@ interface RootManifest { readonly scripts?: Record } -interface DemoPolicy { +interface LauncherPolicy { readonly kind: 'dsh-direct' | 'dsh-wrapper' readonly wrapper?: string } @@ -48,10 +48,16 @@ const EXECUTABLE_SOURCE_ALLOWLIST = new Map([ ['python/sdk-runtime/runtime-bootstrap.mjs', 'private packaging-only runtime dispatcher'], ]) -/** Root demos are application wrappers and therefore must visibly select dsh. */ -const ROOT_DEMO_POLICIES = new Map([ +/** + * Root scripts that start an application must visibly select dsh: every `demo:*` + * script needs a row here, and the Web start and development scripts are pinned + * by name. + */ +const ROOT_LAUNCHER_POLICIES = new Map([ ['demo:ptc', { kind: 'dsh-wrapper', wrapper: 'scripts/demo-ptc.mjs' }], ['demo:inspector', { kind: 'dsh-direct' }], + ['start:web', { kind: 'dsh-direct' }], + ['dev:web', { kind: 'dsh-wrapper', wrapper: 'scripts/dev-web.ts' }], ]) const SOURCE_PATTERNS = [ @@ -131,22 +137,23 @@ function referencesPackageEntry(source: string): boolean { return /packages\/[^/\s'"`]+\/[^/\s'"`]+\/(?:src|lib)\/[^\s'"`]+/.test(source) } -function rootDemoViolations(root: string): string[] { +function rootLauncherViolations(root: string): string[] { const manifestPath = resolve(root, 'package.json') if (!existsSync(manifestPath)) return [] const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as RootManifest const failures: string[] = [] for (const [name, commandValue] of Object.entries(manifest.scripts ?? {}).sort(([left], [right]) => left.localeCompare(right))) { - if (!name.startsWith('demo:')) continue - const command = typeof commandValue === 'string' ? commandValue : '' - const policy = ROOT_DEMO_POLICIES.get(name) + const policy = ROOT_LAUNCHER_POLICIES.get(name) if (policy === undefined) { - failures.push(`package.json scripts.${name}: demo launcher has no explicit dsh or in-process classification`) + if (name.startsWith('demo:')) { + failures.push(`package.json scripts.${name}: demo launcher has no explicit dsh or in-process classification`) + } continue } + const command = typeof commandValue === 'string' ? commandValue : '' if (policy.kind === 'dsh-direct') { - if (!referencesDshCli(command)) failures.push(`package.json scripts.${name}: application demo must launch apps/cli/src/bin.ts`) - if (referencesPackageEntry(command)) failures.push(`package.json scripts.${name}: application demo must not launch a package entry directly`) + if (!referencesDshCli(command)) failures.push(`package.json scripts.${name}: application launcher script must launch apps/cli/src/bin.ts`) + if (referencesPackageEntry(command)) failures.push(`package.json scripts.${name}: application launcher script must not launch a package entry directly`) continue } const wrapper = policy.wrapper @@ -156,12 +163,12 @@ function rootDemoViolations(root: string): string[] { } const wrapperPath = resolve(root, wrapper) if (!existsSync(wrapperPath)) { - failures.push(`${wrapper}: classified demo wrapper is missing`) + failures.push(`${wrapper}: classified launcher wrapper is missing`) continue } const source = readFileSync(wrapperPath, 'utf8') - if (!referencesDshCli(source)) failures.push(`${wrapper}: application demo wrapper must launch apps/cli/src/bin.ts`) - if (referencesPackageEntry(source)) failures.push(`${wrapper}: application demo wrapper must not launch a package entry directly`) + if (!referencesDshCli(source)) failures.push(`${wrapper}: application launcher wrapper must launch apps/cli/src/bin.ts`) + if (referencesPackageEntry(source)) failures.push(`${wrapper}: application launcher wrapper must not launch a package entry directly`) } return failures } @@ -175,7 +182,7 @@ export function applicationEntrypointViolations(root: string): string[] { return [ ...manifestBinViolations(root), ...executableSourceViolations(root), - ...rootDemoViolations(root), + ...rootLauncherViolations(root), ] } From 91fcd9180ac5a5d5b9223d8859a11aedddf0209b Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 15:55:29 +0800 Subject: [PATCH 166/284] docs(client): name the caption bubble owner and record the badge gap The occupying badge chooses its own side, and the update badge keeps a right-opening bubble that the Windows caption menu can cover; ui-settings-general records that as a known limitation. --- packages/client/ui-settings-general/README.i18n.yaml | 4 ++-- packages/client/ui-settings-general/README.md | 1 + packages/client/ui-settings-general/README.zh.md | 1 + packages/client/ui-sidebar/README.i18n.yaml | 4 ++-- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index a485e99405..2f86bc5b7c 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -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/ui-settings-general/README.md -README.md: cf8bb5a75aa90842108646b57ceec67ada8ae711 -README.zh.md: 7df3eee13df7bd041cc5f09288d1b7fe28acded6 +README.md: ebdb22adc071b5df435fddd6620dd982d866aa36 +README.zh.md: 0bb188ea5024b14e68baa1a687a654ee8963f3d7 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index cf8bb5a75a..ebdb22adc0 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -103,6 +103,7 @@ None; this package neither assembles nor sends a provider request. These limits define what the shell itself provides versus what features must supply; they are current package constraints. - **Additional General rows require their feature plugins** — the shell supplies Developer tools; feature plugins supply the remaining preferences. +- **The Windows caption badge keeps a side-opening bubble** — `DesktopUpdateBadge` occupies `sidebar.toggle.badge` in the caption and requests `side="right"`, so the Desktop-owned menu text can cover its bubble while the sidebar is collapsed on Windows; the sidebar toggle and New Session bubbles open below the caption instead (#4688). ### Dev Note diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 7df3eee13d..0bb188ea50 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -103,6 +103,7 @@ kind: "package-reference" 这些限制说明外壳自身提供什么、功能包必须提供什么;它们是当前包约束。 - **额外的通用设置行需要相应功能插件**:外壳提供开发者工具;功能插件提供其他偏好。 +- **Windows 顶栏徽标的气泡仍向右展开**:`DesktopUpdateBadge` 占用顶栏的 `sidebar.toggle.badge` 且请求 `side="right"`,侧栏收起时其气泡会被 Desktop 菜单文字遮挡;侧栏开关与新建会话的气泡则改在顶栏下方展开(#4688)。 ### 开发备注 diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 9703fded4a..72b5b20666 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -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/ui-sidebar/README.md -README.md: 70489f5f4a8892996f455d21a9493a865990dc49 -README.zh.md: c9691315284c62b126043d070ec071d410fe38ce +README.md: 94bb3a70ca8c06b5838f41436bb0f2ba2751501e +README.zh.md: 86f54882ba1c96b9b50898993931757f7afef4e5 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 70489f5f4a..94bb3a70ca 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -41,7 +41,7 @@ The top expand button hosts the optional, non-interactive `sidebar.toggle.badge` During a live collapse, the expanded content fades out at its current width, the upper controls share one fade and leftward translation into the 56px rail, and the layout's column slide ends the motion. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation. -On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region. The sidebar toggle and New Session bubbles open below the caption, where the Desktop-owned menu text cannot cover them; a `sidebar.toggle.badge` occupant keeps its default side. +On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region. The sidebar toggle and New Session bubbles open below the caption, where the Desktop-owned menu text cannot cover them; an occupying `sidebar.toggle.badge` chooses its own bubble side. ### macOS desktop diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index c969131528..86f54882ba 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -41,7 +41,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 实时收起时,展开内容在当前宽度淡出,上方控件共用同一段透明度渐变,并向左平移进入 56px 轨道,由布局的栏滑动结束整段动画。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。固定在底部的 `sidebar.settings` 控件共用相同的透明度渐变时序,但不发生横向位移。 -在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。侧栏开关与新建会话的悬停提示在顶栏下方展开,Desktop 菜单文字不会盖住它们;占用 `sidebar.toggle.badge` 的控件保持默认展开方向。 +在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。侧栏开关与新建会话的悬停提示在顶栏下方展开,Desktop 菜单文字不会盖住它们;占用 `sidebar.toggle.badge` 的控件自行决定气泡展开方向。 ### macOS 桌面 From 6fa56a0cf7c0d7d588a4f94549e82a4936e2cfa6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 15:57:45 +0800 Subject: [PATCH 167/284] fix(conversation): preserve first drafts across Session retirement --- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 2 + .../src/client/input/first-draft.ts | 5 +- .../tests/apply-inject.client.spec.tsx | 61 ++++++++++++++++++- .../tests/first-draft.client.spec.ts | 2 +- 7 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2002de04c1..d9ee350eac 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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/ui-conversation/README.md -README.md: 56b9d06ce5e48ab40691bede2ccb9669e180aa9f -README.zh.md: fd02565698171e0b6db35080181e57b8b5abd42d +README.md: 6ea403d46b9a019cbd24f8963a89835be4745980 +README.zh.md: f6e28d563847316b538ab8c4317507ea7761c913 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 56b9d06ce5..6ea403d46b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -52,7 +52,7 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c The Client chooses the default directory name and initial title when sending: Chinese uses `默认工作区`, English uses `Default workspace`, and other languages use directory `default-workspace` with title `Default workspace`. The request retains those names during initialization. A retry after failed initialization uses the current language; a successfully initialized Workspace keeps its directory and title across language changes. -On first use, the same input accepts text before a Workspace is selected. The first send prepares the [default Workspace](../../workspace/workspace/README.md#first-use-workspace), selects its blank Session, transfers the draft, and uses ordinary message submission. Preparation locks further input. Failure preserves the draft and offers Cancel or Choose folder through the existing picker. A manually chosen folder receives the unsent draft; the user sends again. Once created, the Workspace survives a prompt failure. `conversation/prepare-first-send` awaits staged Session settings before automatically submitting the transferred first-use draft; a rejected listener keeps that draft and reports its error. +On first use, the same input accepts text before a Workspace is selected. The first send prepares the [default Workspace](../../workspace/workspace/README.md#first-use-workspace), selects its blank Session, transfers the draft, and uses ordinary message submission. Preparation locks further input. Failure preserves the draft and offers Cancel or Choose folder through the existing picker. A manually chosen folder receives the unsent draft; the user sends again. Once created, the Workspace survives a prompt failure. `conversation/prepare-first-send` awaits staged Session settings before automatically submitting the transferred first-use draft; a rejected listener keeps that draft and reports its error. If navigation releases the destination Session while preparation is pending, automatic submission stops and its persisted draft remains available on reopening. The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index fd02565698..f6e28d5638 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -52,7 +52,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, Client 在发送时决定默认目录名和初始标题:中文使用 `默认工作区`,英文使用 `Default workspace`,其他语言使用目录 `default-workspace` 和标题 `Default workspace`。初始化期间,请求保留这些名称。初始化失败后的重试使用当前语言;初始化成功的 Workspace 在切换语言后仍保留原目录和标题。 -首次使用时,同一个输入框允许在选择 Workspace 前输入文字。首条消息会准备[默认工作区](../../workspace/workspace/README.zh.md#first-use-workspace)、选中其 blank Session、转移草稿,再使用普通消息提交流程。准备期间锁定后续输入。失败时保留草稿,并提供“取消”或通过已有选择器“选择文件夹”。手动选择的文件夹接收未发送草稿,用户再次发送。Workspace 创建成功后,不会因提示词发送失败而消失。`conversation/prepare-first-send` 在自动提交已转移的首次使用草稿前等待暂存的 Session 设置完成;监听器拒绝时保留该草稿并显示错误。 +首次使用时,同一个输入框允许在选择 Workspace 前输入文字。首条消息会准备[默认工作区](../../workspace/workspace/README.zh.md#first-use-workspace)、选中其 blank Session、转移草稿,再使用普通消息提交流程。准备期间锁定后续输入。失败时保留草稿,并提供“取消”或通过已有选择器“选择文件夹”。手动选择的文件夹接收未发送草稿,用户再次发送。Workspace 创建成功后,不会因提示词发送失败而消失。`conversation/prepare-first-send` 在自动提交已转移的首次使用草稿前等待暂存的 Session 设置完成;监听器拒绝时保留该草稿并显示错误。如果导航在准备期间释放了目标 Session,自动提交会停止,已持久化的草稿会在重新打开时保留。 本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 02c1f3a593..018cf29579 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -208,6 +208,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { const firstDraft = new FirstDraft(ctx, { open: (beforeOpen, signal) => { const language = ctx.locale.getSnapshot().active.toLowerCase().split('-')[0] + // Persisted Workspace names follow the send-time language, including the English fallback. const title = (language === 'zh' ? zh : en)['defaultWorkspace.title'] return workspaceNavigation.openDefaultWorkspace({ directoryName: language === 'zh' || language === 'en' ? title : 'default-workspace', @@ -215,6 +216,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { }, beforeOpen, signal) }, shell: id => inputHub.shell(id), + isCurrent: (id, shell) => sessions.binding(id) !== undefined && inputHub.shell(id) === shell, }) const composerBlocks = new ComposerBlockRegistry() diff --git a/packages/client/ui-conversation/src/client/input/first-draft.ts b/packages/client/ui-conversation/src/client/input/first-draft.ts index 9e1c486eae..8dc7fb32a6 100644 --- a/packages/client/ui-conversation/src/client/input/first-draft.ts +++ b/packages/client/ui-conversation/src/client/input/first-draft.ts @@ -10,13 +10,15 @@ import { SessionInputShell } from './facade.ts' interface FirstDraftDeps { open(beforeOpen: (id: SessionId) => void, signal: AbortSignal): Promise shell(id: SessionId): SessionInputShell + /** Whether the borrowed input still belongs to the retained Session generation. */ + isCurrent(id: SessionId, shell: SessionInputShell): boolean } /** Owns a root-lifetime text editor without allocating a Host Session. */ export class FirstDraft { /** Preparation progress and the recoverable directory failure. */ readonly state = createSnapshotStore({ busy: false, failed: false }) - /** Local editor that stays independent of Host Session allocation. */ + /** Local editor owns failed-submit restoration before transfer to a Host Session. */ readonly shell: SessionInputShell private pending: { text: string; transferred: boolean } | undefined @@ -63,6 +65,7 @@ export class FirstDraft { target = this.deps.shell(id) await this.ctx.serial('conversation/prepare-first-send', id) signal.throwIfAborted() + if (!this.deps.isCurrent(id, target)) return { kind: 'success' } target.submit(mode) return { kind: 'success' } } catch (error) { diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index abc74918d5..c35aff8227 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -139,11 +139,70 @@ async function bench() { return { runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi, inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads, rootReference, references, opened, - locale, openDefaultWorkspace, + locale, openDefaultWorkspace, replaceMain, } } describe('Conversation inject API', () => { + it.each([false, true])('keeps the saved first draft after its Session retires (reopened during preparation: %s)', async (reopenEarly) => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const settings = Promise.withResolvers() + const started = Promise.withResolvers() + b.runtime.ctx.on('conversation/prepare-first-send', () => { + started.resolve(undefined) + return settings.promise + }) + b.openDefaultWorkspace.mockImplementation(async (_request, beforeOpen) => { + b.replaceMain(ROOT, beforeOpen) + return ROOT + }) + const body = b.conversationApi(ROOT) + const unmirror = body.injected.bindDraftMirror(body.instance.actions.setDraft) + const composer = b.composerApi(undefined) + let reopened: SessionReference | undefined + try { + composer.keyboard.paste('keep this first draft') + composer.keyboard.submit('queue') + await started.promise + expect(body.instance.store.getSnapshot().draft).toBe('keep this first draft') + const other = 'other-first-send' as SessionId + await b.runtime.sessions.add({ id: other, session: sessionFakeFor() }) + unmirror() + b.rootReference.release() + b.replaceMain(other) + await vi.waitFor(() => { expect(b.runtime.sessions.binding(ROOT)).toBeUndefined() }) + if (reopenEarly) reopened = b.runtime.sessions.retain(ROOT) + settings.resolve(undefined) + await vi.waitFor(() => { expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) }) + await b.runtime.flush() + expect(b.sessionFake.prompt).not.toHaveBeenCalled() + reopened ??= b.runtime.sessions.retain(ROOT) + b.references.set(ROOT, reopened) + expect(b.conversationApi(ROOT).instance.store.getSnapshot().draft).toBe('keep this first draft') + expect(composer.hooks.composerInput.getSnapshot().draft).toBe('') + } finally { + settings.resolve(undefined) + reopened?.release() + unmirror() + } + }) + + it('submits the first draft through its retained destination input', async () => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + b.openDefaultWorkspace.mockImplementation(async (_request, beforeOpen) => { + b.replaceMain(ROOT, beforeOpen) + return ROOT + }) + const composer = b.composerApi(undefined) + composer.keyboard.paste('first message') + composer.keyboard.submit('queue') + await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce() }) + expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([{ type: 'text', text: 'first message' }]) + expect(composer.hooks.composerInput.getSnapshot().draft).toBe('') + }) + it.each([ ['zh', '默认工作区', '默认工作区'], ['en', 'Default workspace', 'Default workspace'], diff --git a/packages/client/ui-conversation/tests/first-draft.client.spec.ts b/packages/client/ui-conversation/tests/first-draft.client.spec.ts index 3d223a38fe..b23055dcf1 100644 --- a/packages/client/ui-conversation/tests/first-draft.client.spec.ts +++ b/packages/client/ui-conversation/tests/first-draft.client.spec.ts @@ -27,7 +27,7 @@ function harness() { beforeOpen = prepare return opened.promise }) - const first = new FirstDraft(ctx, { open, shell: () => target }) + const first = new FirstDraft(ctx, { open, shell: () => target, isCurrent: () => true }) return { ctx, first, target, submitted, open, opened, complete: () => { From cfec29eb69bf8c4c78b388547502e324e0e1ecd4 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 15:58:10 +0800 Subject: [PATCH 168/284] fix(web): show first-use preparation and update browser readiness --- apps/web/tests/default-workspace.e2e.ts | 20 ++++++++++++++++--- .../default-workspace/preparing.expected.md | 7 +++++++ apps/web/tests/preview-boot.e2e.ts | 4 ++-- apps/web/tests/sessionless-header.e2e.ts | 2 +- apps/web/tests/startup-rpc-budget.e2e.ts | 2 +- apps/web/tests/support.ts | 4 ++-- .../ui-conversation/src/client/locales.ts | 2 ++ .../client/skeleton/ConversationContent.tsx | 2 +- .../tests/skeleton.client.spec.tsx | 1 + snapshots/web/default-workspace/snapshot.yml | 2 ++ .../workspace.expected/.empty | 1 + 11 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 apps/web/tests/expected/default-workspace/preparing.expected.md create mode 100644 snapshots/web/default-workspace/workspace.expected/.empty diff --git a/apps/web/tests/default-workspace.e2e.ts b/apps/web/tests/default-workspace.e2e.ts index 00bec9de3c..265fa715b2 100644 --- a/apps/web/tests/default-workspace.e2e.ts +++ b/apps/web/tests/default-workspace.e2e.ts @@ -3,9 +3,9 @@ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium } from 'playwright' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { - captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, + assertFinalWorkspaceSnapshot, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, selectedSessionFixture, watchConsole, webSnapshotMode, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' @@ -13,6 +13,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) const EXPECTED = fileURLToPath(new URL('../../../snapshots/web/default-workspace/ui.expected.md', import.meta.url)) const FAILURE_EXPECTED = fileURLToPath(new URL('./expected/default-workspace/failure.expected.md', import.meta.url)) +const PREPARING_EXPECTED = fileURLToPath(new URL('./expected/default-workspace/preparing.expected.md', import.meta.url)) const MODE = webSnapshotMode() describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { @@ -38,7 +39,19 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { expect(scaffold.ctx.workspaceRegistry.list()).toEqual([]) expect(scaffold.ctx.sessions.list()).toEqual([]) const settled = scaffold.whenTurnSettled() - await input.press('Enter') + const preparing = Promise.withResolvers() + const initialize = scaffold.ctx.workspaceRegistry.initializeDefault.bind(scaffold.ctx.workspaceRegistry) + vi.spyOn(scaffold.ctx.workspaceRegistry, 'initializeDefault').mockImplementationOnce(async (resolve) => { + await preparing.promise + return initialize(resolve) + }) + try { + await input.press('Enter') + await page.getByRole('textbox', { name: 'Preparing workspace…', exact: true }).waitFor() + await compareOrRefreshGolden(PREPARING_EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) + } finally { + preparing.resolve(undefined) + } const sessionId = await settled const workspace = scaffold.ctx.workspaceRegistry.list()[0]! expect(workspace.title).toBe('Default workspace') @@ -46,6 +59,7 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { expect((await stat(workspace.path)).isDirectory()).toBe(true) expect(workspace.sessionIds).toContain(sessionId) expect(scaffold.ctx.sessions.get(sessionId)?.header.cwd).toBe(workspace.path) + await assertFinalWorkspaceSnapshot(fileURLToPath(new URL('../../../snapshots/web/default-workspace', import.meta.url)), workspace.path) await page.getByText('DONE', { exact: true }).waitFor() await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) await page.reload() diff --git a/apps/web/tests/expected/default-workspace/preparing.expected.md b/apps/web/tests/expected/default-workspace/preparing.expected.md new file mode 100644 index 0000000000..09e86fbb6e --- /dev/null +++ b/apps/web/tests/expected/default-workspace/preparing.expected.md @@ -0,0 +1,7 @@ +- banner +- text: Into the Unknown Preview +- button "Choose workspace" +- button "Standard mode" +- textbox "Preparing workspace…" [disabled] +- button "Add files or run commands" [disabled] +- button "Send message" [disabled] diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index bde54fe69e..4797a6e4bf 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -297,7 +297,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { expect(await page.getByText('Empty environment', { exact: true }).count()).toBe(1) expect(await page.getByText('WebFS directory', { exact: true }).count()).toBe(1) expect(await page.locator('input[name="preview-source"][value="webfs"]').isDisabled()).toBe(true) - expect(await page.getByRole('textbox', { name: 'Choose workspace' }).count()).toBe(0) + expect(await page.getByRole('button', { name: 'Choose workspace' }).count()).toBe(0) await compareOrRefreshGolden( SOURCE_CHOOSER_EXPECTED, await captureStableAria(page, '[data-preview-source-card]', '/__preview_no_workspace__'), @@ -471,7 +471,7 @@ async function bootEmptyPreview(origin: string, browser: Browser): Promise ) expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`) expect(bootLine).toContain('data overlays=0') - await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) + await page.getByRole('button', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) const sessionCount = await page.evaluate(async () => { const transport = (globalThis as typeof globalThis & { __DSH_TRANSPORT__?: { fetch(input: string, init: RequestInit): Promise } diff --git a/apps/web/tests/sessionless-header.e2e.ts b/apps/web/tests/sessionless-header.e2e.ts index b2c1a85fb0..1de5dfe14f 100644 --- a/apps/web/tests/sessionless-header.e2e.ts +++ b/apps/web/tests/sessionless-header.e2e.ts @@ -30,7 +30,7 @@ describe('navigation without a selected Session', () => { } const tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.getByRole('textbox', { name: 'Choose workspace', exact: true }).waitFor() + await page.getByRole('button', { name: 'Choose workspace', exact: true }).waitFor() const sessionHeader = page.locator('[data-slot="conversation.session.header"]') expect(await sessionHeader.count()).toBe(0) const header = page.locator('[data-slot="conversation.header"] > header') diff --git a/apps/web/tests/startup-rpc-budget.e2e.ts b/apps/web/tests/startup-rpc-budget.e2e.ts index f6655ffbf3..d8c89afcca 100644 --- a/apps/web/tests/startup-rpc-budget.e2e.ts +++ b/apps/web/tests/startup-rpc-budget.e2e.ts @@ -36,7 +36,7 @@ describe('startup RPC budget', () => { await page.goto(scaffold.authenticatedUrl) // Boot settles when the workspace picker is interactive; the trailing wait // absorbs the first-connection reset wave the budget must include. - await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 }) + await page.getByRole('button', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 }) await page.waitForTimeout(3000) const describeCount = calls.filter(method => method === 'settings/describe').length expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBe(DESCRIBE_BUDGET) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 884d648e8e..082f5019ae 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -122,7 +122,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: 'Open', exact: true }).click() - await page.getByRole('treeitem', { name: 'New Session', exact: true }).waitFor({ timeout: 15_000 }) + await page.locator('[data-slot="conversation.session.header"]').waitFor({ state: 'attached', timeout: 15_000 }) await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]') .waitFor({ timeout: 15_000 }) } @@ -146,7 +146,7 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = ' await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: '打开', exact: true }).click() - await page.getByRole('treeitem', { name: '新会话', exact: true }).waitFor({ timeout: 15_000 }) + await page.locator('[data-slot="conversation.session.header"]').waitFor({ state: 'attached', timeout: 15_000 }) await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容, / 调用指令, @ 文件或对话"]') .waitFor({ timeout: 15_000 }) } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index c66fe10a8a..ecf2141f1d 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -22,6 +22,7 @@ export const zh = { 'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行', 'placeholder.hero': '描述你想要构建的内容, / 调用指令, @ 文件或对话', 'placeholder.workspace': '选择一个工作区开始', + 'placeholder.preparing': '正在准备工作区…', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.commands': '添加文件或调用指令', 'input.file': '文件', @@ -377,6 +378,7 @@ export const en = { 'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run', 'placeholder.hero': 'Describe what you want to build, / commands, @ files or sessions', 'placeholder.workspace': 'Choose a workspace to start', + 'placeholder.preparing': 'Preparing workspace…', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.commands': 'Add files or run commands', 'input.file': 'File', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index bf6456291f..82ea0e533d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -143,7 +143,7 @@ export function ConversationContent(props: ConversationContentProps) { ...(inert ? { disabled: true, - placeholder: t('placeholder.workspace'), + placeholder: t(firstDraft.busy ? 'placeholder.preparing' : 'placeholder.workspace'), workspacePickerOpen: pickerOpen, ...(needsWorkspace ? { onRequestWorkspace: () => { setPickerOpen(true) } } : {}), } diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 25f7002ea4..619e3fddff 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -416,6 +416,7 @@ describe('ConversationRoot resident composer', () => { }) const box = b.view.getByRole('textbox') expect(box.getAttribute('aria-disabled')).toBe('true') + expect(box.getAttribute('aria-label')).toBe('正在准备工作区…') expect(box.getAttribute('aria-haspopup')).toBeNull() fireEvent.keyDown(box, { key: 'Enter' }) expect(b.sink).not.toHaveBeenCalled() diff --git a/snapshots/web/default-workspace/snapshot.yml b/snapshots/web/default-workspace/snapshot.yml index d1eed14d70..b9efb8a8b7 100644 --- a/snapshots/web/default-workspace/snapshot.yml +++ b/snapshots/web/default-workspace/snapshot.yml @@ -3,6 +3,8 @@ scenario: default-workspace profile: web composition: web-default recording: authored +workspace: + final: true header: class: web-default session: diff --git a/snapshots/web/default-workspace/workspace.expected/.empty b/snapshots/web/default-workspace/workspace.expected/.empty new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/snapshots/web/default-workspace/workspace.expected/.empty @@ -0,0 +1 @@ + From 8e313eaa306b38c35e57a9fd5a4d74174c0360b6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 15:58:33 +0800 Subject: [PATCH 169/284] test(workspace): cover first-use configuration and preset failures --- .../api/workspace-controller/src/index.ts | 9 +++++--- .../tests/workspace-controller.host.spec.ts | 12 ++++++++-- .../tests/apply.client.spec.ts | 19 ++++++++++++---- .../tests/runtime.client.spec.tsx | 22 +++++++++++++++++++ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 2281273e1e..966a5e9fb9 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -38,6 +38,9 @@ export interface Config { documentsLookupTimeoutMs?: number } +/** Directory policy after schema defaults have been applied. */ +type ResolvedConfig = Config & { documentsLookupTimeoutMs: number } + declare module '@deepseek-ai/cordis' { interface Context { /** Host Workspace business API and Remote namespace owner. */ @@ -49,12 +52,12 @@ declare module '@deepseek-ai/cordis' { export class WorkspaceController extends TypertRemoteService { static inject = ['typert', 'workspaceRegistry'] - static Config: z = z.object({ + static Config: z = z.object({ documentsDirectory: z.string(), documentsLookupTimeoutMs: z.natural().min(1).default(10_000), }) - private readonly config: Config + private readonly config: ResolvedConfig private readonly commands: WorkspaceCommands private readonly feed: WorkspaceFeed @@ -96,7 +99,7 @@ export class WorkspaceController extends TypertRemoteService { throw new RemoteError('gateway/bad-request', 'default Workspace requires a directory name and non-blank title', {}) } const workspace = await this.ctx.workspaceRegistry.initializeDefault(async () => { - const timeout = AbortSignal.timeout(this.config.documentsLookupTimeoutMs as number) + const timeout = AbortSignal.timeout(this.config.documentsLookupTimeoutMs) const path = await defaultWorkspaceDirectory( directoryName, this.config.documentsDirectory, AbortSignal.any([signal, timeout]), ) diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index ecaeb9a248..39cd085879 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -41,7 +41,7 @@ function deferred(): Deferred { return { promise, resolve } } -async function harness() { +async function harness(options: { systemDocuments?: boolean } = {}) { const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-workspace-controller-'))) tempDirs.push(root) const ctx = new Context() @@ -59,7 +59,7 @@ async function harness() { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose }, } as never) - const controller = new WorkspaceController(ctx, { documentsDirectory: root }) + const controller = new WorkspaceController(ctx, options.systemDocuments === true ? {} : { documentsDirectory: root }) return { controller, ctx, root, storageDomain } } @@ -418,6 +418,14 @@ describe('WorkspaceController follow', () => { }) describe('first-use Remote', () => { + it('reuses an initialized Workspace without looking up system Documents', async () => { + const { controller, ctx, root } = await harness({ systemDocuments: true }) + const workspace = await ctx.workspaceRegistry.initializeDefault(async () => ({ path: root, title: 'Existing default' })) + const signal = AbortSignal.abort() + await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, signal)) + .resolves.toMatchObject({ workspace: { workspaceId: workspace!.id, path: root, title: 'Existing default' } }) + }) + it.each(['', ' ', '.', '..', '../outside', 'nested/name', 'nested\\name', 'C:outside', 'name\0', '.. ', 'tail.'])( 'rejects invalid directory name %j before invoking the registry', async (directoryName) => { const { controller, ctx } = await harness() diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index ea312699ce..378b40006f 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -6,7 +6,7 @@ */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' @@ -76,6 +76,7 @@ async function bench(options: { failSettingsUpdate?: boolean selectGate?: Promise settingsRosterGate?: Promise + refuseSelect?: boolean } = {}) { const ctx = new Context() // The host's answer, mutable so a spec can move the default the way the @@ -153,7 +154,9 @@ async function bench(options: { deletePreset: () => Promise.resolve({ ok: true as const, value: undefined }), select: (_agentId: SessionId, agentPreset: string) => { calls.push(`select:${agentPreset}`) - return Promise.resolve(options.selectGate).then(() => ({ ok: true as const, value: agentPreset })) + return Promise.resolve(options.selectGate).then(() => options.refuseSelect === true + ? { ok: false as const, error: new RemoteError('gateway/internal', 'preset refused', {}) } + : { ok: true as const, value: agentPreset }) }, } ctx.provide('remote.agentPresets', agentPresets as never) @@ -715,8 +718,10 @@ describe('ui-agent-preset apply', () => { conversation() }) - it('applies the staged choice to the blank session the flow lands on', async () => { - const { ctx, slots, calls } = await bench() + it.each([false, true])('awaits the staged choice before first send and propagates refusal (%s)', async (refuseSelect) => { + const selection = Promise.withResolvers() + const { ctx, slots, calls } = await bench({ refuseSelect, selectGate: selection.promise }) + onTestFinished(async () => { selection.resolve(undefined); await ctx.fiber.dispose() }) declareRoot(slots) declareConversation(slots) ctx.provide('conversation', {} as never) @@ -736,6 +741,8 @@ describe('ui-agent-preset apply', () => { .inject as unknown as (sessionId?: SessionId) => AgentPresetSeatInjected const chip = injectSeat() + await ctx.serial('conversation/prepare-first-send', SessionId('absent')) + await chip.load() // Picked on the hero screen, where there is no session yet. await chip.select('minimal') @@ -747,6 +754,10 @@ describe('ui-agent-preset apply', () => { } sessions.notify() const bound = injectSeat(SessionId('s1')) + const preparing = ctx.serial('conversation/prepare-first-send', SessionId('s1')) + const settled = refuseSelect ? expect(preparing).rejects.toThrow('preset refused') : expect(preparing).resolves.toBeUndefined() + selection.resolve(undefined) + await settled await ctx.serial('conversation/prepare-first-send', SessionId('s1')) await bound.load() diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index 7d29ba7f72..aeff2554d1 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -557,6 +557,28 @@ describe('workspaces', () => { expect(view.container.textContent).toContain('ws:pending') await runtime.dispose() }) + it('requires an explicit default Workspace fixture and forwards its request and lifetime', async () => { + const runtime = await SlotTestRuntime.create() + try { + const request = { directoryName: 'default-workspace', title: 'Default workspace' } + const signal = new AbortController().signal + await expect(runtime.workspaces.initializeDefault(request, signal)).rejects.toThrow('requires a Workspace fixture') + const workspace = { + workspaceId: 'default' as WorkspaceId, title: request.title, path: '/default', sessionIds: [], + createdAt: '2026-09-20T00:00:00Z', updatedAt: '2026-09-20T00:00:00Z', + } + const initialize = vi.fn(async () => workspace) + runtime.workspaces.stub('initializeDefault', initialize) + await expect(runtime.workspaces.initializeDefault(request, signal)).resolves.toBe(workspace) + expect(initialize).toHaveBeenCalledWith(request, signal) + expect(runtime.workspaces.calls).toEqual([ + { method: 'initializeDefault', args: [request, signal] }, + { method: 'initializeDefault', args: [request, signal] }, + ]) + } finally { + await runtime.dispose() + } + }) }) describe('feature mount and disposal', () => { From d4d03b7c41ad12ea07254ca1ca9c170a476f8517 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 15:58:53 +0800 Subject: [PATCH 170/284] docs(workspace): clarify default directory lookup and cancellation --- .../feature/2026-09-20-default-workspace.i18n.yaml | 4 ++-- .../notes/implemented/feature/2026-09-20-default-workspace.md | 1 + .../implemented/feature/2026-09-20-default-workspace.zh.md | 1 + docs/subsystems/workspace.i18n.yaml | 4 ++-- docs/subsystems/workspace.md | 1 + docs/subsystems/workspace.zh.md | 1 + packages/api/workspace-controller/README.i18n.yaml | 4 ++-- packages/api/workspace-controller/README.md | 4 +++- packages/api/workspace-controller/README.zh.md | 4 +++- packages/api/workspace-controller/src/types.ts | 2 +- packages/extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/workspace/workspace/README.i18n.yaml | 4 ++-- packages/workspace/workspace/README.md | 2 +- packages/workspace/workspace/README.zh.md | 2 +- packages/workspace/workspace/src/index.ts | 1 + 15 files changed, 23 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml index cdb2673df8..a3ed2bee71 100644 --- a/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-09-20-default-workspace.md -2026-09-20-default-workspace.md: d2a32d47a407dcf211c1cad8bb98f29847a77a3b -2026-09-20-default-workspace.zh.md: 6b0ec542da6e9e4fe76fb56b6562edea9e5777f4 +2026-09-20-default-workspace.md: 376b2eaf7ac26c13e7483838700c88151b44135d +2026-09-20-default-workspace.zh.md: e16c5d47c6e1bceef293089edf882162812dfa1b diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.md b/.agents/notes/implemented/feature/2026-09-20-default-workspace.md index d2a32d47a4..376b2eaf7a 100644 --- a/.agents/notes/implemented/feature/2026-09-20-default-workspace.md +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.md @@ -21,6 +21,7 @@ Creation failure preserves the draft and offers the existing folder picker. A su ## Alternatives considered - Creating on application startup would write directories for users who never send a message. +- Falling back to `/Documents` when system lookup is unavailable would choose an unverified Documents location; deployments that need another directory use the explicit Host override. - Inferring first use from visible sidebar rows would ignore archived, hidden, and cwd-less Sessions. - Using the localized path as the initialization marker would allow language changes or deletion to create another default. - Changing a Session's cwd after creation would change the meaning of its recorded tools and attachments. diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md b/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md index 6b0ec542da..e16c5d47c6 100644 --- a/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md @@ -21,6 +21,7 @@ Client 在发送时按其语言解析初始目录名和标题。Host 控制器 ## Alternatives considered - 在应用启动时创建,会为从未发送消息的用户写入目录。 +- 系统查询不可用时回退到 `/Documents`,会选择未经确认的 Documents 位置;需要其他目录的部署使用显式 Host 覆盖配置。 - 根据侧栏可见行推断首次使用,会忽略已归档、隐藏和无 cwd 的 Session。 - 将本地化路径作为初始化标记,会允许切换语言或删除后再次创建默认工作区。 - 在 Session 创建后更改 cwd,会改变其已记录工具和附件的含义。 diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index a7a3e3bb91..f9bef3d306 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -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 docs/subsystems/workspace.md -workspace.md: b68acd965b55df462a0751d0b7e3230c82a62172 -workspace.zh.md: 074f59438eb16a4ffb1423540c3e1868857ae40f +workspace.md: 43a631ad0b04dd272436e272c931c2a16b7bbe88 +workspace.zh.md: c4571083f3e445828b4b3b41a27ed8abee9ee04d diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index b68acd965b..43a631ad0b 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -485,6 +485,7 @@ async create(path: string, title?: string): Promise * @param resolveDirectory - resolve the absolute directory and initial title; * called only for eligible creation, inside the registry mutation queue. * Missing directories are created recursively before registration. + * After resolution, caller cancellation does not roll back creation or registration. * @returns the initialized Workspace, or undefined when automatic creation is ineligible. */ initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 074f59438e..c4571083f3 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -485,6 +485,7 @@ async create(path: string, title?: string): Promise * @param resolveDirectory - resolve the absolute directory and initial title; * called only for eligible creation, inside the registry mutation queue. * Missing directories are created recursively before registration. + * After resolution, caller cancellation does not roll back creation or registration. * @returns the initialized Workspace, or undefined when automatic creation is ineligible. */ initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index e7bc836965..6300d2f2e4 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/README.i18n.yaml @@ -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/api/workspace-controller/README.md -README.md: 275c51d0018dda5b2209cc7bece71b618e7d113d -README.zh.md: 92ca3f86f11ce77b51f834ce7c24f195095065b1 +README.md: 24bf0610689bfb857b24e7c8298b1b0494f1ca02 +README.zh.md: ca140abfdda246b62b0485e63ebfd05ab2efe6f9 diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index 275c51d001..24bf061068 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -29,7 +29,7 @@ The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream ### First-use Workspace -`workspace.initializeDefault({ directoryName, title })` returns the durable default Workspace; the Client service exposes the same request as `workspaces.initializeDefault(request, signal?)`. The [Conversation Client](../../client/ui-conversation/README.md) resolves these names from its send-time language. The Host places the directory under its account's `/deepseek-harness`, including on remote Web hosts. The directory name must be one non-blank segment without separators, colon, NUL, surrounding whitespace, or a trailing dot; the title must be non-blank. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory. +`workspace.initializeDefault({ directoryName, title })` returns the durable default Workspace; the Client service exposes the same request as `workspaces.initializeDefault(request, signal?)`. The [Conversation Client](../../client/ui-conversation/README.md) resolves these names from its send-time language. The Host places the directory under its account's `/deepseek-harness`, including on remote Web hosts. The directory name must be one non-blank segment without separators, colon, NUL, surrounding whitespace, or a trailing dot; the title must be non-blank. OS filename restrictions also apply. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory; hosts without it must configure `documentsDirectory` or use the folder picker. The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace) owns eligibility, directory creation, and durable initialization. An existing default Workspace is returned without another Documents lookup; request names do not rename it. Invalid names or ineligible first use reject with `gateway/bad-request`; lookup and creation failures use standard Remote error handling. Initialization creates no Session and sends no message. @@ -38,6 +38,8 @@ The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace | `documentsDirectory` | System Documents directory | Fully qualified Host directory override | | `documentsLookupTimeoutMs` | `10000` | Positive maximum duration of OS directory lookup, in milliseconds | +Documents lookup holds the registry mutation queue, so other Workspace mutations, including registration of a picked directory, can wait up to `documentsLookupTimeoutMs`. Cancellation can stop the lookup; after resolution succeeds, it does not roll back creation or registration. + ----- diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index 92ca3f86f1..ca140abfdd 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -29,7 +29,7 @@ Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。 ### 首次使用工作区 -`workspace.initializeDefault({ directoryName, title })` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(request, signal?)` 提供同一请求。[Conversation Client](../../client/ui-conversation/README.zh.md)按发送时的语言解析这些名称。Host 将目录放在其账户的 `/deepseek-harness` 下,远程 Web Host 也遵循此规则。目录名必须是非空的单个片段,不能含分隔符、冒号、NUL、首尾空白或末尾句点;标题不能为空。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录。 +`workspace.initializeDefault({ directoryName, title })` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(request, signal?)` 提供同一请求。[Conversation Client](../../client/ui-conversation/README.zh.md)按发送时的语言解析这些名称。Host 将目录放在其账户的 `/deepseek-harness` 下,远程 Web Host 也遵循此规则。目录名必须是非空的单个片段,不能含分隔符、冒号、NUL、首尾空白或末尾句点;标题不能为空。操作系统的文件名限制同样适用。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录;不具备该条件的 Host 必须配置 `documentsDirectory` 或使用文件夹选择器。 [Workspace 注册表](../../workspace/workspace/README.zh.md#first-use-workspace)负责资格判断、目录创建和持久化初始化。已有默认工作区直接返回,不再查询 Documents;请求中的名称不会将其重命名。名称无效或不满足首次使用条件时,以 `gateway/bad-request` 拒绝;查询和创建失败遵循标准 Remote 错误处理。初始化不创建 Session,也不发送消息。 @@ -38,6 +38,8 @@ Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。 | `documentsDirectory` | 系统 Documents 目录 | 完全限定的 Host 目录覆盖值 | | `documentsLookupTimeoutMs` | `10000` | 操作系统目录查询的正数最大时长,单位为毫秒 | +Documents 查询占用注册表变更队列,因此其他 Workspace 变更(包括登记已选目录)最多可能等待 `documentsLookupTimeoutMs`。取消可以停止查询;解析成功后,取消不会回滚创建或登记。 + ----- diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index e69c2b8c91..97188d9f23 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -56,7 +56,7 @@ export interface WorkspaceCreateRequest { /** Names chosen by the first-use Client before default Workspace initialization. */ export interface WorkspaceInitializeDefaultRequest { - /** Single non-blank name; no separators, colon, NUL, surrounding whitespace, or trailing dot. */ + /** Host rejects blank names, separators, colon, NUL, surrounding whitespace, and trailing dots; OS filename restrictions also apply. */ readonly directoryName: string /** Initial display title, retained across language changes. */ readonly title: string diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index eac1fcac40..b89f2f3ada 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3376,7 +3376,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: 'initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise', description: 'Initialize the default Workspace only while both the registry and Session history are empty. Repeated requests reuse its durable identity; deleting that registration permanently disables automatic creation.', - parameters: [{ name: 'resolveDirectory', description: 'resolve the absolute directory and initial title; called only for eligible creation, inside the registry mutation queue. Missing directories are created recursively before registration.' }], + parameters: [{ name: 'resolveDirectory', description: 'resolve the absolute directory and initial title; called only for eligible creation, inside the registry mutation queue. Missing directories are created recursively before registration. After resolution, caller cancellation does not roll back creation or registration.' }], returns: 'the initialized Workspace, or undefined when automatic creation is ineligible.', }, { diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index 43e9c7d159..7f50ab3f55 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/README.i18n.yaml @@ -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/workspace/workspace/README.md -README.md: f11d6496c04cf346c21f869e00ec5fb5526008db -README.zh.md: 3d458e13157bda612c3d33d0e7bfb57fa914e9b3 +README.md: 897aca8eb725bc9c3e0808e394e5c976886dd877 +README.zh.md: dc7a81340c9b134110975e700d43d5569fa6f03d diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index f11d6496c0..897aca8eb7 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -66,7 +66,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first The directory resolver runs inside the mutation queue only when creation is eligible. It returns an absolute path and initial title; the registry creates missing parent directories, canonicalizes the path, rechecks Session history, and commits the Workspace with its initialization marker. An existing directory is reused; a file conflict or directory failure rejects initialization. The [Host controller](../../api/workspace-controller/README.md#first-use-workspace) supplies the Documents path policy. -The first successful registration records its identity durably. Repeated calls return it without resolving a directory again; renaming keeps that identity, and deleting its registration does not permit another automatic creation. Directory or registration failure leaves initialization unset for retry. Directories created before a later failure remain on disk. The [first-use decision](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.md) explains this lifetime. +The first successful registration records its identity durably. Repeated calls return it without resolving a directory again; renaming keeps that identity, and deleting its registration does not permit another automatic creation. Directory or registration failure leaves initialization unset for retry. Directories created before a later failure remain on disk. Once directory resolution succeeds, caller cancellation does not roll back directory creation or registration. The [first-use decision](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.md) explains this lifetime. ### Grouping sessions under a project diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 3d458e1315..dc7a81340c 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -66,7 +66,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first 目录解析器仅在允许创建时于变更队列内运行。它返回绝对路径和初始标题;注册表创建缺失的父目录、规范化路径、重新检查 Session 历史,再一起提交 Workspace 和初始化标记。已存在的目录直接复用;文件冲突或目录操作失败时拒绝初始化。[Host 控制器](../../api/workspace-controller/README.zh.md#first-use-workspace)提供 Documents 路径策略。 -首次成功登记会持久保存工作区身份。重复调用直接返回它,不再解析目录;改名保留该身份,删除登记也不会允许再次自动创建。目录或登记失败时,初始化状态保持未设置,可以重试。后续步骤失败前已创建的目录会保留在磁盘上。[首次使用决策](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md)说明这一生命周期。 +首次成功登记会持久保存工作区身份。重复调用直接返回它,不再解析目录;改名保留该身份,删除登记也不会允许再次自动创建。目录或登记失败时,初始化状态保持未设置,可以重试。后续步骤失败前已创建的目录会保留在磁盘上。目录解析成功后,调用方取消操作不会回滚目录创建或登记。[首次使用决策](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md)说明这一生命周期。 ### 将会话归入项目 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 6cb179022a..d0f3d3b8a5 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -182,6 +182,7 @@ export class WorkspaceRegistry extends Service { * @param resolveDirectory - resolve the absolute directory and initial title; * called only for eligible creation, inside the registry mutation queue. * Missing directories are created recursively before registration. + * After resolution, caller cancellation does not roll back creation or registration. * @returns the initialized Workspace, or undefined when automatic creation is ineligible. */ initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise { From 230d975a040cad9f11a11a8b96040c9f6ea94cc1 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 16:18:56 +0800 Subject: [PATCH 171/284] docs(client): explain the identity tiers and the detailName scope The shared rule now states why the collapsed identity and the expanded value keep two label tiers, and the detailName comment names the row identity it also renders. --- .../ui-plugin-manager/src/client/PluginManagerPage.module.css | 4 ++-- .../src/client/PluginInventorySettingsTab.module.css | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 8b4571db6d..0975263b24 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -1112,8 +1112,8 @@ label.registryOption { line-height: 22px; } -/* The package name under the title: the exact spec that installs the package - elsewhere, where a title of its own or a shortened name hides it. */ +/* The package or row identity under the title: the exact spec that installs the + package elsewhere, where a title of its own or a shortened name hides it. */ .detailName { margin: 0; font-size: 12px; diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index 59d9b1afc0..5358b03f67 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -178,7 +178,9 @@ font-weight: 600; } -/* The Loader entry id keeps one size in the card subtitle and the row it expands into. */ +/* The Loader entry id keeps one size in the card subtitle and the row it expands into; + the collapsed subtitle stays on the quieter label tier, while the expanded row carries + the card's primary value. */ .cardIdentity, .entryValue { font-family: var(--ds-font-family-code); From 44cf9b703d6397f8a229505fa0b21e2cae1199fc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 20 Sep 2026 16:25:03 +0800 Subject: [PATCH 172/284] docs(readme): point developers at dev:web and the Make targets --- README.i18n.yaml | 4 ++-- README.md | 2 ++ README.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index 904c7928d2..ce6f75b18d 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -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 README.md -README.md: 36adfe913902e75ce0673adc2cb5cb692151a291 -README.zh.md: f6861b4c9490741b5e5f2d825a370766178dd9f6 +README.md: 423bccd7d9352dbc48f1705f37d35642e74f5b41 +README.zh.md: c760f87e4ac3b7dbb7bb55d733e31db5ab379836 diff --git a/README.md b/README.md index 36adfe9139..423bccd7d9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). Start with the [development guide](docs/development.md) and [architecture documentation](docs/architecture.md). +`pnpm run dev:web` builds, serves, and rebuilds client bundles on source edits in one terminal, and `make help` lists the matching Make targets for Web and Desktop; the guide's application commands section owns the full table. + For agents, follow [AGENTS.md](AGENTS.md). ## Citation diff --git a/README.zh.md b/README.zh.md index f6861b4c94..c760f87e4a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -75,6 +75,8 @@ pnpm dsh web 请先阅读[开发指南](docs/development.zh.md)与[架构文档](docs/architecture.zh.md)。 +`pnpm run dev:web` 会在一个终端里完成构建、启动,并在源码修改时重建 client bundle;`make help` 列出 Web 与 Desktop 对应的 Make target。完整表格见开发指南的「应用命令」一节。 + 面向 agent:请遵循 [AGENTS.md](AGENTS.md)。 ## 引用 From 47ee608079d4693b2fa7ebd682f50c015480a5c9 Mon Sep 17 00:00:00 2001 From: gengruilin Date: Sun, 20 Sep 2026 16:34:49 +0800 Subject: [PATCH 173/284] fix(web): allow overflowing settings navigation to scroll --- apps/web/tests/settings-chrome.e2e.ts | 44 +++++++++++++++++++ .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 + .../client/ui-settings-general/README.zh.md | 2 + .../src/client/SettingsRoot.module.css | 3 +- 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index d84614d33c..8dc23b8560 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -187,6 +187,50 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('scrolls overflowing section navigation independently of the title and settings content', async () => { + const overflowPage = await browser.newPage({ viewport: { width: 1280, height: 768 }, locale: ZH_BROWSER_LOCALE }) + onTestFinished(() => overflowPage.close()) + onTestFailed(() => saveFailureShot(overflowPage, 'web-e2e-settings-navigation')) + const console = watchConsole(overflowPage) + await overflowPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await overflowPage.getByRole('button', { name: '设置', exact: true }).click() + const dialog = overflowPage.getByRole('dialog', { name: '设置', exact: true }) + const navigation = dialog.getByRole('navigation') + const list = navigation.locator('[class*="_navList"]') + const title = navigation.getByText('设置', { exact: true }) + const options = dialog.locator('[class*="_options"]') + const last = navigation.getByRole('button').last() + await last.waitFor() + expect(await list.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(false) + + // A short viewport overflows the shipped sections without synthetic registrations. + await overflowPage.setViewportSize({ width: 1280, height: 240 }) + expect(await list.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true) + const titleBefore = await title.boundingBox() + const contentBefore = await options.innerText() + await navigation.getByRole('button').first().hover() + await overflowPage.mouse.wheel(0, 1000) + await expect.poll(() => last.evaluate((el) => { + const rect = el.getBoundingClientRect() + return el.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)) + })).toBe(true) + const navScroll = await list.evaluate(el => el.scrollTop) + expect(navScroll).toBeGreaterThan(0) + expect(await title.boundingBox()).toEqual(titleBefore) + expect(await options.evaluate(el => el.scrollTop)).toBe(0) + + await options.hover() + await overflowPage.mouse.wheel(0, 1000) + await expect.poll(() => options.evaluate(el => el.scrollTop)).toBeGreaterThan(0) + expect(await list.evaluate(el => el.scrollTop)).toBe(navScroll) + expect(await title.boundingBox()).toEqual(titleBefore) + await last.click() + await expect.poll(() => last.getAttribute('aria-current')).toBe('true') + await expect.poll(() => options.innerText()).not.toBe(contentBefore) + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) + }) + it('stores Permission as the default for future sessions without changing an existing session', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission')) const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before')) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 2f86bc5b7c..5ddaacb419 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -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/ui-settings-general/README.md -README.md: ebdb22adc071b5df435fddd6620dd982d866aa36 -README.zh.md: 0bb188ea5024b14e68baa1a687a654ee8963f3d7 +README.md: 2337b7db8f5b8d2713d17f83092d948657a5acdb +README.zh.md: 2fd25f3d1b44c400b4400397e94d641766d9ae62 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index ebdb22adc0..2337b7db8f 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -27,6 +27,8 @@ Use this package to give the dsh web client a Settings panel, connection-recover Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension; its permanent retry glyph marks the retry action, which the Chinese outage copy also names (连接异常,刷新重试). Every recovery attempt shows the shared ongoing loader beside **Reconnecting** with one to three dots advancing every 500ms, and an attempt stays visible for at least 800ms so brief retries do not flicker. Selecting either yellow state starts an immediate retry; press feedback stays within the warning palette. Recovery changes the region to pale-green **Connected** for two seconds from the moment the green pill becomes visible. The pill fades in on appearance, fades out over 150ms on removal, and sizes to its current label. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time. +When the section navigation exceeds the panel's available height, the list scrolls independently of the settings content and keeps the Settings title fixed. + In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a dot on its top expand button. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. ### The General section diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 0bb188ea50..2fd25f3d1b 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;其常驻重试图形与中文文案「连接异常,刷新重试」都指明重试动作。每次恢复尝试都显示共享 ongoing loading 加**重新连接中**,其后一至三个点每 500ms 前进一次,且每次尝试至少可见 800ms,短暂重试不会闪动。选中任一黄色状态都会立即发起重试;按压反馈留在黄色色阶内。恢复后该区域变为浅绿色的**连接成功**,从绿色药丸可见起驻留 2 秒再消失。药丸出现时淡入、移除时以 150ms 淡出,宽度随当前文案自适应。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。 +当分区导航超出面板可用高度时,列表独立于设置内容滚动,并保持「设置」标题固定。 + 在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以圆点显示相同状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。 ### 「通用」分区 diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css index 783eea44a9..8e7569b3ca 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -79,7 +79,7 @@ One height for every section, taken from the viewport rather than the content: sections differ by hundreds of pixels (a settings list against the composition editor), and a content-sized panel would resize under the - pointer on every nav click. Whatever does not fit scrolls in `.options`. + pointer on every nav click. The navigation and options scroll independently. 24px of air above and below, widened to the frame's published top clearance (the macOS window strip, where clicks drag the window) on both sides so the panel sits centered and clear of the strip. */ @@ -129,6 +129,7 @@ display: flex; flex-direction: column; gap: 4px; + overflow-y: auto; } /* Nav cell (figma .Setting-nav-cell 501:29962): 164x40, r12, pad From a4148d687fa5f30f1c0998af81fdaf9869fe8f41 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 16:46:58 +0800 Subject: [PATCH 174/284] fix: refine file action menus feedback and shared queries --- apps/web/tests/present.e2e.ts | 3 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- packages/api/session-controller/src/index.ts | 3 +- .../session-open-workspace-path.host.spec.ts | 6 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/Deliverables.module.css | 4 + .../src/client/PresentedFileCard.tsx | 10 +- .../ui-deliverables/src/client/locales.ts | 4 +- .../src/client/present-open.ts | 23 +++- .../tests/present-open.client.spec.ts | 40 ++++++- .../client/ui-open-in-app/README.i18n.yaml | 4 +- packages/client/ui-open-in-app/README.md | 4 +- packages/client/ui-open-in-app/README.zh.md | 4 +- .../src/client/FileRouteAction.tsx | 2 +- .../src/client/OpenTargetButton.module.css | 2 + .../src/client/OpenTargetButton.tsx | 26 +++-- .../src/client/file-applications.ts | 102 ++++++++++++------ .../client/ui-open-in-app/src/client/index.ts | 3 +- .../tests/browser-plugin.client.spec.ts | 1 + .../tests/file-applications.client.spec.tsx | 53 +++++++++ .../tests/open-in-app-action.client.spec.tsx | 7 ++ .../tests/open-path-action.client.spec.tsx | 26 ++++- 26 files changed, 267 insertions(+), 76 deletions(-) create mode 100644 packages/client/ui-open-in-app/tests/file-applications.client.spec.tsx diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 4df8bfae81..36d237857a 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -192,6 +192,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con ]) expect(exported).not.toContain('EDITED_REPORT') if (MODE !== 'record') { + await expect.poll(() => page.locator('[data-presented-file] [role="status"]').count(), { timeout: 10_000 }).toBe(0) const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) await page.locator('[data-turn-process]').click() @@ -233,7 +234,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con .find(element => element.textContent === 'report.txt'), 'report title', ) - const description = requiredElement(report.querySelector('span[role="status"]'), 'report status') + const description = requiredElement(report.querySelector('[data-presented-description]'), 'report description') const open = requiredElement( report.querySelector('[data-open-target] button'), 'report open action', diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index e01ad4e71a..7909684664 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: fda32efcfd42bd88f28de0764ef8d2c0e6a4ff9f -README.zh.md: 9eb60799088cb5cc9091352f38dfb50cfd6dbfd3 +README.md: 84734325be38099f0b5ff91026607029aaae7863 +README.zh.md: 41123ae048e1edda132901920222236364095842 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index fda32efcfd..84734325be 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -68,7 +68,7 @@ References keep local Session data, scoped Contexts, and history streams alive, `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. -`workspacePathApplications({ path })` returns the serving desktop’s file handlers after the same filesystem mapping check used by `openWorkspacePath`. An optional `application` in the open request selects a currently registered handler without changing the system default. Application names, default selection, icons, and platform coverage come from [native-command](../../util/native-command/README.md). These operations do not activate an Agent or append Session events. +`workspacePathApplications({ path })` returns the serving desktop’s file handlers after the same filesystem mapping check used by `openWorkspacePath`. An optional `application` in the open request selects a currently registered handler without changing the system default. Application names, default selection, icons, and platform coverage come from [native-command](../../util/native-command/README.md). These operations do not activate an Agent or append Session events. Native failures return bounded messages; original command errors remain Host-side causes. ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 9eb6079908..41123ae048 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -68,7 +68,7 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 -`workspacePathApplications({ path })` 使用与 `openWorkspacePath` 相同的文件系统映射校验,返回服务端桌面上该文件的关联应用。打开请求中的可选 `application` 指定当前关联的应用,不修改系统默认应用。应用名称、默认项、图标和平台支持范围由 [native-command](../../util/native-command/README.zh.md) 提供。这些操作不激活 Agent,也不追加会话事件。 +`workspacePathApplications({ path })` 使用与 `openWorkspacePath` 相同的文件系统映射校验,返回服务端桌面上该文件的关联应用。打开请求中的可选 `application` 指定当前关联的应用,不修改系统默认应用。应用名称、默认项、图标和平台支持范围由 [native-command](../../util/native-command/README.zh.md) 提供。这些操作不激活 Agent,也不追加会话事件。原生操作失败时返回简短消息,原始命令异常作为 Host 端原因保留。 ----- diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index a9750a635f..9cab20852d 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -329,8 +329,9 @@ export class SessionController extends TypertRemoteService { if (error instanceof RemoteError) throw error throw new RemoteError( 'gateway/internal', - `path open failed: ${error instanceof Error ? error.message : String(error)}`, + 'path open failed', {}, + { cause: error }, ) } } diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index 93f51c7ce3..e8216b5214 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -101,7 +101,7 @@ describe('session/openWorkspacePath', () => { it('preserves native opener failure and cancellation results', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => - Promise.reject(new Error('desktop unavailable'))) + Promise.reject(new Error('Command failed: powershell.exe -EncodedCommand private-script-text'))) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', @@ -111,7 +111,7 @@ describe('session/openWorkspacePath', () => { await expect(remote.openWorkspacePath({ path: 'result.html' })) .resolves.toMatchObject({ ok: false, - error: { code: 'gateway/internal', message: 'path open failed: desktop unavailable' }, + error: { code: 'gateway/internal', message: 'path open failed' }, }) const aborted = new AbortController() @@ -140,7 +140,7 @@ describe('session/openWorkspacePath', () => { await expect(controller.openWorkspacePath({ path: 'second.html', }, new AbortController().signal)).rejects.toMatchObject({ - code: 'gateway/internal', message: 'path open failed: desktop unavailable', + code: 'gateway/internal', message: 'path open failed', }) }) }) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 85892e110d..c3c62898a1 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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/ui-deliverables/README.md -README.md: a8e80ede3380cef7e0550a0a63aa195642ed4e12 -README.zh.md: 929ab7f93f6ff995586bd850b655eb2e7a31ac93 +README.md: ce006783310c257957bc8cfddcfd18226972d18f +README.zh.md: 82239ba169f4ada188e8a5ba8e1889c61b336f96 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index a8e80ede33..ce00678331 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -32,7 +32,7 @@ Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](. The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/tool-present/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Duplicate declarations use the last description before the closing reply. -The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. +The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Successful open or reveal feedback stays visible for five seconds, fades over 200ms, then restores the file description; failures remain visible until another attempt. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. ### The changed-files card diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 929ab7f93f..82239ba169 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -32,7 +32,7 @@ kind: "package-reference" Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。重复声明使用收尾回复之前最近一次的说明。 -`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 +`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位成功的反馈显示五秒后,在 200ms 内淡出并恢复文件说明;失败信息保留到下次尝试。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 ### 改动文件卡片 diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index edd1cac863..0194aaf183 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -26,3 +26,7 @@ @container (max-width: 620px) { .presented { grid-template-columns: minmax(0, 1fr); } } @media (pointer: coarse) { .split { min-height: 44px; } .open, .chevron { min-width: 44px; } } .actions { display: inline-flex; flex: none; pointer-events: auto; } + +.secondaryText[data-success] { animation-name: success-fade; animation-timing-function: ease-out; animation-fill-mode: forwards; } +@keyframes success-fade { to { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { .secondaryText[data-success] { animation-name: none; } } diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 97008ceced..f0dd26035b 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -4,7 +4,7 @@ import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { FileTypeIcon, fileExtension } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { PresentedHost } from '../presented.ts' -import type { PresentedOpenPhase } from './present-open.ts' +import { PRESENTED_SUCCESS_HOLD_MS, PRESENTED_SUCCESS_FADE_MS, type PresentedOpenPhase } from './present-open.ts' import { basename, type PresentedPath } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './Deliverables.module.css' @@ -27,6 +27,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, actions, onPreview: () => void actions: ReactNode } & PropsLocale) { + const succeeded = phase === 'opened' || phase === 'revealed' const reveal = host?.fileManager ?? 'directory' const name = basename(file.path) const metadata = fileExtension(name).toUpperCase() || t('presented.file') @@ -42,9 +43,12 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, actions,
    {name} - - {status} + + {status} + {t('presented.preview')}
    diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 4ed0595bc3..69e976fa06 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -23,7 +23,7 @@ export const zh = { 'presented.collapse': '收起', 'presented.collapseAria': '收起交付文件列表', 'presented.opening': '正在打开…', - 'presented.opened': '已在默认程序中打开', + 'presented.opened': '已请求打开', 'presented.error': '打开失败,点击重试', 'presented.file': '文件', 'row.title': '交付文件', @@ -85,7 +85,7 @@ export const en: Record = { 'presented.collapse': 'Collapse', 'presented.collapseAria': 'Collapse delivered files', 'presented.opening': 'Opening…', - 'presented.opened': 'Opened in default app', + 'presented.opened': 'Open requested', 'presented.error': 'Could not open. Click to retry.', 'presented.file': 'File', 'row.title': 'Present files', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index 92764884ef..fae3c3557a 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -4,6 +4,11 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { changedFileUrl } from '../changes.ts' import { presentedFileUrl, PRESENT_HOST_ROUTE, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' +/** Success feedback remains fully visible for five seconds before fading. */ +export const PRESENTED_SUCCESS_HOLD_MS = 5000 +/** Fade interval shared by the card animation and status expiry. */ +export const PRESENTED_SUCCESS_FADE_MS = 200 + /** Failure feedback for the shared native opening control. */ export type PresentedOpenFailure = 'openError' | 'revealError' | null @@ -16,6 +21,7 @@ export class PresentedOpenController { readonly state = createSnapshotStore>({}) /** Native destination metadata, or a retryable read failure. */ readonly host = createSnapshotStore(null) + private readonly expiry = new Map>() private loading: Promise | undefined private metadata = new AbortController() private readonly lifetime = new AbortController() @@ -55,6 +61,7 @@ export class PresentedOpenController { private async openUrl(url: string, action: PresentedAction, application?: string): Promise { const phase = this.state.getSnapshot()[url] if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return null + this.clearExpiry(url) this.state.update((state) => { state[url] = action === 'open' ? 'opening' : 'revealing' }) const task = this.request(url, action, application) this.pending.add(task) @@ -110,9 +117,15 @@ export class PresentedOpenController { /** Cancel outstanding requests and wait until no request can publish state. */ async dispose(): Promise { this.lifetime.abort() + for (const url of this.expiry.keys()) this.clearExpiry(url) await Promise.all(this.pending) } + private clearExpiry(url: string): void { + clearTimeout(this.expiry.get(url)) + this.expiry.delete(url) + } + private async request(url: string, action: PresentedAction, application?: string): Promise { const failure = action === 'open' ? 'error' : 'revealError' let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed' @@ -125,7 +138,15 @@ export class PresentedOpenController { // Transport failures share the retryable card state with Host open failures. phase = failure } - if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = phase }) + if (!this.lifetime.signal.aborted) { + this.state.update((state) => { state[url] = phase }) + if (phase === 'opened' || phase === 'revealed') { + this.expiry.set(url, setTimeout(() => { + this.expiry.delete(url) + this.state.update((state) => { Reflect.deleteProperty(state, url) }) + }, PRESENTED_SUCCESS_HOLD_MS + PRESENTED_SUCCESS_FADE_MS)) + } + } return phase === 'opened' || phase === 'revealed' ? null : action === 'reveal' ? 'revealError' : 'openError' } } diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index 468e065ad4..dea83c354e 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -3,7 +3,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session/types' import { PresentedOpenController } from '../src/client/present-open.ts' -afterEach(() => { vi.unstubAllGlobals() }) +afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers() }) const id = SessionId('fork') const url = 'api/present.open?sessionId=fork&seq=2&index=1' @@ -184,3 +184,41 @@ it('encodes an explicit application identifier without changing the file coordin expect(fetcher).toHaveBeenCalledWith(`${url}&application=%2FApps%2FA%26B.app`, { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) await controller.dispose() }) + + +it.each(['open', 'reveal'] as const)('expires successful %s feedback after five seconds and its fade', async (action) => { + vi.useFakeTimers() + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 }))) + const controller = new PresentedOpenController() + await controller.open(id, 2, 1, action) + await vi.advanceTimersByTimeAsync(5000) + expect(controller.state.getSnapshot()[url]).toBe(action === 'open' ? 'opened' : 'revealed') + await vi.advanceTimersByTimeAsync(200) + expect(controller.state.getSnapshot()[url]).toBeUndefined() + await controller.dispose() +}) + +it('cancels an earlier success expiry when the next action fails', async () => { + vi.useFakeTimers() + const fetcher = vi.fn().mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(new Response(null, { status: 500 })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.open(id, 2, 1) + await vi.advanceTimersByTimeAsync(4000) + await controller.open(id, 2, 1) + await vi.advanceTimersByTimeAsync(6000) + expect(controller.state.getSnapshot()[url]).toBe('error') + await controller.dispose() +}) + +it('cancels success expiry when its controller is disposed', async () => { + vi.useFakeTimers() + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 }))) + const controller = new PresentedOpenController() + await controller.open(id, 2, 1) + const state = controller.state.getSnapshot() + await controller.dispose() + await vi.advanceTimersByTimeAsync(6000) + expect(controller.state.getSnapshot()).toBe(state) +}) diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index 977cb96683..b0fa75db3e 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -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/ui-open-in-app/README.md -README.md: 7840efdf1bb092cd538865f2b9b74b0911cfb06f -README.zh.md: 0774e5228551b8463de129f49be2ac30f05f1273 +README.md: 138be128c291fc4a0611295c54768a95f33f3ae1 +README.zh.md: b81f62f9dbf8ccfbc1822b8a72df95e7169228c0 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index 7840efdf1b..138be128c2 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -31,9 +31,9 @@ Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../h The Session header and document header use the same 24px-high split button with 9px corners. Both headers show only the icon; their tooltip names the default application or the reveal action. Both show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application. -File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” always stays last. When no default application is identified, that row reads “Show file location (default)” and the main button reveals the file. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. +File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When no default application is identified, that row reads “Show file location (default)” and the main button reveals the file. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. -File associations refresh when the file changes or its menu opens; cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control. +Mounted controls with the same association reader and file share one query and its results; opening either menu refreshes all of them. Releasing the last control cancels the query and discards its state. Cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control. ----- diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index 0774e52285..b81f62f9db 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -31,9 +31,9 @@ kind: "package-reference" 会话标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。两个标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。两者都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 -文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”始终放在最后。未识别到默认应用时,该项显示为“显示文件位置(默认)”,主按钮也执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 +文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。未识别到默认应用时,该项显示为“显示文件位置(默认)”,主按钮也执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 -文件变化或打开菜单时会重新查询文件关联;已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。 +使用同一查询函数和文件的已挂载控件共用查询与结果,打开任一菜单会刷新所有相关控件。最后一个控件释放后取消查询并清除状态。已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。 ----- diff --git a/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx b/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx index 287b4b3956..49c27245f1 100644 --- a/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx +++ b/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx @@ -30,7 +30,7 @@ export function FileRouteAction( if (!props.available) return null return app.default)?.id} failed={association.failed} - loading={association.loading || props.pending} refresh={association.refresh} t={props.t} + loading={association.loading} busy={props.pending} refresh={association.refresh} t={props.t} execute={async (operation) => { return props.onAction(operation.kind === 'reveal' ? 'reveal' : 'open', operation.kind === 'application' ? operation.id : undefined) }} /> diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css b/packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css index 89089a8c2a..44a66f59f2 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css @@ -76,3 +76,5 @@ .split[data-size='large'] .chevron { padding: 8px 10px; } + +.skeleton { display: inline-block; flex: none; border-radius: 3px; background: var(--dsw-alias-interactive-bg-hover); } diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index 8cc56c74e5..65306e4716 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -26,6 +26,7 @@ export interface OpenTargetButtonProps { readonly applications: readonly OpenTargetApplication[] readonly defaultId: string | undefined readonly failed: boolean + readonly busy?: boolean readonly loading?: boolean readonly prominent?: boolean readonly t: TranslateNS @@ -79,19 +80,22 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { const [menuOpen, setMenuOpen] = useState(false) const { pending, toast, act } = useOpenTargetGesture(props.execute, t) const preferred = applications.find(app => app.id === defaultId) - const disabled = pending || props.loading === true + const disabled = pending || props.busy === true || props.loading === true + const hasMenu = props.loading === true || props.failed || applications.length + (kind === 'file' ? 1 : 0) > 1 const revealDefault = kind === 'file' && preferred === undefined && props.loading !== true const primaryLabel = preferred === undefined ? t('path.reveal') : t('open.title', { app: preferred.name }) const run = (operation: OpenTargetOperation): void => { setMenuOpen(false); act(operation) } const primary = (): void => { run({ kind: revealDefault ? 'reveal' : 'default' }) } - const icon = revealDefault - ? - : + const icon = props.loading === true && preferred === undefined + ?
    {open && ( From b6f99fad556a1bfe7d8fe106e91b58a162ba532c Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 22:19:45 +0800 Subject: [PATCH 233/284] feat(ui-chat): filter system prompts and permission commands --- .../ui-chat/src/client/contract/chat-visibility.ts | 13 +++++++++++++ .../conversation-nodes/chat-snapshot-builder.ts | 3 ++- .../client/conversation-nodes/turn-navigation.ts | 3 ++- .../conversation-nodes/turn-process-presentation.ts | 3 ++- 4 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 packages/client/ui-chat/src/client/contract/chat-visibility.ts diff --git a/packages/client/ui-chat/src/client/contract/chat-visibility.ts b/packages/client/ui-chat/src/client/contract/chat-visibility.ts new file mode 100644 index 0000000000..7e66c15ca5 --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/chat-visibility.ts @@ -0,0 +1,13 @@ +/** Chat-only row visibility; durable events and trajectory inspection remain intact. */ +import type { ChatNode } from './chat-nodes.ts' + +/** + * Exclude system prompts and permission commands from visible Chat rows. + * @param node - projected Chat node. + * @returns whether the node contributes a visible Chat row. + */ +export function isVisibleChatNode(node: ChatNode): boolean { + return node.visibility === 'visible' + && node.kind !== 'system-prompt' + && !(node.kind === 'command' && node.data.name === 'permission') +} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index fb91233786..8c805e6107 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -6,6 +6,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' +import { isVisibleChatNode } from '../contract/chat-visibility.ts' import type { ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem, @@ -400,7 +401,7 @@ function presentationPosition( export function orderedVisibleChatNodes( nodes: readonly ChatConversationViewNode[], ): ChatConversationViewNode[] { - const visible = nodes.filter(node => node.visibility === 'visible') + const visible = nodes.filter(node => isVisibleChatNode(node as ChatNode)) const presentations = turnProcessPresentations(visible) return visible.sort((left, right) => { const leftPosition = presentationPosition(left, presentations) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts index 331840e80c..d0f6c40798 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts @@ -1,3 +1,4 @@ +import { isVisibleChatNode } from '../contract/chat-visibility.ts' import type { ChatNode } from '../contract/chat-nodes.ts' import type { ChatLocationNodeIndex, ChatNodeStore, TurnNavigationItem } from '../contract/snapshot.ts' @@ -82,7 +83,7 @@ export function turnNavigationItem( ): TurnNavigationItem | undefined { const loaded = locations.getTurn(turn) .map(key => nodes.get(key)) - .filter((node): node is ChatNode => node !== undefined && node.visibility === 'visible') + .filter((node): node is ChatNode => node !== undefined && isVisibleChatNode(node as ChatNode)) const user = loaded.find(node => node.kind === 'user') const anchor = user ?? loaded[0] if (anchor === undefined) return undefined diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts index 98a33a6c8b..d337954772 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts @@ -1,3 +1,4 @@ +import { isVisibleChatNode } from '../contract/chat-visibility.ts' import type { ChatNode } from '../contract/chat-nodes.ts' import type { ChatLocationNodeIndex, ChatNodeStore, ChatTurnProcessPresentation, @@ -49,7 +50,7 @@ function derivePresentation( let compactAnswer = true for (const key of keys) { const node = nodes.get(key) as ChatNode | undefined - if (node === undefined || node.kind === 'turn-process') continue + if (node === undefined || !isVisibleChatNode(node) || node.kind === 'turn-process') continue if ((node.kind === 'user' || node.kind === 'steering') && (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor) && (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) { From bcd14d776c4467d3e46875b397dc2499107858d5 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 22:22:17 +0800 Subject: [PATCH 234/284] perf(ui-chat): memoize tool and command rows --- .../src/client/chat/GenericCommandCard.tsx | 40 ++- .../src/client/tool/components/ToolRow.tsx | 289 +++++++++--------- .../src/client/tool/toolviews/bash-sample.tsx | 36 ++- 3 files changed, 198 insertions(+), 167 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx index 0374637e17..0b0749881b 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts' import { DisclosureRow, IconApiOutlineRegular, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives' import a11yCss from './accessibility.module.css' @@ -6,6 +6,8 @@ import css from './GenericCommandCard.module.css' type CommandRowState = 'running' | 'ok' | 'error' +const COMMAND_ICON = + /** Node state → row state semantic (running while unsettled; outcome kind after). */ function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): CommandRowState { if (outcome === null) return 'running' @@ -19,7 +21,12 @@ export interface GenericCommandCardProps extends CommandRowOwnerProps { runningSummary?: string | undefined } -export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCardProps) { +/** + * Render a command summary and its lazily mounted multiline output. + * @param props - command, locale, and optional running label. + * @returns the command disclosure. + */ +export const GenericCommandCard = memo(function GenericCommandCard({ node, t, runningSummary }: GenericCommandCardProps) { const [expanded, setExpanded] = useState(false) const text = node.outcome?.text const summary = node.outcome === null @@ -32,6 +39,18 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa const running = state === 'running' const body = text !== undefined && text.includes('\n') ? text : null const open = expanded && body !== null + const toggle = useCallback(() => { setExpanded(value => !value) }, []) + const collapsedContent = useMemo(() => ( + <> + + + {summary} + + + ), [running, state, summary]) + const content = useMemo(() => open + ?
    {body}
    + : undefined, [body, open, state]) return (
    {state === 'running' && {t('row.running')}} @@ -41,25 +60,18 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa leadingClassName={css.leading} titleClassName={css.title} chevronClassName={css.chevron} - icon={} + icon={COMMAND_ICON} title={title} running={running} open={open} expandable={body !== null} expandOnRowClick keepContentWhenOpen - onToggle={() => { setExpanded(value => !value) }} - collapsedContent={( - <> - - - {summary} - - - )} + onToggle={toggle} + collapsedContent={collapsedContent} > -
    {body}
    + {content}
    ) -} +}) diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 874beb9252..62b91189fc 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { memo, useCallback, useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { CodeBlock, DiffBlock, DisclosureRow, IconInspectOutlineRegular, ReadBlock, SearchBlock, @@ -101,7 +101,12 @@ function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): strin } } -export function ToolRow({ +/** + * Render one localized tool summary and lazily mounted result card. + * @param props - tool state, summary, output, and navigation callbacks. + * @returns the tool disclosure. + */ +export const ToolRow = memo(function ToolRow({ t, variant, toolName, @@ -134,9 +139,9 @@ export function ToolRow({ const readLabels = useMemo(() => readBlockLabels(t), [t]) const searchLabels = useMemo(() => searchBlockLabels(t), [t]) const webLabels = useMemo(() => webBlockLabels(t), [t]) - const terminalBody = terminal === undefined || terminal === null + const terminalBody = useMemo(() => terminal === undefined || terminal === null ? null - : localizeTerminalCardModel(terminal, t) + : localizeTerminalCardModel(terminal, t), [terminal, t]) const diffBody = diff ?? null const readBody = read ?? null const imageBody = image !== undefined && image !== null && renderSlot !== undefined && loadImage !== undefined @@ -173,26 +178,159 @@ export function ToolRow({ }, [diffBody]) const settledWithCue = state === 'error' || state === 'stopped' const suffix = settledWithCue ? null : summarySuffix ?? diffStat - const toggleExpand = () => { + const toggleExpand = useCallback(() => { setExpanded(v => !v) - } - const openFile = filePath !== undefined && onOpenFile !== undefined && !settledWithCue + }, []) + const openFile = useMemo(() => filePath !== undefined && onOpenFile !== undefined && !settledWithCue ? (event: MouseEvent) => { event.stopPropagation() if (filePathLine === undefined) onOpenFile(filePath) else onOpenFile(filePath, { line: filePathLine }) } - : undefined + : undefined, [filePath, filePathLine, onOpenFile, settledWithCue]) // Keep Enter/Space on the focused path link from bubbling to the row's // keydown handler, which would preventDefault() the key and toggle expand // instead of activating the link — the keyboard analogue of openFile's // stopPropagation. The native button still fires its own onClick from the key. - const fileLinkKeyDown = (event: KeyboardEvent) => { + const fileLinkKeyDown = useCallback((event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') event.stopPropagation() - } + }, []) // The code variant's program renders through CodeBlock (shiki), so only its // output joins the IN/OUT card; every other variant's input does too. const cardBody = variant === 'code' ? null : bodyText + const collapsedContent = useMemo(() => summaryText !== '' && ( + /* An empty summary drops the separator with it (a row that is only + its title shows no trailing dot). */ + <> + + {openFile !== undefined ? ( + + ) : ( + + {summaryText} + + )} + {suffix !== null && ( + {suffix} + )} + + ), [diffStat, fileLinkKeyDown, openFile, running, state, suffix, summaryText]) + const expandedContent = useMemo(() => open ? ( +
    + {askQuestionBody !== null + ? + : terminalBody !== null + ? ( + + ) + : diffBody !== null + ? + : readBody !== null + ? + : imageBody !== null + ? ( + /* Label, gallery, then the result's OWN envelope text. The text + comes from the image card model (which reads the result's text + block), never from the row's flattened output: an image read's + content is [text envelope, image block] and flattening + JSON.stringifies the image block, printing the raw attachment + object under the picture. It is not redundant either — the + attachment slot can render nothing, and then this line is the + only evidence an image was returned. */ +
    +
    {imageBody.label}
    + {renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', { + images: imageBody.images, + loadImage, + align: 'start', + })} +
    {imageBody.text}
    +
    + ) + : searchBody !== null + ? ( + <> + + {/* A capped search's recovery locator lives only in the result + text; show it below the card so the dropped rows survive. */} + {searchBody.recovery !== undefined && ( +
    {searchBody.recovery}
    + )} + + ) + : webBody !== null + ? + : detailsBody !== null + ? + : ( + <> + {variant === 'code' && bodyText !== null && ( +
    + +
    + )} + {(cardBody !== null || outputText !== null) && ( +
    + {cardBody !== null && ( +
    + {t('row.input')} + {cardBody} +
    + )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
    + {t('row.output')} + + {outputText} + +
    + )} +
    + )} + + )} + {inspect !== undefined && ( + + )} +
    + ) : undefined, [ + open, detailsBody, askQuestionBody, terminalBody, terminalLabels, diffBody, diffLabels, readBody, readLabels, + imageBody, renderSlot, loadImage, searchBody, searchLabels, webBody, webLabels, inspect, t, onOpenFile, + variant, bodyText, cardBody, outputText, state, + ]) return (
    {status !== null && {status}} @@ -209,135 +347,10 @@ export function ToolRow({ expandOnRowClick keepContentWhenOpen onToggle={toggleExpand} - collapsedContent={summaryText !== '' && ( - /* An empty summary drops the separator with it (a row that is only - its title shows no trailing dot). */ - <> - - {openFile !== undefined ? ( - - ) : ( - - {summaryText} - - )} - {suffix !== null && ( - {suffix} - )} - - )} + collapsedContent={collapsedContent} > -
    - {askQuestionBody !== null - ? - : terminalBody !== null - ? ( - - ) - : diffBody !== null - ? - : readBody !== null - ? - : imageBody !== null - ? ( - /* Label, gallery, then the result's OWN envelope text. The text - comes from the image card model (which reads the result's text - block), never from the row's flattened output: an image read's - content is [text envelope, image block] and flattening - JSON.stringifies the image block, printing the raw attachment - object under the picture. It is not redundant either — the - attachment slot can render nothing, and then this line is the - only evidence an image was returned. */ -
    -
    {imageBody.label}
    - {renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', { - images: imageBody.images, - loadImage, - align: 'start', - })} -
    {imageBody.text}
    -
    - ) - : searchBody !== null - ? ( - <> - - {/* A capped search's recovery locator lives only in the result - text; show it below the card so the dropped rows survive. */} - {searchBody.recovery !== undefined && ( -
    {searchBody.recovery}
    - )} - - ) - : webBody !== null - ? - : detailsBody !== null - ? - : ( - <> - {variant === 'code' && bodyText !== null && ( -
    - -
    - )} - {(cardBody !== null || outputText !== null) && ( -
    - {cardBody !== null && ( -
    - {t('row.input')} - {cardBody} -
    - )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
    - {t('row.output')} - - {outputText} - -
    - )} -
    - )} - - )} - {inspect !== undefined && ( - - )} -
    + {expandedContent}
    ) -} +}) diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index 37b1d355d0..ebbe18e532 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type KeyboardEvent } from 'react' +import { memo, useCallback, useMemo, useState, type KeyboardEvent } from 'react' import type { Context } from '@deepseek-ai/cordis' import clsx from 'clsx' import { @@ -21,6 +21,8 @@ import css from './bash-sample.module.css' type BashRowProps = ToolCallViewProps & PropsLocale<'conversation'> +const BASH_ICON = + /** Visually hidden status for the color-only running sweep and error tone. */ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null { switch (state) { @@ -31,14 +33,19 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null { } } -/** Renders expandable Bash output with an accessible lifecycle label. */ -export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) { - const model = toolRowModel(toolName, block) +/** + * Render expandable Bash output with an accessible lifecycle label. + * @param props - tool call, Session sources, locale, and inspection callback. + * @returns the Bash output row. + */ +export const BashRow = memo(function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) { + const model = useMemo(() => toolRowModel(toolName, block), [toolName, block]) // An omitted shell workdir is the session workspace; relative values resolve // against it before reaching the terminal primitive. const cwd = useSessions(list => list.byId[sessionId]?.cwd) - const terminalModel = terminalCardModel(block, cwd) - const terminal = terminalModel === null ? null : localizeTerminalCardModel(terminalModel, t) + const terminalModel = useMemo(() => terminalCardModel(block, cwd), [block, cwd]) + const terminal = useMemo(() => terminalModel === null ? null : localizeTerminalCardModel(terminalModel, t), [terminalModel, t]) + const labels = useMemo(() => terminalBlockLabels(t), [t]) // A failing exit status is the terminal card's own error signal (the call // itself settles isError:false), surfaced through the row's error summary. const state = model.state === 'ok' && terminalModel !== null && terminalFailed(terminalModel) @@ -64,25 +71,24 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: ? model.errorSummary ?? normalSummary : state === 'stopped' ? t('bash.stopped') : null const running = state === 'running' - const toggleExpand = () => { + const toggleExpand = useCallback(() => { setExpanded(v => !v) - } - const toggleFromKeyboard = (event: KeyboardEvent) => { + }, []) + const toggleFromKeyboard = useCallback((event: KeyboardEvent) => { if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return event.preventDefault() toggleExpand() - } - const businessIcon = + }, [expandable, toggleExpand]) const leading = open ? : expandable ? ( <> - {businessIcon} + {BASH_ICON} ) - : businessIcon + : BASH_ICON return (
    ) @@ -151,7 +157,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: )}
    ) -} +}) /** Registers the standalone Bash conversation-row sample. */ export const bashToolviewSample = { From 89ee08b78111c3fe872d8f0dfd500919c5d3448d Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 22:29:37 +0800 Subject: [PATCH 235/284] docs: record completed chat migration and deferred turn-step work --- ...26-09-20-chat-work-details-migration.zh.md | 112 +++++++++++------- 1 file changed, 67 insertions(+), 45 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md index 62381f453e..964d37d9d3 100644 --- a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md @@ -4,72 +4,94 @@ Status: proposed ## 问题 -PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发通知、工具行视觉和基础设施行隐藏。整批迁移会把独立功能与列表重组混在一起,也难以逐项判断性能变化。需要一份按功能维护的进度表,先完成不依赖 step-group 的部分,再处理分组,避免遗漏原有产品需求。 +PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发通知、工具行视觉和基础设施行隐藏。整批迁移会把独立功能与列表重组混在一起,也难以逐项判断性能变化。需要一份按功能维护的进度表,先评审独立视觉功能,再结合 step-group 统一考虑轮次相关行为,避免遗漏原有产品需求。 ## 方案 -当前迁移目标是 `feat/chat-presentation-channel`。`pr-4565` worktree 保存原 PR 的重放内容,作为产品行为和实现的参考;`feat/chat-step-groups` 保存尚未接入当前通道的分组实验。不能仅凭分支名或提交记录判断完成情况,还要核对各 worktree 中未提交的代码。 +已完成的独立功能保留在 `feat/chat-presentation-channel`。剩余未完成代码的后续工作区是仓库根目录下的 `.artifacts/worktrees/pr-4565-turnstep`,以当前通道及本进度记录为基线,草稿保持未暂存;后续结合 turn/step 与分组整体评审。`pr-4565` worktree 继续作为原 PR 的行为和实现参考;`feat/chat-step-groups` 的既有分组实验不在本次搬迁范围内。 本记录管理功能范围、当前实现和验收进度。[展示策略通道与过程分组的基础架构](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md)保留架构路线及备选方案;本记录不替代它,也不把其中的方案视为已经实现。新记录没有完整取代既有架构决策,因此保留原记录,不做归档或删除。 -本记录处于开发阶段,只维护中文版。新增迁移代码不自动暂存,不覆盖已有暂存内容。当前这一轮仅新增本记录,没有继续迁移功能代码。 +本记录处于开发阶段,只维护中文版。完成状态、实现状态、暂存或提交状态、验收状态分别记录。未完成项即使已有代码草稿,也继续标为未完成,不从清单移除。测试断言、README 和录制快照留待后续;代码写好、进入暂存区、提交或构建通过,都不等同于功能或性能验收。 ### 状态定义 -| 状态 | 含义 | -|---|---| -| 当前代码已有 | 已能在当前 worktree 源码中找到实现;不代表已提交、已发布或通过完整验收 | -| 待迁移 | 已纳入非分组迁移范围,当前尚未实现 | -| 分组阶段 | 产品需求保留,依赖分组结构或分组交互,暂不接入当前通道 | -| 待验收 | 需要行为、节点身份、刷新范围或浏览器性能证据;不能用编译结果代替 | +| 维度 | 状态 | 含义 | +|---|---|---| +| 完成 | 已完成 | 功能迁移代码已逐项审阅并提交到当前通道;行为与性能证据另行记录 | +| 完成 | 未完成 | 尚未纳入当前通道的已完成迁移范围;可以已有未暂存草稿,也可以尚未编写 | +| 实现 | 当前代码已有 | 当前通道的既有基础能力 | +| 实现 | 代码已写好 | 对应工作区中已有实现;不代表已完成、已暂存、已提交或通过验收 | +| 实现 | 待迁移 | 已纳入非分组迁移范围,当前尚未实现 | +| 实现 | 分组阶段 | 产品需求保留,依赖分组结构或分组交互,暂不接入当前通道 | +| 暂存或提交 | 未暂存 | 该功能的改动仍在工作区,包括尚未跟踪的新文件 | +| 暂存或提交 | 部分暂存 | 该功能只有部分改动进入暂存区,必须注明具体范围 | +| 暂存或提交 | 已暂存 | 该功能当前待提交的代码改动均已进入暂存区,尚未提交 | +| 暂存或提交 | 已提交 | 该功能代码已包含在当前 HEAD 中 | +| 验收 | 待验收 | 需要行为、节点身份、刷新范围或浏览器性能证据;不能用 Git 状态或编译结果代替 | + +暂存状态按产品功能对应的差异片段核对,不按整个文件推断。更新状态时同时查看工作区、暂存区和 HEAD;共享文件只暂存部分字段,不能将依赖该文件的所有功能都标为已暂存。 ### 当前通道已有的能力 -| 能力 | compact | detailed | expanded | 实现状态 | 验收状态 | -|---|---|---|---|---|---| -| 三档设置与旧值读取 | 简洁 | 详细;旧值 `normal` 读作本档,不回写 | 完全展开 | 当前代码已有 | 待验收 | -| 按轮判断整轮折叠资格 | 本轮已结束、起点已加载且存在最终答案时可折叠 | 相同 | 相同 | 当前代码已有 | 待验收 | -| 已完成推理的收起摘要 | 只显示「思考」 | 显示首行 | 显示首行 | 当前代码已有 | 待验收 | -| 模式切换时保留摘要 DOM | 用 CSS 隐藏摘要及分隔点 | 显示原有摘要及分隔点 | 相同 | 当前代码已有 | 待验收 | -| 推理预览的局部订阅 | `ReasoningRow` 选择自己的可见性 | 相同 | 相同 | 当前代码已有 | 待验收 | -| 折叠行引用稳定 | 回调、图标及未变化的摘要保持引用;完整 Markdown 仅在展开时挂载 | 相同 | 相同 | 当前代码已有 | 待验收 | +| 能力 | compact | detailed | expanded | 完成状态 | 实现状态 | 暂存或提交状态 | 验收状态 | +|---|---|---|---|---|---|---|---| +| 三档设置与旧值读取 | 简洁 | 详细;旧值 `normal` 读作本档,不回写 | 完全展开 | 已完成 | 当前代码已有 | 已提交 | 待验收 | +| 按轮判断整轮折叠资格 | 已加载起点且存在最终回复的结束轮次,可收起过程内容;停止和失败的扩展行为见迁移项 6 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 | +| 已完成推理的收起摘要 | 只显示「思考」 | 显示首行 | 显示首行 | 已完成 | 当前代码已有 | 已提交 | 待验收 | +| 模式切换时保留摘要 DOM | 用 CSS 隐藏摘要及分隔点 | 显示原有摘要及分隔点 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 | +| 推理预览的局部订阅 | `ReasoningRow` 选择自己的可见性 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 | +| 折叠行引用稳定 | 回调、图标及未变化的摘要保持引用;完整 Markdown 仅在展开时挂载 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 | 三档都保留整轮折叠。`expanded` 的产品含义是取消二级过程分组,并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。 ### 不依赖分组的迁移清单 -下表的“三档共用”表示三档都应用该行为,不表示切换档位时会改变它。 +下表的“三档共用”表示三档都应用该行为,不表示切换档位时会改变它。“不依赖分组”指实现草稿未引入 step-group,不代表都要先于分组接受。编号沿用逐项评审清单;编号 0 的编译接线修正已完成并提交,不作为产品功能另列。未完成项的草稿留在 `pr-4565-turnstep`,不是当前通道已完成的功能。下列功能的行为与性能均待验收。 -| 功能 | compact | detailed | expanded | 当前状态 | 迁移落点与注意事项 | -|---|---|---|---|---|---| -| 流式推理摘要 | 段落首行写完后才更新 | 相同 | 相同 | 待迁移 | 保留现有摘要 DOM 和局部订阅;配套右侧渐隐。当前仍跟随最新一行,不能标为完成。原版每次扫描全文的计算方式需要优化 | -| 工具标题中文化 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | Bash/Pwsh 为「运行命令」、Grep 为「搜索文件内容」、Glob 为「查找文件」、Skill 为「加载技能」;只改 UI 字典,不改模型工具名 | -| 工具行交互视觉 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 悬停时标题和摘要加深,展开箭头朝上;共享 `DisclosureRow` 的调用方一并核对 | -| Bash 停止状态的读屏提示 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 补齐停止状态的可访问名称,不改变执行或取消语义 | -| 工具及命令运行时文字扫光 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 保留固定文本节点,以运行状态控制动画;不把新建 ReactNode 和临时回调重新引入 memo 调用链;保留减少动态效果设置 | -| 取消、失败后保持过程展开 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 已停止和失败的轮次不自动收起,也不允许手动收起;可独立于计时控件迁移 | -| 整轮状态与用时控件 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 从轮次开始显示,运行时显示用时,完成后固定时长,停止或失败显示对应状态;移除底部重复运行条。控件提前出现需要处理锚点和排序,但不需要 step-group | -| 非人工触发通知 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 用持久化 Inbox 领取记录识别唤醒输入,显示独立通知;展开查看原始内容。来源标题仅使用已记录的来源字段,不解析正文猜测身份 | -| 隐藏基础设施行 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | Chat 隐藏系统提示词、普通上下文注入和 permission 命令;Session 数据及 Trajectory 保留。先保住唤醒通知,再隐藏普通上下文;可见性在节点定义处集中决定 | -| 轮次操作栏位置 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 操作栏放到轮次结束之后,反馈仍对应最后一条持久化文字回复;检查中断、重试和分支操作,不直接删除相关判定 | -| 操作栏常显条件 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 最新轮仅在最后可见内容为回复时常显,其余通过悬停或键盘聚焦显示;把判定放回 Turn 自己的投影,不引入原版分组索引 | +| 编号 | 功能 | compact | detailed | expanded | 完成状态 | 实现状态 | 暂存或提交状态 | 迁移落点与注意事项 | +|---|---|---|---|---|---|---|---|---| +| 1 | 工具标题中文化 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | Bash/Pwsh 为「运行命令」、Grep 为「搜索文件内容」、Glob 为「查找文件」、Skill 为「加载技能」;只改 UI 字典,不改模型工具名 | +| 2 | 工具行交互视觉 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | 工具行、Bash 行和命令行悬停时标题及摘要加深;共享 `DisclosureRow` 和独立 Bash 行展开箭头朝上 | +| 3 | Bash 停止状态的读屏提示 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | 补齐停止状态的可访问名称,不改变执行或取消语义 | +| 4 | 工具及命令运行时文字扫光 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 4A 扫光与 4B 附带优化均已提交 | 4A 包含 `TextShimmer` 固定 span 与 CSS 启停、工具及命令行接入、旧扫光样式替换和减少动态效果支持,不含 step/turn;4B 为业务行额外的计算缓存、稳定回调及 JSX 引用 | +| 5 | 流式推理摘要 | 段落首行写完后才更新 | 相同 | 相同 | 已完成 | 代码已写好 | 已提交 | `ReasoningRow` 沿用原 PR 的 `latestCompletedParagraphFirstLine(text)`,直接从完整文本计算;摘要 DOM、稳定 props 和右侧渐隐保留,不增加 Definition 解析状态或跨层摘要字段 | +| 6 | 取消、失败后保持过程展开 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿在 Turn 过程投影中使用 `keepOpen`;轮次与分组阶段再评审折叠资格和停止、失败行为 | +| 7 | 整轮状态与用时控件 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿将控件锚定到已加载的 `turn/start`、排在开场输入之后,以 `LiveTurnProcessLabel` 单独计时,并替换底部运行条;整体定位与状态待评审 | +| 8 | 非人工触发通知 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿使用 next-turn 及首步 next-step 领取事实识别唤醒输入,投影 `turn-trigger`;轮次起点、通知归属和展开内容待评审 | +| 9(permission/系统) | 隐藏 permission 命令与系统提示词行 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | `isVisibleChatNode` 集中判断,现有 Chat 列表、导航和过程展示复用;命令与请求提示词 Definition 不承载这些过滤规则,持久化数据与 Trajectory 不变 | +| 9(context) | 隐藏普通上下文行 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 与 8 的唤醒通知配套,保留来源解释;过滤规则的最终归属留到轮次与分组阶段 | +| 10 | 轮次操作栏位置 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿将 `turn-tail` 锚定到 `turn/end` 之后;反馈、复制的回复归属和分支的真实轮次结束序号待整体评审 | +| 11 | 操作栏常显条件 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿由 Chat builder 按 Turn 投影 `footer`;最新轮以回复结尾时常显,其余悬停或聚焦显示,尾部判定与分组一起考虑 | 开发者工具默认开启已由当前基线提供,不重复迁移。当前实现保留等待 Host 接受设置后才启用相关能力的保护,不以原 PR 的缺值回退覆盖它。原 PR 的 `Switch` 改动只有注释,不作为功能迁移项。 +### 后续评审批次 + +评审批次独立于实现和暂存状态。未完成草稿的后续评审统一在 `pr-4565-turnstep`,保持未暂存;已有草稿不代表迁移已完成。仅搬迁当前通道的剩余代码,不自动合入 `feat/chat-step-groups` 的实验,也不在目标工作区安装依赖或编译。 + +| 批次 | 功能 | 评审范围 | +|---|---|---| +| 独立功能,已提交 | 4A:文字扫光 | `TextShimmer` 本身、各行的调用方式和浏览器绘制成本;性能待验收 | +| 独立优化,已提交 | 4B:附带的 memo 与引用稳定化 | `ToolRow`、`BashRow` 和 `GenericCommandCard` 的计算缓存、回调和 JSX 引用;不是扫光功能的必要条件,性能待验收 | +| 独立功能,已提交 | 9 中的 permission 命令与系统提示词隐藏 | 沿用原 PR 的集中可见性判断;当前无 step-group,主列表改在 `orderedVisibleChatNodes` 接入,不依赖唤醒消息分类 | +| 未完成,转入 turnstep 工作区 | 6、7、10、11 | 整轮折叠资格、状态与时钟、操作栏位置和尾部判定,统一考虑轮次与分组关系 | +| 未完成,转入 turnstep 工作区 | 8、9 中的普通 context 隐藏 | 触发通知依赖轮次起点和 Inbox 领取判定;隐藏普通上下文与通知配套,避免先隐藏唤醒原因 | + ### 分组阶段保留的需求 -这些项目当前均未接入 `feat/chat-presentation-channel`。隔壁 worktree 有实验代码,不代表这些产品行为或性能要求已经完成。 +这些项目均未完成,继续保留全部需求,后续在 turn/step 与分组阶段处理。当前通道和本次搬迁草稿均未引入 step-group;`feat/chat-step-groups` 的实验代码不代表这些产品行为或性能要求已经完成。 | 功能 | compact | detailed | expanded | 当前状态 | |---|---|---|---|---| -| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行直接展示,不使用二级分组包装 | 分组阶段 | -| 同一 Assistant 中推理与回复的分别显隐 | 推理可归入前组,回复外露 | 相同 | 不做二级分组隐藏 | 分组阶段;不直接复制原版拆成两个渲染节点的实现 | -| 组头类别摘要 | 运行时显示当前类别,完成后聚合主要类别 | 相同 | 无组头 | 分组阶段 | -| 运行中组头详情 | 不显示 | 显示命令、路径、查询等详情,无运行工具时显示推理摘要 | 无组头 | 分组阶段 | -| 组头内容更新节奏 | 保留类别标题的最短展示时间 | 同时处理详情更新 | 不适用 | 分组阶段 | -| 组内限高滚动 | 展开内容限高、独立滚动、边缘渐隐 | 相同 | 不适用 | 分组阶段 | -| 分组与整轮折叠联动 | 整轮收起时隐藏分组 | 相同 | 仅整轮折叠 | 分组阶段;保留节点身份,内部开合状态是否重置单独确定 | -| 分页、流式结算与组身份 | 既有组头和成员稳定,半截轮单独处理 | 相同 | 同一批业务节点直接展示 | 分组阶段 | +| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行直接展示,不使用二级分组包装 | 未完成(分组阶段) | +| 同一 Assistant 中推理与回复的分别显隐 | 推理可归入前组,回复外露 | 相同 | 不做二级分组隐藏 | 未完成;不直接复制原版拆成两个渲染节点的实现 | +| 组头类别摘要 | 运行时显示当前类别,完成后聚合主要类别 | 相同 | 无组头 | 未完成(分组阶段) | +| 运行中组头详情 | 不显示 | 显示命令、路径、查询等详情,无运行工具时显示推理摘要 | 无组头 | 未完成(分组阶段) | +| 组头内容更新节奏 | 保留类别标题的最短展示时间 | 同时处理详情更新 | 不适用 | 未完成(分组阶段) | +| 组内限高滚动 | 展开内容限高、独立滚动、边缘渐隐 | 相同 | 不适用 | 未完成(分组阶段) | +| 分组与整轮折叠联动 | 整轮收起时隐藏分组 | 相同 | 仅整轮折叠 | 未完成;保留节点身份,内部开合状态是否重置单独确定 | +| 分页、流式结算与组身份 | 既有组头和成员稳定,半截轮单独处理 | 相同 | 同一批业务节点直接展示 | 未完成(分组阶段) | 分组产品细节的核对项:完成摘要最多聚合前三类,超过三类使用「等」;调用计数用于分类排序,不直接显示为组头数字。详细模式的实时详情合并空白并限制为 160 个字符;标题最短展示时间为 150ms,只保留最新更新,不补播积压项。展开区高度不超过 400px 与半屏中的较小值,可滚动方向显示 24px 渐隐,并保留与外层滚动的衔接。重试行不属于二级过程组。这些数值是原 PR 的行为参考,不代表当前分组实验已经实现。 @@ -78,7 +100,7 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发 | 场景 | 要保留的性质 | 所需证据 | |---|---|---| | 切换三档模式 | 不改变业务节点 key,不因模式切换重新注册 Definition;仅相关可见性或展示字段更新 | 节点身份和刷新范围检查 | -| 推理或工具内容增量更新 | 只更新所属节点;避免每个分片重新扫描完整推理或完整历史 | 增量工作量与长内容场景 | +| 推理或工具内容增量更新 | 只更新所属节点,不扫描完整历史;推理摘要在渲染时从当前完整文本计算 | 更新范围与长内容计算开销;当前摘要算法不减少 `ReasoningRow` 的渲染次数 | | 整轮运行时钟 | 只有运行中的时钟内容按秒更新,不带动整轮成员、历史行和列表 | 更新范围及计时器释放检查 | | 摘要显隐 | 轻量摘要常驻;完整 Markdown 正文不为模式切换而全部常驻 | DOM 身份、首次加载和内存观察 | | 分页 | 新增节点与事实变化的节点更新,已完成轮次不因全局分页状态一起刷新 | 多页历史交互与刷新范围检查 | @@ -98,18 +120,18 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发 ## 验收标准 -完成一项时更新它的实现状态、实际落点和验证记录;未完成项保留,不因拆分分支而删除。分组阶段开始前,逐行复核上面的产品需求和模式对应关系。 +每项代码写好、暂存或提交后,分别更新对应状态;完成状态按逐项评审和提交记录更新,验收只按实际验证记录更新。未完成项保留,不因拆分分支或搬迁工作区而删除。分组阶段开始前,逐行复核上面的产品需求和模式对应关系。 | 验收项 | 当前状态 | |---|---| | 当前通道已有能力的完整行为验证 | 待验收;既有测试包含只做类型接线、尚未调整的旧断言 | -| 非分组功能迁移 | 待迁移;本次仅创建进度记录 | +| 非分组功能迁移 | 完成范围见迁移清单;未完成草稿留到 `pr-4565-turnstep` 继续评审,功能与性能验证仍未完成 | | 模式切换时的 DOM 身份和刷新范围 | 待验收;不能以 memo 或 selector 的存在代替实际证据 | | 长会话首次打开、分页、流式输入与内存 | 待验收;原 PR 的历史测量不代表本实现 | | 新行为的单元、浏览器与录制快照覆盖 | 待补;开发阶段不在此轮运行测试或录制 | -| 分组功能与两档差异 | 分组阶段 | +| 分组功能与两档差异 | 未完成(分组阶段) | -此前通道调整有编译检查记录,但不是本次全部迁移的通过证明,也不代表当前所有测试通过。本轮未执行检查。 +历史构建记录:迁移草稿曾通过三个源码包的单独 TypeScript 构建和完整 `pnpm run build`。首轮构建暴露的展示策略辅助文件编译归属问题已修正并提交,没有调整测试断言。该记录不代表拆分后的当前通道或 turnstep 工作区已重新验证;本次仅提交进度记录并搬迁草稿,不安装依赖、不编译、不执行测试、快照或性能基准。 ## 风险 From f1c035b1e9d6b91e00c58975b77e9b49a3f5c4e7 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 23:20:07 +0800 Subject: [PATCH 236/284] test(chat): align presentation coverage and snapshots --- ...sentation-policy-and-step-groups.i18n.yaml | 6 + ...hat-presentation-policy-and-step-groups.md | 286 ++++++++++++++++++ ...-presentation-policy-and-step-groups.zh.md | 22 +- ...9-20-chat-work-details-migration.i18n.yaml | 6 + .../2026-09-20-chat-work-details-migration.md | 144 +++++++++ ...26-09-20-chat-work-details-migration.zh.md | 12 +- .../tests/chat-continuous-conversation.e2e.ts | 4 +- .../expected/cordis-history/ui.expected.md | 9 +- .../expected/fork-mid-turn/ui.expected.md | 4 +- .../conversation-expanded.expected.md | 1 - .../conversation.expected.md | 1 - .../settings-chrome/dialog-en.expected.md | 2 +- .../dialog-no-browser.expected.md | 2 +- .../settings-chrome/dialog.expected.md | 4 +- .../skill-user-invoke/ui-expanded.expected.md | 1 - .../expected/skill-user-invoke/ui.expected.md | 1 - .../expected/steer-all/mid-steer.expected.md | 3 +- .../steer-all/settled-expanded.expected.md | 3 +- .../expected/steer-all/settled.expected.md | 1 - apps/web/tests/goal-multi-turn-actions.e2e.ts | 4 +- apps/web/tests/lifecycle-chrome.e2e.ts | 17 +- apps/web/tests/message-actions.e2e.ts | 6 +- apps/web/tests/replay-round-trip.e2e.ts | 21 +- apps/web/tests/seeded-history.e2e.ts | 21 +- apps/web/tests/settings-chrome.e2e.ts | 24 +- apps/web/tests/turn-tail-actions.e2e.ts | 19 +- .../ui-chat/tests/chat-view.client.spec.tsx | 78 +++-- ...nversation-node-definitions.client.spec.ts | 99 +++--- .../tests/presentation-policy.client.spec.tsx | 64 ++++ .../tests/reasoning-row.client.spec.tsx | 93 +++++- .../tests/transcript-view-row.client.spec.tsx | 31 +- .../tests/text-shimmer-styles.client.spec.ts | 16 + .../tests/text-shimmer.client.spec.tsx | 29 ++ .../tests/browser-plugin.client.spec.ts | 2 +- .../ui-skill/tests/skill-row.client.spec.tsx | 6 +- .../tests/coverage-tails.client.spec.tsx | 13 +- .../ui-tool/tests/search-card.client.spec.tsx | 4 +- .../ui-tool/tests/tool-row.client.spec.tsx | 12 +- .../src/client/slot-catalog.ts | 10 +- snapshots/web/bash-abort-row/ui.expected.md | 1 - .../web/changed-files-turn/ui.expected.md | 11 +- .../web/deepseek-messages-chat/ui.expected.md | 1 - .../web/default-workspace/ui.expected.md | 1 - .../feedback-command/ack-expanded.expected.md | 3 +- .../web/feedback-command/ack.expected.md | 1 - .../feedback-release/ack-expanded.expected.md | 3 +- .../web/feedback-release/ack.expected.md | 1 - .../web/file-upload-round/ui.expected.md | 1 - .../fresh-round-trip/ui-expanded.expected.md | 5 +- snapshots/web/fresh-round-trip/ui.expected.md | 1 - .../ui-expanded.expected.md | 20 +- .../goal-multi-turn-actions/ui.expected.md | 2 - .../reloaded-expanded.expected.md | 3 +- .../web/lifecycle-chrome/reloaded.expected.md | 1 - .../cancel-expanded.expected.md | 1 - .../web/live-interactions/cancel.expected.md | 1 - .../live-interactions/error-auth.expected.md | 1 - .../web/live-interactions/loading.expected.md | 1 - .../retry-exhausted.expected.md | 1 - .../retry-expanded.expected.md | 3 +- .../web/live-interactions/retry.expected.md | 1 - .../running-draft.expected.md | 1 - snapshots/web/message-actions/ui.expected.md | 4 +- snapshots/web/minimal-preset/ui.expected.md | 1 - .../plan-review/approved-expanded.expected.md | 9 +- .../web/plan-review/approved.expected.md | 5 +- snapshots/web/present-svg/ui.expected.md | 5 +- snapshots/web/present/ui.expected.md | 5 +- snapshots/web/ptc-round/ui.expected.md | 5 +- .../answered-expanded.expected.md | 5 +- .../question-composer/answered.expected.md | 1 - .../question-composer/cancelled.expected.md | 3 +- .../web/queue-actions/collapsed.expected.md | 1 - .../web/queue-actions/editing.expected.md | 1 - .../web/queue-actions/layout.expected.md | 1 - .../preserved-expanded.expected.md | 1 - .../web/queue-actions/preserved.expected.md | 1 - .../web/queue-actions/sending.expected.md | 1 - snapshots/web/queue-actions/ui.expected.md | 1 - .../web/queued-image/delivered.expected.md | 1 - snapshots/web/queued-image/queued.expected.md | 1 - .../seeded-history/command-row.expected.md | 6 +- .../seeded-history/feedback-row.expected.md | 6 +- .../seeded-history/ui-expanded.expected.md | 5 +- snapshots/web/seeded-history/ui.expected.md | 1 - snapshots/web/skill-tool-row/ui.expected.md | 5 +- snapshots/web/steering/mid-steer.expected.md | 1 - .../web/steering/settled-expanded.expected.md | 1 - snapshots/web/steering/settled.expected.md | 1 - .../sidebar-chat.expected.md | 1 - .../ui-expanded.expected.md | 9 +- .../web/subagent-conversation/ui.expected.md | 5 +- .../offline-composer.expected.md | 1 - .../turn-tail-actions/completed.expected.md | 1 - .../web/turn-tail-actions/focused.expected.md | 3 +- .../web/turn-tail-actions/running.expected.md | 3 +- .../web/turn-tail-actions/settled.expected.md | 1 - .../usage-expanded.expected.md | 1 - snapshots/web/web-search-round/ui.expected.md | 1 - snapshots/web/workflow-run/ui.expected.md | 5 +- 100 files changed, 932 insertions(+), 323 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md create mode 100644 .agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md create mode 100644 packages/client/ui-chat/tests/presentation-policy.client.spec.tsx create mode 100644 packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts create mode 100644 packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml new file mode 100644 index 0000000000..bb3a7b1cbe --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md +2026-09-20-chat-presentation-policy-and-step-groups.md: df0397a0568967c920994192b370e9fe1d063229 +2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 1bf9cf95174a1de0c7d0576bdd9fbb842d980ae7 diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md new file mode 100644 index 0000000000..df0397a056 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md @@ -0,0 +1,286 @@ +# Agent Note: Chat presentation policy channel and process-group architecture + +Status: proposed + +English | [中文](2026-09-20-chat-presentation-policy-and-step-groups.zh.md) + +## Problem + +At the start of this proposal, Chat's display setting had two modes: `normal` did not fold content, while `compact` folded process rows behind a whole-Turn control after completion. `TranscriptViewPolicy.mode` was injected into ChatView as an observable. ChatView read it through `useTranscriptView`, converted it to the boolean `compactTranscript`, and passed that prop through `ChatNodeList` to every `ChatNodeSeat`. The global `hasMore` boolean followed the same path as `historyIncomplete`. Either change re-rendered every seat, even when only a few foldable rows changed their output. + +The product requires three modes—Compact, Detailed, and Expanded—with consecutive process rows between replies collected into collapsible groups in Compact and Detailed. PR #4565 implemented the full feature, but was reverted eight minutes after merging because of performance regressions. The recorded local before/after comparison found long-session pagination +27%, DOM nodes +23%, heap +14%, and Trajectory switching +10%, all within CI budgets. None of the six existing repository benchmarks covered the new hot path: reasoning or tool chunks entering an active group during streaming, group reprojection, and seat re-rendering. + +The review identified implementation choices, not the product requirements, as the problem: + +- The mode became an input to list structure. `ChatNodeList` subscribed to the entire `layout` and generated wrapper components from ranges; Expanded removed those wrappers. Switching modes remounted the list, and regrouping rebuilt wrapper keys. +- The mode entered `ChatNodeOwnerProps`. Every seat's owner object changed with the mode, re-rendering all business renderers, including unrelated user messages, tool rows, and compaction markers. +- A seat split one Assistant node into derived reasoning and response objects solely to wrap the former in the DOM. Master's `AssistantMarkdown` already had `reasoningHidden` and `revealProcess`, which could hide reasoning independently. +- Consumers repeated infrastructure-row filtering in four places, although nodes already had a Definition-owned `visibility` field. + +The deeper difficulty is information ownership: a group header must exist before its members as soon as the group opens, but its content requires a view of the entire Turn. Each event-driven Context receives only its own events. Only a turn-level Context can observe the whole Turn, and it can produce only one node. + +This proposal supplies an architecture for three modes and grouping that satisfies the constraints below and confines later product changes to a small set of nodes. + +The three settings, presentation policy channel, and per-Turn folding eligibility are implemented; step-group remains proposed. The [migration progress table](../feature/2026-09-20-chat-work-details-migration.md) owns current implementation, committed scope, and incomplete items. The grouping state machine and change inventory below do not describe delivered work. + +## Goals and constraints + +The following decisions were confirmed during design review and are inputs to this proposal. + +| Number | Decision | Meaning | +|---|---|---| +| 1 | Definitions own group structure | A Definition matching multiple events in the Turn folds group opening, closing, member ranges, and summaries; renderers do not derive them by scanning nodes | +| 2 | Limit seat re-rendering | Mode changes re-render only seats whose presentation changes; streaming chunks re-render only their seat and group header; user messages, replies, and turn-tail do not re-render for either change | +| 3 | One node per Context | Do not introduce multiple nodes per Context; most Contexts have one element, and arrays would fragment memory further | +| 4 | Modes do not toggle Definitions | Registry changes rebuild and replay all Contexts, refreshing every seat; Definitions must be mode-independent | +| 5 | Decide folding per Turn | A Turn can fold when its own `turn/start` is loaded; the oldest partial Turn stays open and is not optimized here | +| 6 | ui-conversation may change | Engine changes are allowed only where needed for this design to work correctly | +| 7 | Initial implementation scope | Initially change only code and this note; add tests and other documentation during CI repair | + +Accepting a full-page browser layout on mode changes does not mean accepting every seat re-rendering. Layout is a geometric result; a seat re-render executes a React component function. This proposal constrains the latter. + +## Viable approaches + +There are five approaches to group-header identity and content ownership. + +**Approach A: the first member's seat renders the header.** Keep the list unchanged. The builder derives membership and tells the first member's seat to render a title above itself; other member seats use the existing `hidden` mechanism. This adds no new concept, but creates a third seat state—visible with only a title—and attaches the header to a business node's seat. + +**Approach α: one Context per step supplies a header.** Add a Definition identified by `turn:step`. During construction, read the preceding `assistant-step` data to decide whether this is a new group start and emit a header node if so. Another turn-level Definition publishes the group's content as Turn data for the renderer. + +**Approach β: one Context emits multiple nodes.** A turn-level Definition emits the whole-Turn control and N group headers. + +**Approach γ: a parent Context derives child Contexts.** When a group opens, the turn-level fold creates a `step-group` child Context and forwards later events to it. Each child owns one node. The engine needs forwarding and cascaded replay primitives. + +**Approach δ: Definitions own structure; the builder materializes rows.** A turn-level Definition folds the group list and publishes Turn data. The Chat builder materializes header nodes and assigns membership. Seats use combined selectors for visibility. `ViewBuilder.apply` receives the Location data whose references changed in the flush. + +## Alternatives considered + +**One Context per group cannot satisfy the matching rules.** The engine requires `match(event)` to derive an id from one event. A group number depends on whether a reply preceded the event; one event cannot determine it. This is incompatible with the matching contract, not merely difficult to implement. + +**Approach α conflicts with decision 2.** Header identity is derivable, but every header reads the same Turn data, so one group's change refreshes all headers in that Turn. Reading later steps through `turn.steps` instead leaves stale references because a header node is not rebuilt when later steps arrive. + +**Decision 3 rules out approach β.** See the table above. + +**Approach γ is not justified yet.** It has the clearest ownership, but forwarding and cascaded replay would add hundreds of lines to the assembler core. It remains an alternative under the conditions at the end of this note. + +**Approach A remains the fallback.** If builder-owned materialization is rejected in practice, use A. Both share the same structural source and differ only in what carries the header row. + +Choose approach δ. + +## Proposal + +### Data flow + +``` +事件流 + ──▶ step-groups Definition(turn 级 fold,多重匹配本轮事件) + ──▶ Turn 数据 step-groups:组列表,每组一个对象 + ──▶ Chat builder + ├─ 物化组头节点:一组一个 key,data 即组对象 + └─ 成员归属:按 step 映射到 key,写进 per-key presentation + ──▶ seat:hidden = f(策略字段, 角色, 展开状态) + ──▶ 组头渲染器:读自己节点的 data +展示策略 + ──▶ 渲染器:按 kind 注入的 hooks,选单个字段 + ──▶ seat:组合 selector,选出的是自己的结论 +``` + +The only engine change passes the Location data whose references changed during a flush to the builder through `apply`. + +### Responsibilities + +| Layer | Owns | Explicitly does not own | +|---|---|---| +| `step-groups` Definition | Opening and closing groups, `headStep`, `closeStep`, tool counts and names, running tools; publishes Turn data | View nodes or identification of member nodes | +| Chat builder | Header-node identity and lifecycle, membership, `turnStarted`, identity comparison and publication of per-key presentation | Reading events or deriving business structure | +| Presentation policy object | Mapping the enum to capability fields | Node or seat state | +| Seat | Combining policy fields, role, and store state into hidden state; supplying group open/close actions to the header renderer | Determining group boundaries | +| Header renderer | Title, chevron, click-to-toggle interaction | The source of group content | +| assistant-step renderer | Settled reasoning first-line preview; hiding reasoning when closing a group | Group boundaries | + +### Header-node identity and lifecycle + +A header node's key is `step-group:${turn}:${headStep}`. `headStep` is fixed when a group opens, and later events cannot change its first member. The header exists and is reused from that point onward; its key does not change during streaming. Closing the group only changes `closed` in its data from false to true. + +The header anchor is the first member's anchor minus a synthetic offset, placing it immediately before that member. A streaming Assistant is anchored at its first visible chunk, then at its message event after persistence. Its anchor moves once; the header follows in the same structural update without adding another update. + +A header is part of the whole-Turn process and hides with its members when the Turn folds. It is not in `TURN_PROCESS_INDEPENDENT_KINDS`. + +Header data is the group object published by the fold. Closed group objects never change references, so their header sources never notify. Each event that changes a running group replaces only that group's object and re-renders only its header. + +### Fold state machine + +The `step-groups` Definition uses the Turn number as its id, starts on `turn/start`, and matches the same event set as `turn-process`. Its State is `{ turn, groups, open }`: `groups` is an array of group objects, and `open` is the active group's index or empty. + +| Event | Transition | +|---|---| +| `tool/call` | If no group is open, open one at the event's step; increment the count, record distinct tool names, and add callId to the running set | +| Appended `tool/result` | Remove callId from the running set | +| `assistant/live-chunk` reasoning delta with visible characters | Open a group at the chunk's step if none is open | +| `assistant/live-chunk` text delta or text-block end with visible characters | Close an open group at the chunk's step | +| Appended `assistant/message` | Apply the reasoning rule for visible reasoning and the text rule for a visible reply, in reasoning-then-text order | +| `turn/end` | Mark an open group as closed by the Turn end | +| `step/start`, `step/end`, `llm/retry` | No state change; retry rows belong to no group | + +Three invariants apply: + +- Idempotence: opening an already-open group and closing an already-closed group do nothing. Live chunks and persisted messages describing the same content do not open or close twice; replay with only persisted messages derives the same group list. +- Stable references: `update` returns the original object when State is unchanged; the `groups` array changes reference only when the group list changes; closed group objects are never replaced. +- Forward-only folding: do not read neighboring Contexts, nodes, or later history. + +Turn-data publication: `buildLocationData` returns a value only for the turn phase and reuses its previous value when the `groups` reference is unchanged, preventing republication. A history window without `turn/start` uses the same fallback as `turn-process`: fold State from the available matches. + +Publication timing: batch live chunks at animation frames; publish other changes immediately. + +### Membership rules + +The builder assigns membership by step, not by seq ranges, because live-chunk seqs and persisted-event seqs use different scales. + +| Node | Rule | +|---|---| +| `tool-call` at step s | Belongs to the last group with `headStep ≤ s`. If text closes the preceding group in the same step, later tool calls open a new group at that step, so the last group is the applicable one | +| `assistant-step` at step s where a group has `closeStep === s` | Closer role: the node remains visible; folding the group hides only its reasoning through the existing `reasoningHidden` mechanism | +| Other `assistant-step` at step s | Member role: belongs to the group with `headStep ≤ s` whose `closeStep` has not been passed | +| `model-retry`, `turn-error`, `turn-max-tokens`, `turn-tail`, `user`, `steering` | Belong to no group | + +Recompute membership only when the group list changes, with cost proportional to that Turn's node count. Streaming chunks that leave the group list unchanged do not publish member presentation. + +The header anchors before the earliest member. If a group contains only its closer's reasoning and no members, such as reasoning followed by a reply in one step, anchor the header before the closer so the hidden reasoning still has a control that reveals it. + +### Delivering group changes to the builder + +The fold publishes Turn data, not view nodes. When Location data changes, the engine's `flush` sets `timelineDirty` and calls `apply` on all active targets, but the builder cannot identify the changed Turn and key. Inferring them from an unrelated node update in the same Turn and flush is not a reliable guarantee. + +Add optional `changedLocationData` to `ConversationViewBuilder.apply`: the Location values whose references changed in this flush, each carrying `kind`, `turn`, optional `step`, and `key`. The assembler already compares references in `applyDirtyLocationData`; it collects those results for the builder. Chat reprojects only Turns with `key === 'step-groups'`. When the optional input is absent, the builder falls back to Turns affected by the current upserts. + +### Combining hidden state in a seat + +Seats stop receiving `compactTranscript` and `historyIncomplete`. A seat reads policy through `usePresentation`, selecting its own result rather than the mode: + +```ts +declare function usePresentation(select: (policy: { + stepGrouping: 'collapsed' | 'none' + foldCompletedTurns: boolean +}) => T): T +declare const group: { readonly headStep: number } | undefined + +const grouping = usePresentation(p => group === undefined ? 'none' : p.stepGrouping) +const foldCompleted = usePresentation(p => p.foldCompletedTurns) +``` + +For a seat outside every group, `grouping` is `'none'` in all modes, so it does not re-render. `foldCompletedTurns` likewise stays true in all modes. The selector hook uses `useSyncExternalStoreWithSelector` and re-renders only when the selected value changes. + +Three sources combine into `hidden`: + +| Source | Condition | Reveal action | +|---|---|---| +| Whole-Turn folding | Existing rule: `turnClosed && turnStarted && has reply`, without manual expansion | Open the Turn | +| Group folding | `grouping === 'collapsed'`, member role, group not manually expanded | Open the group | +| Group header in Expanded | Header role and `grouping === 'none'` | None | + +The closer's reasoning is hidden independently: the seat passes `stepGroup.reasoningHidden` through owner props to the assistant-step renderer. + +The chat store keeps group expansion under a key combining `turn` and `headStep`, alongside whole-Turn manual expansion. + +### Re-render scope + +The following guarantees also define the next round of render-count assertions. Each row names an exact set, not an upper bound. + +| External change | May re-render | +|---|---| +| A running group receives a reasoning chunk | That assistant seat | +| A running group receives `tool/call` | That tool seat and the group's header | +| A reply appears and closes a group | That assistant seat and group header; membership recomputation occurs only when opening or closing groups | +| A new group opens | Insert the new header into `order` with one structural layout; no other seat refreshes | +| Switch Compact ↔ Detailed | Only assistant-step renderers with settled reasoning | +| Switch to or from Expanded | Only member and header seats | +| Change `performanceUsage` | Only turn-tail | +| Load an earlier page | New seats and historical seats whose presentation changes | +| Change locale | All seats, as a control case | + +### Presentation policy object + +Separate the persisted enum from runtime capabilities. `TranscriptViewMode` contains `compact`, `detailed`, and `expanded`; reading the legacy saved value `normal` maps it to `detailed` without writing it back. Runtime capabilities are the derived `ChatPresentationPolicy`: + +| Field | compact | detailed | expanded | +|---|---|---|---| +| `foldCompletedTurns` | true | true | true | +| `stepGrouping` | collapsed | collapsed | none | +| `settledReasoningPreview` | false | true | true | + +The mapping has one owner. Each mode maps to a constant object with a stable reference, so the derived observable needs no subscription bookkeeping: `getSnapshot` performs a lookup and `subscribe` forwards to the mode observable. + +Renderers select needed fields instead of comparing enum values. Policy is injected by kind: the assistant-step registration receives it through `inject.hooks.presentation`, just as turn-tail receives `performanceUsage`. Adding a fourth mode changes the table, not the renderers. + +### Per-Turn folding + +Add `turnStarted` to per-key presentation, derived from whether the Turn's `turn/start` is in the loaded window. Folding eligibility uses it instead of global `!historyIncomplete`. Completing pagination changes no global seat prop; only new nodes and presentation in their Turns change. + +### Complexity requirements + +| Scenario | Fold | Builder | Publication | +|---|---|---|---| +| Content chunk without a group-boundary change | O(1) transition, original object returned | No reprojection | None | +| Event that changes a group boundary | O(1) | Reproject only the owning Turn, proportional to its node count | Keys whose facts changed in that Turn | +| Structural changes and pagination | Proportional to matched events | One full pass | Keys whose facts changed | +| Mode change | None | None | None; seat and renderer selectors determine re-renders | + +## Change inventory + +| Package | File | Change | +|---|---|---| +| ui-conversation | `contract/conversation.ts` | Add `ConversationLocationDataUpdate` and optional `changedLocationData` to `ConversationViewBuilder.apply` | +| ui-conversation | `conversation/assembler.ts` | Collect changed Location references in `applyDirtyLocationData` and pass them through `apply` | +| ui-conversation | `index.ts` | Export the new type | +| ui-chat | `chat-settings.ts` | Three-mode enum; schema accepts legacy `normal` | +| ui-chat | `transcript-view.ts` | Map `normal` to `detailed` on read | +| ui-chat | New `presentation-policy.ts` | Policy objects, mapping table, derived observable | +| ui-chat | `settings/TranscriptViewRow.tsx` | Three options | +| ui-chat | `locale.ts` | Mode labels and group-header copy | +| ui-chat | `contract/slots.ts` | `ChatViewInjected.hooks.presentation`, `PresentationInjected`, `ChatNodeOwnerProps.stepGroup`, `StepGroupOwnerProps` | +| ui-chat | `contract/snapshot.ts` | `ChatTurnProcessPresentation.turnStarted`, `ChatNodeGroupPresentation`; combine `processSource` values per key | +| ui-chat | `contract/chat-nodes.ts` | `StepGroupChatData` | +| ui-chat | `contract/store.ts`, `stores.ts` | Manual group-expansion state and actions | +| ui-chat | `conversation-nodes/common.ts` | Header anchor offset | +| ui-chat | New `conversation-nodes/step-groups.ts` | Fold Definition and Turn-data declaration merging | +| ui-chat | New `conversation-nodes/step-group-nodes.ts` | Materialize headers and project membership | +| ui-chat | `conversation-nodes/turn-process-presentation.ts` | `turnStarted` and combined per-key presentation | +| ui-chat | `conversation-nodes/chat-snapshot-builder.ts` | Integrate materialization, consume `changedLocationData`, support key deletion in the store | +| ui-chat | `conversation-nodes/register.ts` | Register the fold Definition | +| ui-chat | `chat/ChatView.tsx` | Stop passing `compactTranscript` and `historyIncomplete`; pass `usePresentation` | +| ui-chat | `chat/ChatNodeSeat.tsx` | Combined selector, three hiding sources, `stepGroup` owner props | +| ui-chat | `chat/AssistantNodeView.tsx`, `chat/AssistantMarkdown.tsx`, `chat/ReasoningRow.tsx` | Inject policy, toggle settled reasoning previews, hide closer reasoning | +| ui-chat | New `chat/StepGroupNodeView.tsx` | Header renderer | +| ui-chat | `chat/register-node-renderers.ts` | Register header renderer and inject policy into assistant-step | +| ui-chat | `apply.ts`, `index.ts` | Create and inject policy observable; export types | + +This inventory retains the complete grouping proposal. Independent features have updated READMEs, i18n pairing files, and the slot catalog. The migration record tracks tests and snapshots; those updates do not complete the outstanding grouping work. + +## Acceptance criteria + +Grouping validation remains unimplemented; the migration record tracks independent presentation-feature evidence. + +**Render-count isolation.** Add counters to the existing `chat-view.client.spec` mounting approach: React Profiler `onRender` counts per seat, and test renderers registered in `conversation.chat.node` count by kind. Assert the exact sets in the re-render-scope table for each stimulus. + +**Publication counts.** Count notifications from each key's source under the same stimuli and assert the exact set. + +**Fold state-machine tests.** Feed event sequences and assert opening, closing, a new group after text then tools in one step, Turn-end closure, idempotence, unchanged closed-group references, and equivalent replay using only persisted messages. + +**Membership tests.** Given the group list and Turn nodes, assert each key's role. + +**Benchmark coverage.** Add incremental apply with reasoning and tools to `conversation-fold`, and a streaming fixture with tools and reasoning to `long-session-browser`. These provide wall-clock evidence, not a gate. + +## Risks + +- **Builder-owned node creation.** This is the first proposed node creation by a view builder. A builder may only materialize rows from Turn or Step data; it must not read events or derive business structure. Synthetic keys use `step-group:`, while Context keys begin with digits, preventing collisions. Fall back to approach A if this ownership is rejected in practice. +- **Header anchor moves once with its first member.** Persisting a streaming Assistant moves its anchor from the first visible chunk to the message event. The header follows in the Assistant's structural update. +- **Actual mode differences in this phase.** Compact and Detailed currently differ only in settled reasoning first-line previews. Running detail titles and category summaries are outside this phase; the policy table leaves room for them. +- **Pagination and partial Turns.** The oldest partial Turn neither folds nor groups. Membership uses steps and is independent of pagination. +- **Header copy.** This phase proposes only processing/completed labels and tool-call counts; category summaries remain product work. +- **Streaming chunk step.** `assistant/live-chunk` carries `step`, as used by `turn-process`; a chunk without a step does not open a group. + +## Retained alternative: parent-derived child Contexts + +If builder materialization exposes more ownership problems than expected, add an engine primitive: a Context's `update` may derive a child Context with an explicit kind and id, then forward later matches. Each child owns its node, publication timing, and Location. Parent replay aligns children by id; children no longer derived become hidden under the existing withdrawal rules. + +This returns row identity and content to Definitions with one Context per group and no array fragmentation. It requires assembler forwarding and cascaded replay, and introduces a Context creation path outside `match`. Reconsider it if more than two business features exceed the builder's materialization limits, or if membership requires cases that steps cannot express. diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md index 88ee6a4986..1bf9cf9517 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md @@ -2,9 +2,11 @@ Status: proposed +[English](2026-09-20-chat-presentation-policy-and-step-groups.md) | 中文 + ## 问题 -Chat 的「对话显示」设置今天只有两档:`normal` 不折叠、`compact` 在轮次结束后把过程行折进一个整轮控件。设置值的流向是:`TranscriptViewPolicy.mode` 观察量注入 ChatView,ChatView 用 `useTranscriptView` 读成一个布尔值 `compactTranscript`,再作为 prop 传给 `ChatNodeList`,再传给每一个 `ChatNodeSeat`。另一个全局布尔值 `hasMore` 走同一条路,以 `historyIncomplete` 的名字进每个 seat。两个值一变,全部 seat 重渲染一遍,哪怕只有可折叠的那几个输出真的变了。 +Chat 的「对话显示」设置在本提案开始时只有两档:`normal` 不折叠、`compact` 在轮次结束后把过程行折进一个整轮控件。设置值的流向是:`TranscriptViewPolicy.mode` 观察量注入 ChatView,ChatView 用 `useTranscriptView` 读成一个布尔值 `compactTranscript`,再作为 prop 传给 `ChatNodeList`,再传给每一个 `ChatNodeSeat`。另一个全局布尔值 `hasMore` 走同一条路,以 `historyIncomplete` 的名字进每个 seat。两个值一变,全部 seat 重渲染一遍,哪怕只有可折叠的那几个输出真的变了。 产品要把这个设置改成三档:简洁、详细、完全展开;并在简洁和详细两档里把回复之间的连续过程行归成可折叠的组。PR #4565 做过一次完整实现,合并八分钟后因性能回退被整体撤回。合并前后在本机的对照实测:长会话分页耗时 +27%,DOM 节点 +23%,堆内存 +14%,Trajectory 切换 +10%,全部仍在 CI 预算内;仓库现有六个基准没有任何一个覆盖「流式期间 reasoning 或工具 chunk 进入活动分组、分组重投影、seat 重渲染」这条它新增的热路径。 @@ -19,6 +21,8 @@ Chat 的「对话显示」设置今天只有两档:`normal` 不折叠、`compa 本记录给出一套基础架构,让三档模式与分组落地时满足下面的约束,并让后续产品变化只落在少数节点上。 +三档设置、展示策略通道与按轮折叠资格已实现;step-group 仍是提案。当前实现、已提交范围和未完成项以[迁移进度表](../feature/2026-09-20-chat-work-details-migration.zh.md)为准,下面的分组状态机与改动清单不表示已经交付。 + ## 目标与约束 下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。 @@ -31,7 +35,7 @@ Chat 的「对话显示」设置今天只有两档:`normal` 不折叠、`compa | 4 | 模式不开关 Definition | 注册变化会触发 registry 重建与全量重放,所有 seat 必刷;Definition 必须与模式无关 | | 5 | 折叠按轮判定 | 一轮只要自己的 `turn/start` 已加载就可以折叠;被分页切开的最老一轮保持展开,本轮不优化 | | 6 | 允许改 ui-conversation | 引擎可以改,但只做让方案正确成立所必需的小改动 | -| 7 | 本轮范围 | 只写代码与本记录;不写测试,不改其他文档;测试与文档在下一轮补 | +| 7 | 初始实现范围 | 初始阶段只写代码与本记录;测试与其他文档在 CI 修复阶段补齐 | 有一条前置澄清:接受「切模式全屏重排版」不等于接受「所有 seat 重渲染」。浏览器重排是几何结果,seat 重渲染是 React 组件函数重跑,后者才是这里要限制的量。 @@ -49,7 +53,7 @@ Chat 的「对话显示」设置今天只有两档:`normal` 不折叠、`compa **路线 δ,Definition 拥有结构,builder 物化行。** turn 级 Definition 折叠出组列表并发布为 Turn 数据;Chat builder 据此物化组头节点并给成员写归属;seat 用组合 selector 决定隐藏;引擎给 `ViewBuilder.apply` 的入参加一份「本次 flush 哪些 Location 数据换了引用」。 -## 砍掉的路线与理由 +## 备选方案与取舍 **每组一个 Context 无法成立。** 引擎要求 `match(event)` 只看单个事件算出 id,「第几组」取决于前面有没有回复,单个事件算不出。这不是实现难度,是契约上的不可能。 @@ -154,6 +158,12 @@ fold 只发布 Turn 数据,没有视图节点。engine 的 `flush` 在 Locatio seat 不再接收 `compactTranscript` 与 `historyIncomplete` 两个 prop。它通过 `usePresentation` 读策略,但选出的不是模式,而是自己的结论: ```ts +declare function usePresentation(select: (policy: { + stepGrouping: 'collapsed' | 'none' + foldCompletedTurns: boolean +}) => T): T +declare const group: { readonly headStep: number } | undefined + const grouping = usePresentation(p => group === undefined ? 'none' : p.stepGrouping) const foldCompleted = usePresentation(p => p.foldCompletedTurns) ``` @@ -244,11 +254,11 @@ per-key presentation 增加 `turnStarted`,来源是该轮 `turn/start` 是否 | ui-chat | `chat/register-node-renderers.ts` | 注册组头渲染器;给 assistant-step 注入策略 | | ui-chat | `apply.ts`、`index.ts` | 创建策略观察量并注入;导出类型 | -本轮不改:README、i18n 配对文件、slot catalog、测试、快照。slot catalog 中 `ChatNodeOwnerProps` 的源码摘录与 key 列表需要在提 PR 前重新生成。 +上述清单保留完整分组方案;独立功能已经更新 README、i18n 配对文件及 slot catalog。测试与快照进度见迁移记录,未完成的分组改动不随这些更新自动完成。 ## 验收标准 -下一轮补齐,本轮不实现。 +分组相关验收仍待实现;独立展示功能的验证进度见迁移记录。 **渲染次数隔离测试。** 在现有 `chat-view.client.spec` 的挂载方式上加计数:React Profiler 的 `onRender` 按 seat 计数,测试专用的计数渲染器注册进 `conversation.chat.node` 按 kind 计数。刺激与期望按「刷新范围」一节的表逐行断言精确集合。 @@ -260,7 +270,7 @@ per-key presentation 增加 `turnStarted`,来源是该轮 `turn/start` 是否 **基准补位。** `conversation-fold` 增加带推理与工具的增量 apply 场景;`long-session-browser` 增加带工具与推理的流式 fixture。这是墙钟兜底,不是门禁。 -## 风险与待决 +## 风险 - **builder 产出节点的边界。** 这是这套架构里第一次由视图 builder 产出节点。边界写死为:builder 只能从 Turn 或 Step 数据物化行,不能读事件,不能自己算业务结构。合成 key 用 `step-group:` 前缀,Context key 以数字开头,不会冲突。如果实践中被否决,退到路线 A。 - **组头锚点随头成员移动一次。** 头成员是流式 Assistant 时,落盘让锚点从首个可见 chunk 移到消息事件,组头跟着移动一次。与 Assistant 自己的结构性更新同时发生。 diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml new file mode 100644 index 0000000000..1f41262d74 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md +2026-09-20-chat-work-details-migration.md: cd5dbe723425d5bed719bbb579305e3f917d0cc5 +2026-09-20-chat-work-details-migration.zh.md: 4055b027fc7e5c710358886361badb88a4e9b13e diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md new file mode 100644 index 0000000000..cd5dbe7234 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md @@ -0,0 +1,144 @@ +# Agent Note: Chat work-details migration progress + +Status: proposed + +English | [中文](2026-09-20-chat-work-details-migration.zh.md) + +## Problem + +PR #4565 combines three display modes, process grouping, whole-Turn status, trigger notifications, tool-row visuals, and infrastructure-row hiding. Migrating it as one batch mixes independent features with list restructuring and makes individual performance changes difficult to assess. A feature-based progress table allows independent visuals to be reviewed first and Turn-related behavior to be considered with step-group later, without losing product requirements. + +## Proposal + +Completed independent features stay in `feat/chat-presentation-channel`. The remaining unfinished code continues in `.artifacts/worktrees/pr-4565-turnstep` under the repository root, based on the current channel and this progress record, with drafts kept unstaged for combined turn/step and grouping review. The `pr-4565` worktree remains the original PR's behavior and implementation reference; existing `feat/chat-step-groups` experiments are outside this transfer. + +This record tracks feature scope, current implementation, and acceptance progress. The [presentation policy channel and process-group architecture](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.md) retains the architecture and alternatives. This record neither replaces it nor treats its proposals as implemented. Because it does not fully supersede that decision, retain the architecture note without archival or deletion. + +This record maintains an English/Chinese pair. Completion, implementation, staging or commit state, and acceptance are separate dimensions. Incomplete items remain marked incomplete even when drafts exist, and stay in the inventory. Tests and documentation are added during CI repair; browser snapshots and performance acceptance are tracked separately. Written code, staging, commits, and successful builds do not establish complete functional or performance acceptance. + +### Status definitions + +| Dimension | State | Meaning | +|---|---|---| +| Completion | Complete | Migration code has been reviewed feature by feature and committed to this channel; behavior and performance evidence are separate | +| Completion | Incomplete | Outside the channel's completed migration scope; may have an unstaged draft or no implementation | +| Implementation | Existing capability | Already present in this channel | +| Implementation | Code written | Implementation exists in the relevant worktree; does not imply completion, staging, commit, or acceptance | +| Implementation | Awaiting migration | Included in non-grouping scope but not yet implemented | +| Implementation | Grouping phase | Requirement retained; depends on grouping structure or interactions and is not connected to this channel yet | +| Staging or commit | Unstaged | Changes remain in the worktree, including untracked files | +| Staging or commit | Partially staged | Only some feature changes are staged; the exact scope must be stated | +| Staging or commit | Staged | All pending code changes for the feature are staged but not committed | +| Staging or commit | Committed | Feature code is included in current HEAD | +| Acceptance | Pending | Requires behavior, node identity, update-scope, or browser performance evidence; Git state and compilation cannot substitute | + +Verify staging against the feature's diff hunks, not whole files. Inspect the worktree, index, and HEAD when updating status. Staging some fields in a shared file does not stage every feature depending on that file. + +### Capabilities already in this channel + +| Capability | compact | detailed | expanded | Completion | Implementation | Staging or commit | Acceptance | +|---|---|---|---|---|---|---|---| +| Three settings and legacy-value reading | Compact | Detailed; legacy `normal` reads as this mode without write-back | Expanded | Complete | Existing capability | Committed | Pending | +| Per-Turn folding eligibility | A closed Turn with its start loaded and a final reply can fold process content; cancellation/failure extensions are item 6 | Same | Same | Complete | Existing capability | Committed | Pending | +| Collapsed settled-reasoning summary | Think title only | First line | First line | Complete | Existing capability | Committed | Pending | +| Retain summary DOM across mode changes | CSS hides summary and separator | Show existing summary and separator | Same | Complete | Existing capability | Committed | Pending | +| Local reasoning-preview subscription | `ReasoningRow` selects its own visibility | Same | Same | Complete | Existing capability | Committed | Pending | +| Stable disclosure-row references | Callbacks, icons, and unchanged summaries retain references; full Markdown mounts only on expansion | Same | Same | Complete | Existing capability | Committed | Pending | + +All three modes retain whole-Turn folding. `expanded` means removing secondary process groups, not disabling whole-Turn folding or automatically expanding reasoning and tool-card bodies. Until grouping is connected, Chat presents `detailed` and `expanded` identically. + +### Migration inventory without grouping dependencies + +“All three modes” means the behavior applies in every mode, not that switching modes changes it. “Without grouping dependencies” means the draft introduces no step-group, not that every item must be accepted before grouping. Numbers follow the feature review inventory; item 0's compilation wiring is complete and committed, so it is not listed as a product feature. Incomplete drafts remain in `pr-4565-turnstep`, outside this channel's completed features. Behavior and performance acceptance remain pending for the features below. + +| Number | Feature | compact | detailed | expanded | Completion | Implementation | Staging or commit | Destination and considerations | +|---|---|---|---|---|---|---|---|---| +| 1 | Chinese tool titles | All three modes | All three modes | All three modes | Complete | Code written | Committed | Bash/Pwsh: “运行命令”; Grep: “搜索文件内容”; Glob: “查找文件”; Skill: “加载技能”. Only UI dictionaries change; model tool names do not | +| 2 | Tool-row interaction visuals | All three modes | All three modes | All three modes | Complete | Code written | Committed | Hover darkens titles and summaries in tool, Bash, and command rows; shared `DisclosureRow` and independent Bash rows point the expanded chevron upward | +| 3 | Bash stopped-state screen-reader text | All three modes | All three modes | All three modes | Complete | Code written | Committed | Complete the stopped-state accessible name without changing execution or cancellation semantics | +| 4 | Running tool/command text shimmer | All three modes | All three modes | All three modes | Complete | Code written | Both 4A shimmer and 4B accompanying optimizations committed | 4A includes a fixed `TextShimmer` span, CSS activation, tool/command integration, replacement of old sweep styles, and reduced-motion support, excluding step/turn. 4B adds business-row computation caches, stable callbacks, and JSX references | +| 5 | Streaming reasoning summary | Update after a paragraph's first line completes | Same | Same | Complete | Code written | Committed | `ReasoningRow` retains the original PR's `latestCompletedParagraphFirstLine(text)` and computes from full text. Retain summary DOM, stable props, and right-edge fading; add no Definition parsing state or cross-layer summary fields | +| 6 | Keep process expanded after cancellation/failure | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft uses `keepOpen` in Turn process projection; folding eligibility and stopped/failed behavior await Turn/grouping review | +| 7 | Whole-Turn status and duration control | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft anchors the control to loaded `turn/start` after opening inputs, isolates timing in `LiveTurnProcessLabel`, and replaces the bottom activity bar; positioning and status await review | +| 8 | Non-human trigger notifications | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft identifies waking inputs from next-turn and first-step next-step claims and projects `turn-trigger`; Turn starts, notification ownership, and expanded content await review | +| 9 (permission/system) | Hide permission-command and system-prompt rows | All three modes | All three modes | All three modes | Complete | Code written | Committed | `isVisibleChatNode` centralizes filtering for the existing Chat list, navigation, and process presentation. Command and request-prompt Definitions do not own these filters; persistence and Trajectory are unchanged | +| 9 (context) | Hide ordinary Context rows | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Paired with item 8's waking notifications to retain source explanations; final filter ownership awaits Turn/grouping review | +| 10 | Turn action-bar positioning | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft anchors `turn-tail` after `turn/end`; reply ownership for feedback/copy and the real Turn-end seq for branching await combined review | +| 11 | Action-bar persistent visibility | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft projects `footer` per Turn in the Chat builder: persistently visible when the latest Turn ends in a reply, otherwise on hover/focus; tail detection will be considered with grouping | + +The current base already enables developer tools by default; do not migrate that again. Retain the protection that waits for Host acceptance of settings instead of applying the original PR's missing-value fallback. Its `Switch` change only affects a comment and is not a migrated feature. + +### Subsequent review batches + +Review batches are independent of implementation and staging status. Review remaining drafts in `pr-4565-turnstep`, kept unstaged; a draft does not complete a migration. Transfer only this channel's remaining code, without automatically incorporating `feat/chat-step-groups` experiments or installing dependencies and compiling in the destination worktree. + +| Batch | Features | Review scope | +|---|---|---| +| Independent feature, committed | 4A: text shimmer | `TextShimmer`, row integration, and browser painting cost; performance acceptance pending | +| Independent optimization, committed | 4B: accompanying memoization and reference stability | `ToolRow`, `BashRow`, and `GenericCommandCard` computation caches, callbacks, and JSX references; not required for shimmer itself; performance acceptance pending | +| Independent feature, committed | Permission-command and system-prompt hiding from item 9 | Retain the original PR's centralized visibility decision; without step-group, the main list connects it in `orderedVisibleChatNodes`, independent of waking-message classification | +| Incomplete, transferred to turnstep worktree | 6, 7, 10, 11 | Whole-Turn folding eligibility, status/clock, action-bar position, and tail detection, considered with Turn/group relationships | +| Incomplete, transferred to turnstep worktree | 8 and ordinary Context hiding from item 9 | Trigger notifications depend on Turn starts and Inbox claims; Context hiding accompanies notifications so waking reasons remain visible | + +### Requirements retained for the grouping phase + +All items below remain incomplete and retain their full requirements for the turn/step and grouping phase. Neither this channel nor the transferred drafts introduce step-group. Experimental code in `feat/chat-step-groups` does not establish completion of these product behaviors or performance requirements. + +| Feature | compact | detailed | expanded | Current state | +|---|---|---|---|---| +| Consecutive process groups between replies | Group tools and reasoning, initially collapsed | Same | Show process rows directly without secondary wrappers | Incomplete (grouping phase) | +| Independent reasoning/reply visibility within one Assistant | Reasoning may join the preceding group; reply stays visible | Same | No secondary group hiding | Incomplete; do not directly copy the original split into two render nodes | +| Header category summary | Current category while running; aggregate main categories after completion | Same | No header | Incomplete (grouping phase) | +| Running header details | Hidden | Show command, path, query, etc.; reasoning summary when no tool is running | No header | Incomplete (grouping phase) | +| Header update cadence | Preserve category title's minimum display duration | Also handle detail updates | Not applicable | Incomplete (grouping phase) | +| Height-limited scrolling within a group | Limit expanded height, scroll independently, fade edges | Same | Not applicable | Incomplete (grouping phase) | +| Group and whole-Turn folding interaction | Hide groups when the Turn folds | Same | Whole-Turn folding only | Incomplete; retain node identity and separately decide whether internal expansion resets | +| Pagination, streaming settlement, and group identity | Stable existing headers and members; handle partial Turns separately | Same | Show the same business nodes directly | Incomplete (grouping phase) | + +Grouping details to verify: completed summaries aggregate at most three categories and use “etc.” beyond three; call counts determine category ordering rather than appearing as header numbers. Detailed-mode live details collapse whitespace and are capped at 160 characters. Titles remain for at least 150ms; retain only the latest update without replaying queued updates. Expanded height is at most the smaller of 400px and half the viewport. Scrollable edges use a 24px fade and preserve interaction with outer scrolling. Retry rows are outside secondary groups. These values describe the original PR's reference behavior, not completed grouping experiments. + +## Performance constraints + +| Scenario | Property to preserve | Required evidence | +|---|---|---| +| Switching among modes | Business-node keys do not change; modes do not re-register Definitions; only relevant visibility/presentation fields update | Node identity and update-scope checks | +| Incremental reasoning/tool content | Update only the owning node without scanning full history; compute reasoning summary from current full text during rendering | Update scope and long-text cost; the current summary algorithm does not reduce `ReasoningRow` render count | +| Whole-Turn running clock | Only live clock content updates each second, without refreshing members, historical rows, or list | Update scope and timer disposal | +| Summary visibility | Keep lightweight summaries mounted; do not mount every full Markdown body for mode switching | DOM identity, initial loading, memory observations | +| Pagination | Update new nodes and nodes with changed facts; completed Turns do not refresh together because of global pagination state | Multi-page interaction and update-scope checks | +| State transitions | Stable text/icon/callback references when content is unchanged; do not clear internal state through remounting | Interaction state and component lifecycle checks | + +Retaining DOM reduces mounting and unmounting cost, not style calculation, layout, or painting, and can increase retained memory. Changing display properties alone is not a guarantee against jank. The aim is lower switching peaks with acceptable initial-load, pagination, and streaming costs. + +## Alternatives considered + +**Replay the original PR as one batch.** Rejected: grouping, node splitting, whole-Turn status, and independent visuals would mix, obscuring each feature's performance effects and overwriting local subscriptions and node reuse already completed in this channel. + +**Unmount and recreate summaries or process nodes on mode changes.** Rejected for summary mode changes. Stable mounts allow repeated switching to change only display state; initial costs for new nodes still require separate validation. + +**Keep every Markdown body mounted for smooth switching.** Rejected: lightweight summaries and full bodies differ in memory, DOM, and rendering cost. Bodies remain mounted according to manual expansion. + +**Remount renderers when folding a Turn to reset tool cards.** Do not migrate directly. It conflicts with stable nodes and preserved interaction state. If reset behavior remains necessary, define its state owner and trigger explicitly. + +## Acceptance criteria + +Update implementation, staging, and commit status separately as work progresses. Completion follows feature review and commits; acceptance follows actual validation. Retain incomplete items across branch splits and worktree transfers. Before grouping work starts, check every product requirement and mode mapping above. + +| Acceptance item | Current state | +|---|---| +| Complete behavior validation of existing channel capabilities | Relevant unit and browser regressions pass; this excludes unmigrated grouping features and does not establish performance acceptance | +| Non-grouping feature migration | Completed scope is in the inventory; incomplete drafts await review in `pr-4565-turnstep`; functional and performance validation remain incomplete | +| DOM identity and update scope during mode changes | Unit tests verify summary/expanded-body DOM identity and policy-field subscriptions; browsers verify persistence of all modes and whole-Turn folding; whole-page update scope and performance remain pending | +| Long-session initial loading, pagination, streaming, and memory | Pending; the original PR's historical measurements do not validate this implementation | +| Unit, browser, and recorded-snapshot coverage | 785 relevant unit tests pass; read-only replay of 35 browser files yields 178 passing tests and 4 conditional skips; UI expectations are updated and Session JSONL is unchanged | +| Grouping and the Detailed/Expanded distinction | Incomplete (grouping phase) | + +Validation evidence: after rebasing onto master, this channel passed full `pnpm run build`, lint, duplication checks, and all 41 documentation checks. New presentation policy, shimmer, and visibility filtering, plus the settings and Skill rows reported by CI, pass 100% coverage checks over their selected source scope. Test repairs, UI snapshots, and bilingual pairing are committed together as CI fixes. No install, build, or tests ran in `pr-4565-turnstep`; this channel's results do not establish acceptance for that worktree. A real-model GUI demo and performance measurements remain outstanding. + +## Risks + +- `expanded` and `detailed` currently present identically; the original PR's distinction exists only after grouping is implemented. +- Whole-Turn duration replaces the existing tool/message-count title, changing product information. Migration must preserve status, accessible names, and Turns with no process content, not merely restyle the control. +- Hiding ordinary Context before adding trigger notifications removes background waking reasons; connect both items in the inventory's order. +- The original footer decision depends on grouping indexes. Rework ownership per feature rather than introducing the whole grouping infrastructure for a footer. +- Group identity, membership, and builder materialization in the experiments still require evaluation; this record does not treat the experiment as final architecture. diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md index 964d37d9d3..4055b027fc 100644 --- a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md @@ -2,6 +2,8 @@ Status: proposed +[English](2026-09-20-chat-work-details-migration.md) | 中文 + ## 问题 PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发通知、工具行视觉和基础设施行隐藏。整批迁移会把独立功能与列表重组混在一起,也难以逐项判断性能变化。需要一份按功能维护的进度表,先评审独立视觉功能,再结合 step-group 统一考虑轮次相关行为,避免遗漏原有产品需求。 @@ -12,7 +14,7 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发 本记录管理功能范围、当前实现和验收进度。[展示策略通道与过程分组的基础架构](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md)保留架构路线及备选方案;本记录不替代它,也不把其中的方案视为已经实现。新记录没有完整取代既有架构决策,因此保留原记录,不做归档或删除。 -本记录处于开发阶段,只维护中文版。完成状态、实现状态、暂存或提交状态、验收状态分别记录。未完成项即使已有代码草稿,也继续标为未完成,不从清单移除。测试断言、README 和录制快照留待后续;代码写好、进入暂存区、提交或构建通过,都不等同于功能或性能验收。 +本记录维护中英文配对。完成状态、实现状态、暂存或提交状态、验收状态分别记录。未完成项即使已有代码草稿,也继续标为未完成,不从清单移除。测试和文档随 CI 修复补齐,浏览器快照与性能验收单独记录;代码写好、进入暂存区、提交或构建通过,都不等同于完整功能或性能验收。 ### 状态定义 @@ -124,14 +126,14 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发 | 验收项 | 当前状态 | |---|---| -| 当前通道已有能力的完整行为验证 | 待验收;既有测试包含只做类型接线、尚未调整的旧断言 | +| 当前通道已有能力的完整行为验证 | 已通过本次相关单元和浏览器回归;不包含未迁移的分组功能,也不代表性能验收 | | 非分组功能迁移 | 完成范围见迁移清单;未完成草稿留到 `pr-4565-turnstep` 继续评审,功能与性能验证仍未完成 | -| 模式切换时的 DOM 身份和刷新范围 | 待验收;不能以 memo 或 selector 的存在代替实际证据 | +| 模式切换时的 DOM 身份和刷新范围 | 单元测试已验证摘要、手动展开正文的 DOM 身份和策略字段订阅;浏览器已验证三档持久化及整轮折叠,整页刷新范围与性能仍待验收 | | 长会话首次打开、分页、流式输入与内存 | 待验收;原 PR 的历史测量不代表本实现 | -| 新行为的单元、浏览器与录制快照覆盖 | 待补;开发阶段不在此轮运行测试或录制 | +| 新行为的单元、浏览器与录制快照覆盖 | 相关单元测试 785 项通过;35 个浏览器文件只读回放,178 项通过、4 项按条件跳过;UI 预期输出已更新,Session JSONL 未改动 | | 分组功能与两档差异 | 未完成(分组阶段) | -历史构建记录:迁移草稿曾通过三个源码包的单独 TypeScript 构建和完整 `pnpm run build`。首轮构建暴露的展示策略辅助文件编译归属问题已修正并提交,没有调整测试断言。该记录不代表拆分后的当前通道或 turnstep 工作区已重新验证;本次仅提交进度记录并搬迁草稿,不安装依赖、不编译、不执行测试、快照或性能基准。 +验证记录:当前通道在 rebase master 后通过完整 `pnpm run build`、lint、重复代码检查和完整文档检查(41 项)。新增展示策略与扫光、可见性过滤,以及 CI 报告的设置行和 Skill 行覆盖率已通过对应源码范围的 100% 检查。测试修复、UI 快照和中英配对随 CI 修复一并提交;`pr-4565-turnstep` 未执行安装、构建或测试,当前通道的结果不代表该工作区已验收。真实模型 GUI 演示和性能测量仍待补。 ## 风险 diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index 810336de88..de94289607 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -340,9 +340,9 @@ describe('web e2e: continuous conversation grown through the composer', () => { ))).toHaveLength(TURN_COUNT) expect(sessionEvents.flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) - expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(1) + expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(0) expect(await page.locator( - '[data-chat-flow-kind="system-prompt"][hidden="until-found"]', + '[data-chat-flow-kind="system-prompt"]', ).count()).toBe(0) expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000) expect(sessionEvents.flatMap(event => ( diff --git a/apps/web/tests/expected/cordis-history/ui.expected.md b/apps/web/tests/expected/cordis-history/ui.expected.md index 2ddd03bddb..1b37b0484f 100644 --- a/apps/web/tests/expected/cordis-history/ui.expected.md +++ b/apps/web/tests/expected/cordis-history/ui.expected.md @@ -9,14 +9,13 @@ - button "Jump to turn 1" - button "Jump to turn 2" - button "Jump to turn 3" -- button "System prompt" - text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. 9/1 {{clock}}" - button "Copy" - button "3 tool calls" [expanded] - button "Context injection runtime-context" -- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package." +- button "Think" - button "Inspect plugins 0 dynamic plugins" -- button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package." +- button "Think" - button "Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready" [expanded] - tablist "Plugin source": - tab "Client" @@ -27,11 +26,11 @@ - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" - text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings - button "Inspect" -- button "Think The Host returned snap-1/pkg-1, so I will request its first activation." +- button "Think" - text: Run Cordis Plugin snap-1 · pkg-1 Ready - button "Inspect" - text: snap-1/pkg-1 is awaiting user approval (run-1). -- button "Think The activation request has been submitted, so I will return the requested readiness marker." +- button "Think" - paragraph: CORDIS_UI_READY - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/fork-mid-turn/ui.expected.md b/apps/web/tests/expected/fork-mid-turn/ui.expected.md index 1350afec53..6c9e8eeaa5 100644 --- a/apps/web/tests/expected/fork-mid-turn/ui.expected.md +++ b/apps/web/tests/expected/fork-mid-turn/ui.expected.md @@ -1,12 +1,10 @@ -- button "System prompt" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel." +- button "Think" - text: Failed - 'button "Read The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."' - text: Failed - 'button "Tool call The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."' -- button "System prompt" - text: FORK_BRANCH_USER Summarize what this branch knows about the reads. {{clock}} - button "Copy" - button "Thought for a while" diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 4abbc3d013..6fbf824a1b 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -16,7 +16,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - button "Thought for a while" [expanded] - button "Context injection webhook github webhook handled by review-pr-when-ready" - button "Context injection runtime-context" diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 7c18886de2..1b30edc3e0 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -16,7 +16,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - button "Thought for a while" - paragraph: "Review complete: no actionable findings." - button "Copy" diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 1337d351d5..3f945d9cce 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -18,7 +18,7 @@ - text: Font size Only affects conversation content 14 - button "Increase font size" - button "Decrease font size" - - text: px Conversation display Controls process content in completed turns + - text: px Work details Controls how turns and steps expand by default - button "Compact" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index 1337d351d5..3f945d9cce 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -18,7 +18,7 @@ - text: Font size Only affects conversation content 14 - button "Increase font size" - button "Decrease font size" - - text: px Conversation display Controls process content in completed turns + - text: px Work details Controls how turns and steps expand by default - button "Compact" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 74db4241cd..c0a1b2ed35 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -18,8 +18,8 @@ - text: 字号大小 仅影响会话内容的字号 14 - button "增大字号" - button "减小字号" - - text: px 对话显示 控制已完成轮次的过程内容 - - button "紧凑" + - text: px 工作过程展示 控制轮次和步骤的默认展开方式 + - button "简洁" - text: 性能与用量 选择性能与用量信息展示的详细程度 - button "详细" - text: 开发者工具 显示用于调试和排查问题的工具与信息 diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 1876f63c32..9540d8b21f 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - button "/user-invoke-demo" - button "meeting notes.md" - text: and confirm the fixture wiring {{clock}} diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index b66832d25e..a1a363ff59 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - button "/user-invoke-demo" - button "meeting notes.md" - text: and confirm the fixture wiring {{clock}} diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index f3e9564660..13cc723a67 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -6,11 +6,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" - button "Context injection runtime-context" -- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." +- button "Think" - status: Deep diving... - text: "Interjection: include the word BANANA in your final reply." - button "Copy" diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index 9538b35054..0cbd3e6544 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -6,12 +6,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" - button "1 tool call" [expanded] - button "Context injection runtime-context" -- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." +- button "Think" - button "Ask question 1/1 answered" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index 870a712c04..92445bc6c1 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" - button "1 tool call" diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts index 9b6ebf857f..1361a476d3 100644 --- a/apps/web/tests/goal-multi-turn-actions.e2e.ts +++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts @@ -155,9 +155,9 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () = expect(sessionEvents.flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) await expect.poll(() => page.locator('[data-turn-process]').count(), { timeout: 15_000 }).toBe(2) - expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(2) + expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(0) expect(await page.locator( - '[data-chat-flow-kind="system-prompt"][hidden="until-found"]', + '[data-chat-flow-kind="system-prompt"]', ).count()).toBe(0) const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' }) await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 241d90707c..0576d26412 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -323,10 +323,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 } if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 }) const observedReasoning = Promise.withResolvers() + const reasoningComplete = Promise.withResolvers() const releaseStream = MODE === 'record' ? undefined : scaffold.ctx.on('llm/stream', async function* (_options, next) { let reasoning = false for await (const chunk of next()) { if (reasoning && chunk.type !== 'reasoning-delta') { + reasoningComplete.resolve(undefined) await observedReasoning.promise } if (chunk.type === 'reasoning-delta') reasoning = true @@ -338,17 +340,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () if (MODE !== 'record') { const thinking = page.locator('[data-variant="think"][data-state="running"]') await expect.poll(() => thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false') - const liveTail = thinking.locator('[data-follow-end]') - await expect.poll(async () => { - if (await liveTail.count() !== 1) return false - return await liveTail.evaluate((element) => { - const text = element.firstElementChild - if (!(text instanceof HTMLElement)) return false - const viewport = element.getBoundingClientRect() - const content = text.getBoundingClientRect() - return content.width > viewport.width && Math.abs(content.right - viewport.right) <= 1 - }) - }, { timeout: 10_000, interval: 10 }).toBe(true) + await reasoningComplete.promise + expect(await thinking.getAttribute('data-preview')).toBeNull() + expect(await thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false') + expect(await thinking.locator('[data-streaming]').isVisible()).toBe(false) } observedReasoning.resolve(undefined) return await settled diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index fd4a0b2e9c..a4f07bb745 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -227,7 +227,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await expect.poll( () => page.getByRole('button', { name: 'System prompt', exact: true }).count(), { timeout: 10_000 }, - ).toBe(2) + ).toBe(0) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until // hover/focus-within). Branch renders only under assistant answers — user @@ -255,7 +255,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => { const copy = page.getByRole('button', { name: 'Copy', exact: true }).last() const composer = page.locator('[data-composer-seat]') const tooltip = page.getByRole('tooltip', { name: 'Copy', exact: true }) + const originalViewport = page.viewportSize() + if (originalViewport === null) throw new Error('tooltip probe requires a fixed viewport') try { + await page.setViewportSize({ width: originalViewport.width, height: 600 }) // Grow the sticky seat upward so the bottom tooltip overlaps it without // depending on the fixture's resting composer height. await composer.evaluate((element) => { element.style.paddingTop = '48px' }) @@ -297,6 +300,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await page.mouse.move(0, 0) if (await copy.count() > 0) await copy.evaluate((element) => { element.blur() }) if (await tooltip.count() > 0) await tooltip.waitFor({ state: 'hidden', timeout: 5_000 }) + await page.setViewportSize(originalViewport) } expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 48b505f398..7604dba970 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -296,24 +296,13 @@ describe('web e2e: fresh round trip through the real assembly', () => { await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE) }) - it.skipIf(MODE === 'record')('renders the system prompt disclosure inside the expanded Turn process', async () => { + it.skipIf(MODE === 'record')('omits system prompt rows even when the Turn process is expanded', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-system-prompt')) await expandTurnProcesses(page) - const disclosure = page.getByRole('button', { name: 'System prompt', exact: true }) - const body = page.locator('[data-system-prompt-body]') - await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1) - await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false') - expect(await body.count()).toBe(0) - - await disclosure.click() - await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') - const opaque = body.locator('[data-context-text]') - await expect.poll(() => opaque.count(), { timeout: 5_000 }).toBe(1) - expect(await opaque.textContent()).toContain('You are an AI agent powered by DeepSeek Harness.') - - await disclosure.click() - await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false') - await expect.poll(() => body.count()).toBe(0) + expect(sessionEvents.some(event => event.type === 'system/message')).toBe(true) + expect(await page.locator('[data-chat-flow-kind="system-prompt"]').count()).toBe(0) + expect(await page.getByRole('button', { name: 'System prompt', exact: true }).count()).toBe(0) + expect(await page.locator('[data-system-prompt-body]').count()).toBe(0) }) it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => { diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index b31b33bba0..e63471911d 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -721,24 +721,21 @@ describe('web e2e: seeded history renders through cold resume', () => { if (bodyError !== undefined) throw bodyError }) - it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => { + it.skipIf(MODE === 'record')('an Access-chip switch persists its command without adding a Chat row', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row')) - // The Access chip submits `/permission ` — a host command with no - // model call, so the settled row renders keylessly over this cold history. - // The row copy is the assertion: `permission · preset read-only`, - // where neither half repeats the other (the dispatched `/` and its - // argument stay out of the title, and the settlement text never restates - // the command's own name). await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).click() await page.getByRole('menuitem', { name: 'Read Only' }).click() const access = page.getByRole('button', { name: 'Access mode, current: Read Only' }) await expect.poll(() => access.isEnabled(), { timeout: 10_000 }).toBe(true) - // Scoped to the row itself, so unrelated page text that happens to read - // `permission` (a future resident slash menu) cannot satisfy or break it. const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset read-only' }) - await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1) - expect(await row.getByText('permission', { exact: true }).count()).toBe(1) - expect(await row.getByText('/permission read-only', { exact: true }).count()).toBe(0) + expect(await row.count()).toBe(0) + const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) + if (agent === undefined) throw new Error('seeded session did not attach an agent') + const events = agent.session.snapshotEvents() + const run = events.findLast(event => event.type === 'command/run' && event.data.name === 'permission') + if (run?.type !== 'command/run') throw new Error('permission command was not persisted') + expect(events.find(event => event.type === 'command/done' && event.data.commandId === run.data.commandId)?.data) + .toMatchObject({ kind: 'success', text: 'preset read-only' }) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 0c6e79308a..050a928221 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -548,17 +548,20 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 90_000) - it('persists the completed-Turn transcript mode across reload', async () => { + it.each([ + ['detailed', '详细'], ['expanded', '完全展开'], + ] as const)('persists the %s work-details mode across reload', async (mode, label) => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-transcript-view')) await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByText('对话显示', { exact: true }).waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '紧凑', exact: true }).click() - await page.getByRole('menuitem', { name: '标准', exact: true }).click() - await dialog.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 }) + await dialog.getByText('工作过程展示', { exact: true }).waitFor({ timeout: 10_000 }) + const details = dialog.getByText('工作过程展示', { exact: true }).locator('../..') + await details.getByRole('button', { name: '简洁', exact: true }).click() + await page.getByRole('menuitem', { name: label, exact: true }).click() + await details.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 }) await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) - .toMatch(/ui-chat:\n\s+transcriptView: normal/) + .toContain(`transcriptView: ${mode}`) await page.keyboard.press('Escape') const warningStart = tripwire.warnings.length @@ -567,11 +570,12 @@ describe('web e2e: settings modal and General preferences', () => { acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.getByRole('button', { name: '设置', exact: true }).click() const reloaded = page.getByRole('dialog', { name: '设置' }) - await reloaded.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 }) + const restoredDetails = reloaded.getByText('工作过程展示', { exact: true }).locator('../..') + await restoredDetails.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 }) - await reloaded.getByRole('button', { name: '标准', exact: true }).click() - await page.getByRole('menuitem', { name: '紧凑', exact: true }).click() - await reloaded.getByRole('button', { name: '紧凑', exact: true }).waitFor({ timeout: 10_000 }) + await restoredDetails.getByRole('button', { name: label, exact: true }).click() + await page.getByRole('menuitem', { name: '简洁', exact: true }).click() + await restoredDetails.getByRole('button', { name: '简洁', exact: true }).waitFor({ timeout: 10_000 }) await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) .toMatch(/ui-chat:\n\s+transcriptView: compact/) await page.keyboard.press('Escape') diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 7ea13e1c9c..f4f33e8f1d 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -232,7 +232,9 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { expect(tripwire.warnings).toEqual([]) }, 60_000) - it.skipIf(MODE === 'record')('switches a completed Turn between Compact and Normal', async () => { + it.skipIf(MODE === 'record').each([ + ['detailed', 'Detailed'], ['expanded', 'Expanded'], + ] as const)('preserves whole-Turn folding in %s mode', async (mode, label) => { await launch() onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting')) const { settled } = await sendPrompt() @@ -245,18 +247,21 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { await page.getByRole('button', { name: 'Settings', exact: true }).click() const dialog = page.getByRole('dialog', { name: 'Settings' }) - await dialog.getByRole('button', { name: 'Compact', exact: true }).click() - await page.getByRole('menuitem', { name: 'Normal', exact: true }).click() + await dialog.getByText('Work details', { exact: true }).locator('../..') + .getByRole('button', { name: 'Compact', exact: true }).click() + await page.getByRole('menuitem', { name: label, exact: true }).click() await page.keyboard.press('Escape') - await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(0) - await tool.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await process.count()).toBe(1) + expect(await process.getAttribute('aria-expanded')).toBe('false') + expect(await tool.isVisible()).toBe(false) await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) - .toMatch(/ui-chat:\n\s+transcriptView: normal/) + .toContain(`transcriptView: ${mode}`) await page.getByRole('button', { name: 'Settings', exact: true }).click() const restored = page.getByRole('dialog', { name: 'Settings' }) - await restored.getByRole('button', { name: 'Normal', exact: true }).click() + await restored.getByText('Work details', { exact: true }).locator('../..') + .getByRole('button', { name: label, exact: true }).click() await page.getByRole('menuitem', { name: 'Compact', exact: true }).click() await page.keyboard.press('Escape') await process.waitFor({ timeout: 10_000 }) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 9cae3426ea..1ee50e0265 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1472,6 +1472,7 @@ describe('ChatView', () => { assistant(5, 'final answer', 1, 2), ], turnEnds: new Map([[1, 6]]), + turnTimings: new Map([[1, { startTime: 0, endTime: 6_000 }]]), }) const view = render() const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') @@ -1485,18 +1486,16 @@ describe('ChatView', () => { expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null]) }) - it('keeps the first System prompt above User and outside Process through completion and expansion', () => { + it('omits the System prompt through Turn completion and process expansion', () => { const builder = new ChatSnapshotBuilder() const initial = withSystemPrompt(chatSnapshotFixture({ nodes: [userInTurn(2, 'question', 1), context(3, 'runtime policy', 1)], + turnTimings: new Map([[1, { startTime: 0 }]]), }), builder) const h = makeHarness({ chat: initial }, { running: true }) const view = render() - const promptRow = view.container.querySelector('[data-chat-flow-kind="system-prompt"]')! - - expect(renderedFlowKinds(view.container)).toEqual(['system-prompt', 'user', 'context']) - expect(promptRow.getAttribute('hidden')).toBeNull() - expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false) + expect(renderedFlowKinds(view.container)).toEqual(['user', 'context']) + expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull() act(() => { h.set({ @@ -1506,15 +1505,15 @@ describe('ChatView', () => { context(3, 'runtime policy', 1), reasoningAssistant(4, 'inspect', 1, 1), ], + turnTimings: new Map([[1, { startTime: 0 }]]), }), builder), running: true, }) }) expect(renderedFlowKinds(view.container)).toEqual([ - 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', + 'user', 'turn-process', 'context', 'assistant-step', ]) - expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBe(promptRow) - expect(promptRow.getAttribute('hidden')).toBeNull() + expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull() act(() => { h.set({ @@ -1526,6 +1525,7 @@ describe('ChatView', () => { assistant(6, 'final answer', 1, 2), ], turnEnds: new Map([[1, 7]]), + turnTimings: new Map([[1, { startTime: 0, endTime: 7_000 }]]), }), builder), running: false, }) @@ -1533,19 +1533,18 @@ describe('ChatView', () => { const toggle = turnProcessControl(view.container)! const members = [...view.container.querySelectorAll('[data-turn-process-member]')] expect(renderedFlowKinds(view.container)).toEqual([ - 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail', + 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail', ]) expect(toggle.getAttribute('aria-expanded')).toBe('false') - expect(promptRow.getAttribute('hidden')).toBeNull() - expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false) + expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull() expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step']) expect(members.map(member => member.getAttribute('hidden'))).toEqual(['until-found', 'until-found']) fireEvent.click(toggle) expect(renderedFlowKinds(view.container)).toEqual([ - 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail', + 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail', ]) - expect(promptRow.getAttribute('hidden')).toBeNull() + expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull() expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null]) }) @@ -1553,6 +1552,7 @@ describe('ChatView', () => { const h = makeHarness({ nodes: [user(1, 'question'), context(2, 'runtime policy', 1), assistant(3, 'final answer', 1, 1)], turnEnds: new Map([[1, 4]]), + turnTimings: new Map([[1, { startTime: 0, endTime: 4_000 }]]), }) const view = render() const toggle = view.getByRole('button', { name: '已思考' }) @@ -1607,6 +1607,7 @@ describe('ChatView', () => { nodes: [user(1, 'question'), process], partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'streaming answer' }] }, running: true, + turnTimings: new Map([[1, { startTime: 0 }]]), }) const view = render() expect(turnProcessControl(view.container)).toBeNull() @@ -1625,11 +1626,12 @@ describe('ChatView', () => { expect(processRow.getAttribute('hidden')).toBe('until-found') }) - it('switches completed Turns between the persisted Normal and Compact modes', () => { + it('preserves whole-Turn folding and manual expansion across work-details modes', () => { const process = assistant(2, 'inspect', 1, 1) const h = makeHarness({ nodes: [user(1, 'question'), process, assistant(4, 'final answer', 1, 2)], turnEnds: new Map([[1, 5]]), + turnTimings: new Map([[1, { startTime: 0, endTime: 5_000 }]]), }) const view = render() const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement @@ -1637,13 +1639,20 @@ describe('ChatView', () => { expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') expect(processRow.getAttribute('hidden')).toBe('until-found') - act(() => { h.setTranscriptView('detailed') }) - expect(turnProcessControl(view.container)).toBeNull() - expect(processRow.getAttribute('hidden')).toBeNull() - - act(() => { h.setTranscriptView('compact') }) - expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') - expect(processRow.getAttribute('hidden')).toBe('until-found') + const toggle = turnProcessControl(view.container)! + for (const mode of ['detailed', 'expanded', 'compact'] as const) { + act(() => { h.setTranscriptView(mode) }) + expect(turnProcessControl(view.container)).toBe(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(processRow.getAttribute('hidden')).toBe('until-found') + } + fireEvent.click(toggle) + for (const mode of ['detailed', 'expanded', 'compact'] as const) { + act(() => { h.setTranscriptView(mode) }) + expect(turnProcessControl(view.container)).toBe(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(processRow.getAttribute('hidden')).toBeNull() + } }) it('folds final-step reasoning under the fallback title when every summary count is zero', () => { @@ -1654,7 +1663,11 @@ describe('ChatView', () => { { kind: 'text' as const, text: 'final answer' }, ], } - const h = makeHarness({ nodes: [user(1, 'question'), final], turnEnds: new Map([[1, 4]]) }) + const h = makeHarness({ + nodes: [user(1, 'question'), final], + turnEnds: new Map([[1, 4]]), + turnTimings: new Map([[1, { startTime: 0, endTime: 4_000 }]]), + }) const view = render() const toggle = view.getByRole('button', { name: '已思考' }) const reasoning = view.container.querySelector('[data-turn-process-inline]') @@ -1667,7 +1680,10 @@ describe('ChatView', () => { it('folds a completed Turn even while the reader is away from the tail', () => { const first = assistant(2, 'first answer', 1, 1) - const h = makeHarness({ nodes: [user(1, 'question'), first], running: true }) + const h = makeHarness({ + nodes: [user(1, 'question'), first], running: true, + turnTimings: new Map([[1, { startTime: 0 }]]), + }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true }) @@ -1689,6 +1705,7 @@ describe('ChatView', () => { const h = makeHarness({ nodes: [user(1, 'question'), context(2, 'runtime policy', 1)], running: true, + turnTimings: new Map([[1, { startTime: 0 }]]), }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement @@ -1716,6 +1733,7 @@ describe('ChatView', () => { const h = makeHarness({ nodes: [user(1, 'question'), context(2, 'runtime policy', 1)], running: true, + turnTimings: new Map([[1, { startTime: 0 }]]), }) const view = render() const contextToggle = view.getByRole('button', { name: '上下文注入' }) @@ -1743,7 +1761,7 @@ describe('ChatView', () => { expect(contextRow?.getAttribute('hidden')).toBe('until-found') }) - it('keeps a foldable closed Turn fully visible while history is partial', () => { + it('withholds folding until the Turn start arrives even when older history remains', () => { const h = makeHarness({ nodes: [ user(1, 'question'), @@ -1762,6 +1780,13 @@ describe('ChatView', () => { expect(contextRow?.hasAttribute('data-turn-process-member')).toBe(false) act(() => { h.set({ hasMore: false }) }) + expect(turnProcessControl(view.container)).toBeNull() + expect(contextRow?.getAttribute('hidden')).toBeNull() + + act(() => { h.set({ + hasMore: true, + turnTimings: new Map([[1, { startTime: 0, endTime: 5_000 }]]), + }) }) const toggle = turnProcessControl(view.container)! expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(contextRow?.getAttribute('hidden')).toBe('until-found') @@ -1785,6 +1810,7 @@ describe('ChatView', () => { assistant(10, 'visible answer', 2), ], turnEnds: new Map([[1, 5]]), + turnTimings: new Map([[1, { startTime: 0, endTime: 5_000 }]]), hasMore: false, }) }) @@ -1808,6 +1834,7 @@ describe('ChatView', () => { assistant(4, 'final answer', 1, 2), ], turnEnds: new Map([[1, 5]]), + turnTimings: new Map([[1, { startTime: 0, endTime: 5_000 }]]), }) const process = source.nodes.values() .find((candidate): candidate is ChatNode<'turn-process'> => candidate.kind === 'turn-process') @@ -1863,6 +1890,7 @@ describe('ChatView', () => { const h = makeHarness({ nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)], turnEnds: new Map([[1, 5]]), + turnTimings: new Map([[1, { startTime: 0, endTime: 5_000 }]]), }) const view = render(, { container: host }) fireEvent.click(turnProcessControl(view.container)!) diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index f116edab09..90c9c79b0a 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -166,6 +166,12 @@ function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | und return value.nodes.values().find(candidate => candidate.kind === kind) } +function promptNodes(value: ChatSnapshot): ChatConversationViewNode[] { + const prompts = value.nodes.values().filter(candidate => candidate.kind === 'system-prompt') + for (const prompt of prompts) expect(value.order).not.toContain(prompt.key) + return prompts.sort((left, right) => left.anchorSeq - right.anchorSeq) +} + function textMessage(id: string, text: string) { return { id, @@ -319,6 +325,42 @@ describe('built-in conversation node Definitions', () => { expect(hasAssistantReplyContent([{ kind: 'other', block: { type: 'future' } }])).toBe(true) }) + it.each(['replay', 'live', 'prepend'] as const)('hides permission commands but retains their data and ordinary Context (%s)', (mode) => { + const entries = [ + at(1, 'command/run', { commandId: 'permission-1', name: 'permission', source: { kind: 'user' } }), + at(2, 'command/done', { commandId: 'permission-1', kind: 'success', text: 'Granted' }), + at(3, 'command/run', { commandId: 'plan-1', name: 'plan', source: { kind: 'user' } }), + at(4, 'command/done', { commandId: 'plan-1', kind: 'success', text: 'Plan mode' }), + at(5, 'turn/start', { turn: 1 }), + at(6, 'step/start', { turn: 1, step: 1 }), + at(7, 'user/message', { + ...textMessage('context-1', 'workspace context'), + source: { kind: 'context' }, + }, { surfaceOp: 'append' }), + ] + const value = assembler(mode === 'replay' ? entries : []) + if (mode === 'live') { + for (const entry of entries) { + value.append(entry) + value.flush() + } + } else if (mode === 'prepend') { + value.replaceWindow(entries.slice(1), true) + value.flush() + expect(node(snapshot(value), 'command')?.data).toMatchObject({ name: null }) + value.prepend(entries.slice(0, 1), false) + value.flush() + } + const current = snapshot(value) + const visible = current.order.map(key => current.nodes.get(key)) + expect(visible.map(candidate => candidate?.kind)).toEqual(['command', 'context']) + expect(visible[0]?.data).toMatchObject({ name: 'plan' }) + expect(current.nodes.values().filter(candidate => candidate.kind === 'command')).toHaveLength(2) + expect(node(current, 'command')?.data).toMatchObject({ + name: 'permission', outcome: { kind: 'success', text: 'Granted' }, + }) + }) + it('projects one reversible process window before the finalized answer', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), @@ -1543,7 +1585,7 @@ describe('built-in conversation node Definitions', () => { expect(current.nodes.values().filter(candidate => candidate.kind === 'unknown')).toEqual([]) }) - it('shows a complete appended prompt at the start of a headerless window', () => { + it('retains a complete appended prompt at the start of a headerless window', () => { const value = assembler([ systemUpdateAt(10, '# Known prompt', 2, 1), at(11, 'user/message', textMessage('window-user', 'continue'), { surfaceOp: 'append' }), @@ -1558,8 +1600,8 @@ describe('built-in conversation node Definitions', () => { ], false) value.flush() const restored = snapshot(value) - expect(restored.order.map(key => restored.nodes.get(key)).filter(candidate => candidate?.kind === 'system-prompt') - .map(candidate => candidate?.data)).toEqual([{ text: '# Original' }, { text: '# Known prompt', update: true }]) + expect(promptNodes(restored).map(candidate => candidate.data)) + .toEqual([{ text: '# Original' }, { text: '# Known prompt', update: true }]) }) it('withholds windowed replacement prompts until prepend resolves their positions', () => { @@ -1595,10 +1637,7 @@ describe('built-in conversation node Definitions', () => { ] const promptTexts = (value: ConversationNodeAssembler) => { const restored = snapshot(value) - return restored.order.flatMap((key) => { - const candidate = restored.nodes.get(key) - return candidate?.kind === 'system-prompt' ? [candidate.data] : [] - }) + return promptNodes(restored).map(candidate => candidate.data) } windowed.prepend(older, false) windowed.flush() @@ -1608,7 +1647,7 @@ describe('built-in conversation node Definitions', () => { expect(promptTexts(nodeless)).toEqual([{ text: '# Original prompt' }, { text: '# Original prompt' }]) }) - it('shows the system node text as the request prompt card before the request messages', () => { + it('retains the request prompt without adding it to visible Chat rows', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), at(2, 'step/start', { turn: 1, step: 1 }), @@ -1626,7 +1665,6 @@ describe('built-in conversation node Definitions', () => { const current = snapshot(value) expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([ - 'system-prompt', 'user', 'context', ]) @@ -1675,8 +1713,7 @@ describe('built-in conversation node Definitions', () => { } } const current = snapshot(value) - expect(current.order.map(key => current.nodes.get(key)).filter(candidate => candidate?.kind === 'system-prompt') - .map(candidate => candidate?.data)).toEqual([ + expect(promptNodes(current).map(candidate => candidate.data)).toEqual([ { text: 'A' }, { text: 'B', update: true }, { text: 'A' }, ]) }) @@ -1704,15 +1741,15 @@ describe('built-in conversation node Definitions', () => { ]) const current = snapshot(value) - expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user']) + expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['user']) value.append(systemAt(5, '# Replaced', 3)) value.flush() const replaced = snapshot(value) - expect(replaced.order.map(key => replaced.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user']) + expect(replaced.order.map(key => replaced.nodes.get(key)?.kind)).toEqual(['user']) }) - it('presents an in-history prompt update as its own card and lets no same-step header repeat it', () => { + it('retains an in-history prompt update without duplicating it at the same-step header', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), at(2, 'step/start', { turn: 1, step: 1 }), @@ -1731,19 +1768,14 @@ describe('built-in conversation node Definitions', () => { ]) const cards = () => { const current = snapshot(value) - return current.order.flatMap((key) => { - const candidate = current.nodes.get(key) - return candidate?.kind === 'system-prompt' ? [[candidate.anchorSeq, candidate.data]] : [] - }) + return promptNodes(current).map(candidate => [candidate.anchorSeq, candidate.data]) } - // The update is the model-visible change at its position; node 0 keeps its card. expect(cards()).toEqual([ [1, { text: '# System' }], [10, { text: '# Updated', update: true }], ]) - // A series header in the same step shows nothing more: the update card already carries the text. value.append(at(12, 'request/header', { reason: 'series', startsSeries: true, @@ -1752,7 +1784,6 @@ describe('built-in conversation node Definitions', () => { value.flush() expect(cards()).toHaveLength(2) - // A later series header presents the effective prompt again, as any series start does. value.append(at(13, 'step/end', { turn: 2, step: 1 })) value.append(at(14, 'step/start', { turn: 2, step: 2 })) value.append(at(15, 'request/header', { @@ -1784,10 +1815,11 @@ describe('built-in conversation node Definitions', () => { ]) const current = snapshot(value) - expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user']) + expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['user']) + expect(promptNodes(current).map(candidate => candidate.data)).toEqual([{ text: '# System' }]) }) - it('keeps the initial system prompt before the opening User as Turn process state changes', () => { + it('keeps the system prompt out of Chat order as Turn process state changes', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), at(2, 'step/start', { turn: 1, step: 1 }), @@ -1808,14 +1840,14 @@ describe('built-in conversation node Definitions', () => { } const promptKey = node(snapshot(value), 'system-prompt')?.key - expect(kinds()).toEqual(['system-prompt', 'user', 'context']) + expect(kinds()).toEqual(['user', 'context']) value.append(at(7, 'assistant/live-chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, })) value.flush() expect(kinds()).toEqual([ - 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', + 'user', 'turn-process', 'context', 'assistant-step', ]) value.append(at(8, 'step/end', { turn: 1, step: 1 })) @@ -1828,7 +1860,7 @@ describe('built-in conversation node Definitions', () => { value.flush() expect(kinds()).toEqual([ - 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail', + 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail', ]) expect(node(snapshot(value), 'system-prompt')?.key).toBe(promptKey) }) @@ -1855,7 +1887,8 @@ describe('built-in conversation node Definitions', () => { const candidate = current.nodes.get(key) return candidate?.kind === 'system-prompt' || candidate?.kind === 'user' ? [candidate] : [] }) - expect(ordered.map(candidate => candidate.kind)).toEqual(['system-prompt', 'user', 'user']) + expect(ordered.map(candidate => candidate.kind)).toEqual(['user', 'user']) + expect(promptNodes(current).map(candidate => candidate.data)).toEqual([{ text: '# System' }]) }) it('places a withheld replacement prompt after prepend supplies its original node', () => { @@ -1892,10 +1925,7 @@ describe('built-in conversation node Definitions', () => { windowed.flush() const restored = snapshot(windowed) - const prompts = restored.order.flatMap((key) => { - const candidate = restored.nodes.get(key) - return candidate?.kind === 'system-prompt' ? [candidate] : [] - }) + const prompts = promptNodes(restored) expect(prompts.map(candidate => candidate.anchorSeq)).toEqual([1, 10]) expect(prompts.at(-1)?.data).toEqual({ text: windowedSystem }) expect(restored.nodes.get(user.key)).toBeDefined() @@ -1936,11 +1966,8 @@ describe('built-in conversation node Definitions', () => { const candidate = current.nodes.get(key) return candidate?.kind === 'system-prompt' || candidate?.kind === 'user' ? [candidate] : [] }) - expect(ordered.map(candidate => candidate?.kind)).toEqual([ - 'system-prompt', 'user', 'system-prompt', 'system-prompt', 'user', - ]) - expect(ordered.filter(candidate => candidate?.kind === 'system-prompt') - .map(candidate => candidate?.anchorSeq)).toEqual([1, 7, 10]) + expect(ordered.map(candidate => candidate.kind)).toEqual(['user', 'user']) + expect(promptNodes(current).map(candidate => candidate.anchorSeq)).toEqual([1, 7, 10]) }) it('associates each direct message with its immediately following session recall', () => { diff --git a/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx b/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx new file mode 100644 index 0000000000..c7070e147c --- /dev/null +++ b/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import type { TranscriptViewMode } from '../src/chat-settings.ts' +import { derivePresentationPolicy, presentationPolicyFor } from '../src/client/presentation-policy.ts' + +afterEach(cleanup) + +describe('Chat presentation policy', () => { + it.each([ + ['compact', 'collapsed', false], + ['detailed', 'collapsed', true], + ['expanded', 'none', true], + ] as const)('maps %s to stable presentation capabilities', (mode, stepGrouping, settledReasoningPreview) => { + const policy = presentationPolicyFor(mode) + expect(policy).toEqual({ mode, foldCompletedTurns: true, stepGrouping, settledReasoningPreview }) + expect(presentationPolicyFor(mode)).toBe(policy) + }) + + it('forwards mode notifications and removes the subscription on disposal', () => { + const mode = createSnapshotStore('compact') + const policy = derivePresentationPolicy(mode) + const listener = vi.fn() + const dispose = policy.subscribe(listener) + expect(policy.getSnapshot()).toBe(presentationPolicyFor('compact')) + mode.set('detailed') + expect(listener).toHaveBeenCalledTimes(1) + expect(policy.getSnapshot()).toBe(presentationPolicyFor('detailed')) + dispose() + mode.set('expanded') + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('renders only consumers whose selected field changes', () => { + const mode = createSnapshotStore('compact') + const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode)) + const foldRender = vi.fn() + const previewRender = vi.fn() + function Fold() { + const fold = usePresentation(policy => policy.foldCompletedTurns) + foldRender(fold) + return {String(fold)} + } + function Preview() { + const preview = usePresentation(policy => policy.settledReasoningPreview) + previewRender(preview) + return {String(preview)} + } + render(<>) + expect(foldRender).toHaveBeenCalledTimes(1) + expect(previewRender).toHaveBeenCalledTimes(1) + act(() => { mode.set('detailed') }) + expect(foldRender).toHaveBeenCalledTimes(1) + expect(previewRender).toHaveBeenCalledTimes(2) + act(() => { mode.set('expanded') }) + expect(foldRender).toHaveBeenCalledTimes(1) + expect(previewRender).toHaveBeenCalledTimes(2) + act(() => { mode.set('compact') }) + expect(foldRender).toHaveBeenCalledTimes(1) + expect(previewRender).toHaveBeenCalledTimes(3) + }) +}) diff --git a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index e1f399208e..c729a91634 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -1,11 +1,15 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest' -import { cleanup, fireEvent, render } from '@testing-library/react' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '../src/client/locale.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' import { useDetailedPresentation } from './presentation-fixture.client.ts' +import type { TranscriptViewMode } from '../src/chat-settings.ts' +import { derivePresentationPolicy } from '../src/client/presentation-policy.ts' +import { ReasoningRow } from '../src/client/chat/ReasoningRow.tsx' afterEach(() => { cleanup() @@ -15,6 +19,59 @@ const t = makeTranslate(zh, commonZh) const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null describe('ReasoningRow', () => { + it.each([ + ['', ''], + ['An unfinished line', ''], + ['A completed first line\nUnfinished continuation', 'A completed first line'], + ['First\nSecond\nThird\n', 'First'], + ['First\n\nNext unfinished', 'First'], + ['First\n\nNext complete\nDetails', 'Next complete'], + ['First\r\n \t\r\nNext complete\r\n', 'Next complete'], + ['First\n\n\n', 'First'], + ])('previews the completed paragraph first line for %j', (text, summary) => { + const view = render() + const root = view.container.querySelector('[data-variant="think"]')! + expect(root.querySelector('[class*="summaryText"]')?.textContent).toBe(summary) + expect(root.hasAttribute('data-preview')).toBe(summary !== '') + }) + + it('retains summaries and expanded Markdown when the work-details mode changes', () => { + const mode = createSnapshotStore('detailed') + const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode)) + const view = render() + const root = view.container.querySelector('[data-variant="think"]')! + const summary = view.getByText('First line') + const toggle = view.getByRole('button') + for (const next of ['compact', 'detailed', 'expanded', 'compact'] as const) { + act(() => { mode.set(next) }) + expect(view.getByText('First line')).toBe(summary) + expect(root.hasAttribute('data-preview')).toBe(next !== 'compact') + expect(root.querySelector('[data-markdown-variant]')).toBeNull() + } + fireEvent.click(toggle) + const body = view.getByText('Detailed body') + for (const next of ['detailed', 'expanded', 'compact'] as const) { + act(() => { mode.set(next) }) + expect(view.getByText('Detailed body')).toBe(body) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(root.hasAttribute('data-preview')).toBe(false) + } + }) + + it('keeps a running preview enabled in Compact and hides it on settlement without removing it', () => { + const mode = createSnapshotStore('compact') + const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode)) + const props = { text: 'First line\n', usePresentation, t } + const view = render() + const summary = view.getByText('First line') + const root = view.container.querySelector('[data-variant="think"]')! + expect(root.hasAttribute('data-preview')).toBe(true) + view.rerender() + expect(root.hasAttribute('data-preview')).toBe(false) + expect(view.getByText('First line')).toBe(summary) + }) + it.each([ { kind: 'text' as const, text: 'Answer' }, { kind: 'tool-call' as const, callId: 'call-1', name: 'read', argsRaw: '{}' }, @@ -44,38 +101,52 @@ describe('ReasoningRow', () => { expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') }) - it('follows the latest streaming line, then restores the settled first line', () => { + it('advances on completed paragraph first lines, then restores the settled first line', () => { const view = render( , ) expect(view.getByText('运行中')).toBeTruthy() expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') - expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-follow-end')) + expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-streaming')) .toBe('true') view.rerender( , ) - expect(view.getByText('Newest reasoning tokens keep arriving').parentElement - ?.getAttribute('data-follow-end')).toBe('true') + expect(view.getByText('Newest reasoning tokens').parentElement + ?.getAttribute('data-streaming')).toBe('true') + expect(view.queryByText('Checking boundaries')).toBeNull() + + const text = 'Inspect the session\nDetails\n\nNewest reasoning tokens\nMore details\n\nChecking boundaries\n' + view.rerender( + , + ) + expect(view.getByText('Checking boundaries')).toBeTruthy() + expect(view.queryByText('Newest reasoning tokens')).toBeNull() view.rerender( , @@ -83,7 +154,7 @@ describe('ReasoningRow', () => { const settledSummary = view.getByText('Inspect the session') expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') expect(view.queryByText('运行中')).toBeNull() - expect(settledSummary.parentElement?.hasAttribute('data-follow-end')).toBe(false) + expect(settledSummary.parentElement?.hasAttribute('data-streaming')).toBe(false) }) it('expands from either Think or the reasoning summary', () => { @@ -114,7 +185,7 @@ describe('ReasoningRow', () => { }, { label: 'streaming', - text: 'Inspect the session\n**Comparing checkout and merge bases**', + text: 'Inspect the session\n\n**Comparing checkout and merge bases**\n', streaming: true, }, ])('strips double-asterisk markers from the $label summary and renders body emphasis', ({ text, streaming }) => { diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index a5887a4e1e..6d578cb38f 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -55,28 +55,37 @@ function mount(mode: TranscriptViewMode = 'compact', dictionary: typeof en | typ describe('TranscriptViewRow', () => { it('explains the preference and shows Compact by default', () => { mount() - expect(screen.getByText('Conversation display')).toBeDefined() - expect(screen.getByText('Controls process content in completed turns')).toBeDefined() + expect(screen.getByText('Work details')).toBeDefined() + expect(screen.getByText('Controls how turns and steps expand by default')).toBeDefined() expect(screen.getByRole('button', { name: /Compact/ }).getAttribute('aria-expanded')).toBe('false') }) - it('selects Normal and follows the mirrored value', () => { + it.each([ + ['detailed', 'Detailed'], + ['expanded', 'Expanded'], + ] as const)('selects %s and follows the mirrored value', (mode, label) => { const b = mount() fireEvent.click(screen.getByRole('button', { name: /Compact/ })) - fireEvent.click(screen.getByRole('menuitem', { name: 'Normal' })) - expect(b.setTranscriptView).toHaveBeenCalledWith('normal') - const trigger = screen.getByRole('button', { name: /Normal/ }) + expect(screen.queryByRole('menuitem', { name: 'Normal' })).toBeNull() + fireEvent.click(screen.getByRole('menuitem', { name: label })) + expect(b.setTranscriptView).toHaveBeenCalledWith(mode) + const trigger = screen.getByRole('button', { name: label }) fireEvent.click(trigger) expect(screen.getByRole('menuitem', { name: 'Compact' })).toBeDefined() fireEvent.pointerDown(document.body) expect(screen.queryByRole('menuitem', { name: 'Compact' })).toBeNull() }) - it('shows the conversation-display values in Chinese', () => { - mount('compact', zh) - fireEvent.click(screen.getByRole('button', { name: '紧凑' })) - fireEvent.click(screen.getByRole('menuitem', { name: '标准' })) - expect(screen.getByRole('button', { name: '标准' })).toBeDefined() + it('shows all three work-detail values in Chinese', () => { + const b = mount('compact', zh) + expect(screen.getByText('工作过程展示')).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: '简洁' })) + fireEvent.click(screen.getByRole('menuitem', { name: '详细' })) + expect(b.setTranscriptView).toHaveBeenLastCalledWith('detailed') + fireEvent.click(screen.getByRole('button', { name: '详细' })) + fireEvent.click(screen.getByRole('menuitem', { name: '完全展开' })) + expect(b.setTranscriptView).toHaveBeenLastCalledWith('expanded') + expect(screen.getByRole('button', { name: '完全展开' })).toBeDefined() }) }) diff --git a/packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts b/packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts new file mode 100644 index 0000000000..b416a1705f --- /dev/null +++ b/packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts @@ -0,0 +1,16 @@ +/** CSS activity and reduced-motion rules that jsdom does not execute. */ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const css = readFileSync(new URL('../src/TextShimmer.module.css', import.meta.url), 'utf8') + +describe('TextShimmer styles', () => { + it('animates active text and disables motion and transparent fill for reduced motion', () => { + expect(css).toMatch(/\.root\[data-text-shimmer\]\s*\{[^}]*animation: dsh-text-shimmer/s) + expect(css).toMatch(/@keyframes dsh-text-shimmer\s*\{[^}]*background-position: 0% center/s) + const reduced = /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.root\[data-text-shimmer\]\s*\{([^}]+)\}/.exec(css)?.[1] + expect(reduced).toContain('background-image: none;') + expect(reduced).toContain('-webkit-text-fill-color: currentColor;') + expect(reduced).toContain('animation: none;') + }) +}) diff --git a/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx b/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx new file mode 100644 index 0000000000..5f6efdf805 --- /dev/null +++ b/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx @@ -0,0 +1,29 @@ +// @vitest-environment jsdom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { TextShimmer } from '../src/TextShimmer.tsx' + +afterEach(cleanup) + +describe('TextShimmer', () => { + it('retains its text span across activity, text, and owner-class changes', () => { + const view = render(Run) + const span = view.getByText('Run') + expect(span.tagName).toBe('SPAN') + expect(span.dataset.textShimmer).toBe('true') + expect(span.classList.contains('title')).toBe(true) + expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('24px') + + view.rerender(End) + expect(view.getByText('End')).toBe(span) + expect(span.hasAttribute('data-text-shimmer')).toBe(false) + expect(span.classList.contains('title')).toBe(false) + expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('24px') + + view.rerender(Running) + expect(view.getByText('Running')).toBe(span) + expect(span.dataset.textShimmer).toBe('true') + expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('56px') + }) + +}) diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index d45088b225..ead382b4ac 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -132,7 +132,7 @@ describe('apply', () => { expect(presentation.dictionaries).toEqual([{ namespace: 'skill', dictionaries: { zh: { - 'row.title': 'Skill', + 'row.title': '加载技能', 'row.running': '正在加载 skill', 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', diff --git a/packages/client/ui-skill/tests/skill-row.client.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx index da55917f84..053f78b4a9 100644 --- a/packages/client/ui-skill/tests/skill-row.client.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.client.spec.tsx @@ -51,7 +51,7 @@ describe('SkillRow', () => { it('renders a compact Bash-shaped summary and discloses the exact instructions', () => { const inspect = vi.fn() const view = render() - const row = screen.getByRole('button', { name: 'Skilldsh-manage-issues' }) + const row = screen.getByRole('button', { name: '加载技能dsh-manage-issues' }) expect(row.getAttribute('aria-expanded')).toBe('false') expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('ok') expect(view.container.querySelector('[data-tool="skill"] svg')?.getAttribute('width')).toBe('14') @@ -95,7 +95,7 @@ describe('SkillRow', () => { isError: true, error: { name: 'SkillError', code: 'missing' }, }))} />) - const row = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing resource' }) + const row = screen.getByRole('button', { name: 'skill 加载失败加载技能SkillError: missing resource' }) expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('error') expect(view.container.querySelector('[data-tool="skill"] > div > span:first-child svg')).not.toBeNull() expect(view.container.querySelector('[data-tool="skill"] [data-state]')).toBeNull() @@ -128,7 +128,7 @@ describe('SkillRow', () => { isError: true, error: { name: 'SkillError', code: 'missing' }, }))} />) - const errorRow = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing' }) + const errorRow = screen.getByRole('button', { name: 'skill 加载失败加载技能SkillError: missing' }) fireEvent.click(errorRow) expect(screen.getAllByText('SkillError: missing')).toHaveLength(2) }) diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index 80615b752f..8254a70476 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -48,7 +48,7 @@ describe('Tool presentation tails', () => { ) expect(view.queryByTestId('icon')).not.toBeNull() const summary = view.getByText('s') - expect(summary.className).toContain('stoppedSummary') + expect(summary.parentElement?.className).toContain('stoppedSummary') }) it('a settled others-variant row renders the sparkle icon in the leading slot', () => { @@ -76,7 +76,7 @@ describe('Tool presentation tails', () => { } const view = render() const row = view.container.querySelector('[data-sample="bash"]')! - expect(row.textContent).toContain('Bash') + expect(row.textContent).toContain('运行命令') expect(row.textContent).toContain('Build') expect(row.getAttribute('data-clickable')).toBeNull() }) @@ -99,7 +99,7 @@ describe('Tool presentation tails', () => { const runningView = render() expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() - expect(runningView.getByText('Bash')).toBeTruthy() + expect(runningView.getByText('运行命令')).toBeTruthy() expect(runningView.getByText('List')).toBeTruthy() runningView.unmount() @@ -107,14 +107,15 @@ describe('Tool presentation tails', () => { expect(errorView.container.querySelector('[data-sample="bash"]')).not.toBeNull() expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull() expect(errorView.container.querySelector('[data-state="error"] svg')).not.toBeNull() - expect(errorView.getByText('Bash')).toBeTruthy() + expect(errorView.getByText('运行命令')).toBeTruthy() expect(errorView.container.querySelector('[class*="_errorSummary_"]')).not.toBeNull() errorView.unmount() const stoppedView = render() expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull() expect(stoppedView.container.querySelector('[data-state="stopped"] svg')).not.toBeNull() - const stoppedSummary = stoppedView.getByText('已停止') - expect(stoppedSummary.className).toContain('stoppedSummary') + const stoppedSummary = stoppedView.getByRole('button').querySelector('[class*="_stoppedSummary_"]') + expect(stoppedSummary?.textContent).toBe('已停止') + expect(stoppedView.getAllByText('已停止')).toHaveLength(2) }) }) diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index ca8e8bde20..cee488b8b6 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -236,7 +236,7 @@ describe('SearchRow keyed card', () => { it('collapses to the summary row; expanding reveals the grep card', () => { const view = render() - expect(view.getByText('Grep')).toBeTruthy() + expect(view.getByText('搜索文件内容')).toBeTruthy() expect(view.queryByText('Search')).toBeNull() // Collapsed: the card is not in the DOM until the row is expanded. expect(searchKindOf(view.container)).toBeNull() @@ -250,7 +250,7 @@ describe('SearchRow keyed card', () => { it('expands to the glob path card', () => { const view = render() - expect(view.getByText('Glob')).toBeTruthy() + expect(view.getByText('查找文件')).toBeTruthy() expect(view.queryByText('Search')).toBeNull() expect(searchKindOf(view.container)).toBeNull() toggleRow(view) diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index 6ef6cd713a..d4749831be 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -81,10 +81,10 @@ describe('tool-call-model', () => { expect(t(toolRowModel('cordis_unmount', running({ name: 'cordis_unmount', argsRaw: '{}' })).titleKey)).toBe('工具调用') }) - it('gives the pwsh shell row the bash family treatment with its own title', () => { + it('gives the pwsh shell row the bash family treatment and localized command title', () => { const m = toolRowModel('pwsh', running()) expect(m.variant).toBe('bash') - expect(t(m.titleKey)).toBe('Pwsh') + expect(t(m.titleKey)).toBe('运行命令') }) it('derives state across running/ok/error/interrupted', () => { @@ -96,7 +96,7 @@ describe('tool-call-model', () => { it('derives the bash summary from description over command', () => { const m = toolRowModel('bash', running()) - expect(t(m.titleKey)).toBe('Bash') + expect(t(m.titleKey)).toBe('运行命令') expect(m.summary).toBe('List files') expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' })).summary).toBe('pwd') }) @@ -420,7 +420,7 @@ describe('ToolRow', () => { it('an error row without an error summary keeps the args summary', () => { const view = render() const summary = view.getByText('List files') - expect(summary.className).toContain('errorSummary') + expect(summary.parentElement?.className).toContain('errorSummary') }) it('renders summarySuffix outside the ellipsized summary span, and drops it on a failure line', () => { @@ -499,7 +499,7 @@ describe('GenericToolCard', () => { it('renders the classified variant row from the frozen slice', () => { const view = render() - expect(view.getByText('Bash')).toBeTruthy() + expect(view.getByText('运行命令')).toBeTruthy() expect(view.getByText('List files')).toBeTruthy() expect(view.container.querySelector('[data-variant="bash"]')).not.toBeNull() }) @@ -556,7 +556,7 @@ describe('GenericToolCard', () => { it('passes the owner inspect callback through to the expanded row pill', () => { const inspect = vi.fn() const view = render() - fireEvent.click(view.getByRole('button', { name: /Bash/ })) + fireEvent.click(view.getByRole('button', { name: /运行命令/ })) fireEvent.click(view.getByText('查看')) expect(inspect).toHaveBeenCalledTimes(1) }) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e49ab33f28..73647081a7 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -176,7 +176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:227', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:243', }, { key: 'conversation.chat.commandview', @@ -224,7 +224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:215', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:231', }, { key: 'conversation.chat.node', @@ -293,7 +293,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:196', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:212', }, { key: 'conversation.chat.turnTail', @@ -354,7 +354,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:221', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:237', }, { key: 'conversation.composer', @@ -1126,7 +1126,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:209', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:225', }, { key: 'conversation.plan-review.actions', diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index d6188dfffa..911bf72973 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -5,7 +5,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{clock}}" - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/changed-files-turn/ui.expected.md b/snapshots/web/changed-files-turn/ui.expected.md index 197e8c92f5..f1942da607 100644 --- a/snapshots/web/changed-files-turn/ui.expected.md +++ b/snapshots/web/changed-files-turn/ui.expected.md @@ -1,14 +1,13 @@ -- button "系统提示词" - text: 不用先查看目录,直接做四件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,新建 app.local 写一行 mode=demo,最后用 bash 在 notes.txt 末尾追加一行 done。 {{clock}} - button "复制" - button "7 次工具调用" [expanded] - button "上下文注入 runtime-context" -- button "思考 The user wants four things done directly. But I need to read intro.md before editing (fs-observation-policy requires reading existing file first). The user says don't look at the directory first, but reading intro.md is needed for the edit tool. Let me just do it." +- button "思考" - button "读取 intro.md": - text: 读取 - button "intro.md" -- button "Bash List files and show notes.txt" -- button "思考 Now do all four actions. Edit intro.md, write src/util.ts, write app.local, bash append." +- button "运行命令 List files and show notes.txt" +- button "思考" - button "编辑 intro.md +1 -1": - text: 编辑 - button "intro.md" @@ -21,8 +20,8 @@ - text: 写入 - button "app.local" - text: +1 -0 -- button "Bash Append done to notes.txt" -- 'button "思考 All four done. Present the files? The user asked to create files. I should call present for the deliverables. Files: intro.md, src/util.ts, app.local, notes.txt. All are outputs. Let me present them."' +- button "运行命令 Append done to notes.txt" +- button "思考" - button "交付文件 已交付 intro.md, src/util.ts, app.local, notes.txt" - paragraph: 四件事全部完成: - list: diff --git a/snapshots/web/deepseek-messages-chat/ui.expected.md b/snapshots/web/deepseek-messages-chat/ui.expected.md index 9a790ba8ea..9704a35af6 100644 --- a/snapshots/web/deepseek-messages-chat/ui.expected.md +++ b/snapshots/web/deepseek-messages-chat/ui.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "对话" [selected] - tab "轨迹" -- button "系统提示词" - text: 只回复 MESSAGES_WEB_READY,不调用工具。 {{clock}} - button "复制" - button "已思考" diff --git a/snapshots/web/default-workspace/ui.expected.md b/snapshots/web/default-workspace/ui.expected.md index 425343834b..7988d45c0c 100644 --- a/snapshots/web/default-workspace/ui.expected.md +++ b/snapshots/web/default-workspace/ui.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "1 tool call" diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index 50bc047607..d9c09956cf 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -6,12 +6,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" - button "Thought for a while" [expanded] - button "Context injection runtime-context" -- button "Think The user wants me to reply with a single word. Let me comply." +- button "Think" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index d553156708..b5060084a4 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" - button "Thought for a while" diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index 50bc047607..d9c09956cf 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -6,12 +6,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" - button "Thought for a while" [expanded] - button "Context injection runtime-context" -- button "Think The user wants me to reply with a single word. Let me comply." +- button "Think" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index d553156708..b5060084a4 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" - button "Thought for a while" diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md index 6435e7d5bf..fc6a0cf362 100644 --- a/snapshots/web/file-upload-round/ui.expected.md +++ b/snapshots/web/file-upload-round/ui.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: poem.txt TXT 16B - button "reference-1.png, click to view original": - img "reference-1.png" diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index 0b593347b8..1bbd83f0d9 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -6,14 +6,13 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "1 tool call" [expanded] - button "Context injection runtime-context" -- button "Think The user wants me to run a simple bash command and reply with \"DONE\"." +- button "Think" - button "Bash Echo the test string" -- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 425343834b..7988d45c0c 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "1 tool call" diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index ffd5ea6805..7c9f533f8c 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -11,22 +11,21 @@ - button "Jump to turn 2" - group "Command input": /goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 - 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"' -- button "System prompt" - button "6 tool calls" [expanded] - button "Context injection goal" - button "Context injection runtime-context" -- 'button "Think The user''s goal is: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\" — which translates to: \"Do two turns, each turn output the file structure of a random package. Note that after you finish one turn, directly output the content and stop; our system will help you open another turn, and you just do something similar.\""' +- button "Think" - button "Bash List package directories deterministically" - button "View goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的" -- 'button "Think There''s a `packages` directory. Let me look inside to see what packages are available."' +- button "Think" - button "Bash List packages directory contents" -- button "Think There are 4 packages:" +- button "Think" - button "Failed Bash Randomly pick one package directory" -- 'button "Think shuf is not available on macOS. I''ll use a different approach for random selection. Let me use $RANDOM in bash or use `sort -R` or Python."' +- button "Think" - button "Bash Randomly select one package with Python" -- 'button "Think The random pick for turn 1 is `packages/context/session-reference`. Now I''ll output its file structure. Let me get the file tree."' +- button "Think" - button "Bash List all files in session-reference package" -- 'button "Think Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn."' +- button "Think" - paragraph: - strong: Turn 1 / 2 - text: — 随机选中的包: @@ -42,15 +41,14 @@ - button "Bad response" - button "Branch into a new conversation" - text: {{clock}} -- button "System prompt" - button "5 tool calls · 2 messages" [expanded] - button "Context injection goal" -- 'button "Think This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."' +- button "Think" - button "Bash Randomly select another package with Python" -- 'button "Think The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package."' +- button "Think" - button "Bash Re-roll excluding the package from turn 1" - button "Bash List all files in token-meter package" -- 'button "Think This is turn 2 of 2. I''ve output the file structure for `packages/llm/token-meter`. Both turns are done. I should output the content, stop, and then mark the goal as complete since both turns are finished."' +- button "Think" - paragraph: - strong: Turn 2 / 2 - text: — 随机选中的包: diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index 59dc395da6..2d7d6a5d24 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -11,7 +11,6 @@ - button "Jump to turn 2" - group "Command input": /goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 - 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"' -- button "System prompt" - button "6 tool calls" - paragraph: - strong: Turn 1 / 2 @@ -28,7 +27,6 @@ - button "Bad response" - button "Branch into a new conversation" - text: {{clock}} -- button "System prompt" - button "5 tool calls · 2 messages" - paragraph: 两个 turn 都完成了 ✅ - paragraph: diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index 8e1c4db5cc..8b6f7eae5f 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -6,12 +6,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" - button "Thought for a while" [expanded] - button "Context injection runtime-context" -- button "Think The user wants me to reply with a single word. Let me comply." +- button "Think" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index c4bf010378..a760c82371 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" - button "Thought for a while" diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md index 1cbe13209e..3813c500b7 100644 --- a/snapshots/web/live-interactions/cancel-expanded.expected.md +++ b/snapshots/web/live-interactions/cancel-expanded.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Thought for a while" [expanded] diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index b30b140798..8b64580291 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Thought for a while" diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index aa423c09dc..4a5dd961fe 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 67b4bf71a4..4635768121 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index 0f9f0503c1..2433cb613f 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index d201eba408..5ea5441f23 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -6,14 +6,13 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Thought for a while" [expanded] - button "Context injection runtime-context" - group: - status: Retried model request (1/5) · {{duration}} -- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls." +- button "Think" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 435a2aac62..a122990338 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Thought for a while" diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index 6518e42770..92ddaed171 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index df127b9b31..f4776040f5 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -9,11 +9,10 @@ - button "Jump to turn 1" - button "Jump to turn 2" - button "Jump to turn 3" -- button "System prompt" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" - tooltip "Copy" -- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel." +- button "Think" - paragraph: I will read both files before answering. - button "Copy" - button "Good response" @@ -26,7 +25,6 @@ - button "Read b.txt": - text: Read - button "b.txt" -- button "System prompt" - text: Now give the final answer. {{clock}} - button "Copy" - paragraph: DONE diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index 3567494649..e7e695c43c 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the bash tool to run exactly: printf 'MINIMAL_BASH_CARD_OK\\n'. Then reply exactly MINIMAL_PRESET_REQUEST_OK and stop. {{clock}}" - button "Copy" - button "1 tool call" [expanded] diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 71a77be2f5..602b7120f8 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -5,20 +5,17 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: plan Plan mode on. Use /plan off to leave. -- button "System prompt" -- text: "Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "1 tool call · 1 message" [expanded] - button "Context injection runtime-context" -- 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."' +- button "Think" - paragraph: - text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with - code: exit_plan_mode - text: . - 'button "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"' -- button "System prompt" -- 'button "Think The plan was approved. The user''s last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."' +- button "Think" - paragraph: DONE - 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - button "Copy" diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index 94ba2c08b5..fe52ec9268 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -5,12 +5,9 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: plan Plan mode on. Use /plan off to leave. -- button "System prompt" -- text: "Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "1 tool call · 1 message" -- button "System prompt" - paragraph: DONE - 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - button "Copy" diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index c867d85c45..5238b2a252 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -1,15 +1,14 @@ -- button "系统提示词" - text: 简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg {{clock}} - button "复制" - button "2 次工具调用 · 1 条消息" [expanded] - button "上下文注入 runtime-context" -- button "思考 The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses." +- button "思考" - paragraph: 我来创建一个冯诺依曼架构的 SVG 示意图。 - button "写入 von-neumann.svg +83 -0": - text: 写入 - button "von-neumann.svg" - text: +83 -0 -- button "思考 The file is created. Let me present it as a deliverable." +- button "思考" - button "交付文件 已交付 von-neumann.svg" - paragraph: - text: 已创建 diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index ad3780520c..02fb01a3f8 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -6,18 +6,17 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. Use bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`. Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program. Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph. {{clock}}" - button "Copy" - button "1 tool call" [expanded] - button "Context injection runtime-context" -- button "Think The user wants one run_code program that:" +- button "Think" - text: Failed - 'button "Code Error: code run failed (exception): AFTER_PRESENT"' - button "Present files Delivery failed missing.txt" - button "Bash Write DELIVERED_REPORT and DELIVERED_NOTE to files" - button "Present files Delivered report.txt, 说明.txt" -- button "Think The program ran as intended:" +- button "Think" - paragraph: - text: "The single program ran exactly as ordered:" - code: present diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index c62860982b..9e68c8604a 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -6,12 +6,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy" - button "1 tool call" [expanded] - button "Context injection runtime-context" -- 'button "Think The user wants me to write a single `run_code` program that:"' +- button "Think" - button "Code Run bash echo and catch missing file read" - button "Bash Echo CODE_ROUND_OK" [expanded] - text: Done workspace echo CODE_ROUND_OK @@ -20,7 +19,7 @@ - button "Inspect" - text: Failed - 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"' -- button "Think The program ran successfully. Let me now reply DONE as instructed." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index 1e5e5a39cb..488e8b8029 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -6,17 +6,16 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "1 tool call" [expanded] - button "Context injection runtime-context" -- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that." +- button "Think" - button "Ask question 1/1 answered" [expanded] - term: Which color do you prefer? - definition: Blue Include accessibility notes - button "Inspect" -- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index dd494fb614..fa8b822467 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "1 tool call" diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index 15af0b5cc7..7238215d90 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -5,11 +5,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "Context injection runtime-context" -- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that." +- button "Think" - button "Ask question cancelled" [expanded] - paragraph: This question set was cancelled before answers were submitted. - list: diff --git a/snapshots/web/queue-actions/collapsed.expected.md b/snapshots/web/queue-actions/collapsed.expected.md index 765e045f17..bcb7ecf753 100644 --- a/snapshots/web/queue-actions/collapsed.expected.md +++ b/snapshots/web/queue-actions/collapsed.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index 96eff6022d..13925614bf 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/queue-actions/layout.expected.md b/snapshots/web/queue-actions/layout.expected.md index 0d5d2ce2b1..4ca4469eed 100644 --- a/snapshots/web/queue-actions/layout.expected.md +++ b/snapshots/web/queue-actions/layout.expected.md @@ -8,7 +8,6 @@ - tab "Trajectory" - group "Command input": /goal Keep the composer context panels aligned - 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"' -- button "System prompt" - button "Context injection goal" - button "Context injection runtime-context" - paragraph: partial diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md index fe66236496..e95a3f6fb3 100644 --- a/snapshots/web/queue-actions/preserved-expanded.expected.md +++ b/snapshots/web/queue-actions/preserved-expanded.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Thought for a while" [expanded] diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index 66052f1522..3f186da354 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Thought for a while" diff --git a/snapshots/web/queue-actions/sending.expected.md b/snapshots/web/queue-actions/sending.expected.md index 07dd357aba..02e60e5f77 100644 --- a/snapshots/web/queue-actions/sending.expected.md +++ b/snapshots/web/queue-actions/sending.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index 9e43a41140..fcc1477d43 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index 289963776b..a77a5d3547 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -10,7 +10,6 @@ - button "Jump to turn 1" - button "Jump to turn 2" - button "Jump to turn 3" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Thought for a while" diff --git a/snapshots/web/queued-image/queued.expected.md b/snapshots/web/queued-image/queued.expected.md index e28d8df980..95b98d26cd 100644 --- a/snapshots/web/queued-image/queued.expected.md +++ b/snapshots/web/queued-image/queued.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 727368f383..bcebaccbe0 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -8,18 +8,17 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- button "System prompt" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "2 tool calls" [expanded] -- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel." +- button "Think" - button "Read a.txt": - text: Read - button "a.txt" - button "Read b.txt": - text: Read - button "b.txt" -- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" @@ -28,7 +27,6 @@ - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md" -- text: permission preset read-only - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Read Only"': Read Only diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 2b26bde0d6..cb0bde06e7 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -8,18 +8,17 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- button "System prompt" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "2 tool calls" [expanded] -- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel." +- button "Think" - button "Read a.txt": - text: Read - button "a.txt" - button "Read b.txt": - text: Read - button "b.txt" -- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" @@ -28,7 +27,6 @@ - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md" -- text: permission preset read-only - 'button "feedback Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." [expanded]' - text: "Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index bd687552fd..4f6ac15000 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -8,18 +8,17 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- button "System prompt" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "2 tool calls" [expanded] -- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel." +- button "Think" - button "Read a.txt": - text: Read - button "a.txt" - button "Read b.txt": - text: Read - button "b.txt" -- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 0a26ac4c88..4f703e11d9 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -8,7 +8,6 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- button "System prompt" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "2 tool calls" diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index aa014bd9e0..816b680d6e 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -5,17 +5,16 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Load the editing-cordis-compositions skill with the skill tool, then reply DONE. {{date}} {{clock}} - button "Copy" - button "1 tool call" [expanded] - button "Context injection runtime-context" - button "Context injection skill-catalog" -- button "Think Load the requested skill." +- button "Think" - button "Skill editing-cordis-compositions" [expanded] - region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. ## Off-limits **Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `ptc`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. ## Decide the plane first Two planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared. **Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. **Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. For deployment overrides, read `${DSH_HOME:-$HOME/.dsh}/profiles//cordis.patch.yml`, `${DSH_HOME:-$HOME/.dsh}/cordis.patch.yml`, and any launch `--patch` files for the `agent-presets` row's `roots` and `includeUserRoot`. Obtain the active profile and extra patch paths from the user when they are not in the task context; do not guess a different profile. ## Authoring a preset Use shell and file tools to locate the installed `@deepseek-ai/dsh-agent-presets` package under the active profile's `node_modules` or the deployment installation. Its `presets//` directory contains each shipped preset. If those files are unavailable, ask the user to copy the preset through the Web preset picker and provide the copied directory; runtime API inspection does not execute Remote methods. Copy the complete source directory, including skills and assets, into a new `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` directory (or the explicitly configured writable root). Refuse an existing destination. Set `name` and `description` in `preset.yml` and remove the copied roster `order`. Never overwrite the installed source. Edit the copy's `agent.cordis.yml` with the normal file tools. Writes outside the workspace follow the active filesystem approval policy. For profile-wide capabilities, author a workspace bundle and install it with `plugin_manager`; load `cordis-plugin-development` for packaging guidance. ## Service isolation A preset row that publishes a service needs an `isolate` realm containing both the provider and all its consumers. A tool that only consumes a host service remains outside that realm. Copy the shipped preset's existing groups rather than introducing service instances into the process-global realm. ```yaml - id: delegation name: cordis:group group: true isolate: workflowEngine: true config: - id: workflow-ptc name: '@deepseek-ai/dsh-workflow-ptc' config: provider: spawn - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' ``` ## Verify a preset Check the edited YAML and referenced local files with file tools. Ask the user to select the new preset in the Web picker and start a session; this authoring agent cannot invoke the preset Remote API or start a Web session through inspection. Once the user provides a running session or browser control, inspect the visible tools and any activation diagnostic. File validation alone does not verify imports, service dependencies, or isolation. Report which checks ran and leave activation unverified until that session check succeeds. ## Native product subagents Codex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers: ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` Each Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start. Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: ```yaml - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: codex toolName: subagent_codex backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: claude-code toolName: subagent_claude_code backgroundMode: one-shot maxDepth: provider-managed ``` For additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings. The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings. ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. " - button "Inspect" -- button "Think The skill is loaded." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index 18b8b2bda0..f8b2fc5561 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index 558a2cbccd..037a6f71da 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" - button "1 tool call" [expanded] diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 9d5e8bbdc5..b1cb14742e 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" - button "1 tool call" diff --git a/snapshots/web/subagent-conversation/sidebar-chat.expected.md b/snapshots/web/subagent-conversation/sidebar-chat.expected.md index 588d7bc778..2563dc0d92 100644 --- a/snapshots/web/subagent-conversation/sidebar-chat.expected.md +++ b/snapshots/web/subagent-conversation/sidebar-chat.expected.md @@ -1,4 +1,3 @@ -- button "System prompt" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" - button "Thought for a while" diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 958a6e5041..c4a77f1285 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -13,23 +13,20 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- button "System prompt" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" - button "Thought for a while" [expanded] - button "Context injection runtime-context" -- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls." +- button "Think" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" - button "Bad response" - button "Branch into a new conversation" -- text: {{clock}} -- button "System prompt" -- text: Now give the same explanation to a human reader. {{clock}} +- text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy" - button "Thought for a while" [expanded] -- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls." +- button "Think" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index 62af856938..5e1ccfc3fa 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -13,7 +13,6 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- button "System prompt" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" - button "Thought for a while" @@ -22,9 +21,7 @@ - button "Good response" - button "Bad response" - button "Branch into a new conversation" -- text: {{clock}} -- button "System prompt" -- text: Now give the same explanation to a human reader. {{clock}} +- text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy" - button "Thought for a while" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index 32ef6d7bd7..7c67669374 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -9,7 +9,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index 88cbf864a8..52872ef7db 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - button "1 tool call · 1 message" diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index 97ce445aef..cd8c64c32e 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -6,12 +6,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - button "1 tool call · 1 message" [expanded] - button "Context injection runtime-context" -- button "Think The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop." +- button "Think" - paragraph: Reading the workspace now. - button "Bash Print alpha to stdout" - paragraph: DONE diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index 0aad69b556..ce077f8d64 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -6,12 +6,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - tooltip "Copy" - button "Context injection runtime-context" -- button "Think The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop." +- button "Think" - paragraph: Reading the workspace now. - button "Bash Print alpha to stdout" - paragraph: partial diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index 33ff59bd2a..df291fa049 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - button "1 tool call · 1 message" diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index ea6ffa712d..19a3319879 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - button "1 tool call · 1 message" diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index a843fffa55..b769848c9f 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use web_search once with queries ["DeepSeek Harness snapshot search","DeepSeek Harness multi-query search"]. Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy" - button "1 tool call" [expanded] diff --git a/snapshots/web/workflow-run/ui.expected.md b/snapshots/web/workflow-run/ui.expected.md index da34c06fd8..cd0ee5f743 100644 --- a/snapshots/web/workflow-run/ui.expected.md +++ b/snapshots/web/workflow-run/ui.expected.md @@ -1,12 +1,11 @@ -- button "System prompt" - text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}" - button "Copy" - button "1 tool call" [expanded] - button "Context injection runtime-context" -- button "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:" +- button "Think" - button "Run workflow snapshot-flow" - button "snapshot-flow 1 member Completed" -- button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop." +- button "Think" - paragraph: WORKFLOW_DONE - button "Copy" - button "Good response" From c7e6e36dd11c2ba1c750b42318608efdb367b7cb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 20 Sep 2026 23:22:13 +0800 Subject: [PATCH 237/284] fix(web): keep a superseded creation notice silent and add its e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the refused-creation notice: - `openWorkspace` raises `createFailed` only while its navigation is current, so a refusal that lands after a later navigation or owner disposal stays silent — the gate `initializeDefaultWorkspace` already uses and what the README states; two service cases pin it. - `RowActionToast` switches on the closed notice union and ends in `assertNever`, so an unhandled kind fails to compile instead of rendering another notice's copy. - `apps/web/tests/new-session-refused.e2e.ts` drives the composed Web server with a user preset whose row cannot resolve and compares the notice's ARIA golden, beside the default-Workspace failure scenario. - `@throws` on `openWorkspace` covers every failure (catalog regenerated); `RowToast.createFailed` records why the Host code stays in the copy; the README states that other failures show their own message. --- .../new-session-refused/notice.expected.md | 1 + apps/web/tests/new-session-refused.e2e.ts | 70 +++++++++++++++++++ apps/web/tsconfig.json | 1 + packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../ui-workspace/src/client/contract/slots.ts | 6 +- .../ui-workspace/src/client/navigation.ts | 14 ++-- .../client/session-actions/RowActionToast.tsx | 25 +++++-- .../tests/workspaces-service.client.spec.ts | 12 ++++ .../src/client/api-catalog.ts | 2 +- tsconfig.host.json | 1 + 12 files changed, 122 insertions(+), 18 deletions(-) create mode 100644 apps/web/tests/expected/new-session-refused/notice.expected.md create mode 100644 apps/web/tests/new-session-refused.e2e.ts diff --git a/apps/web/tests/expected/new-session-refused/notice.expected.md b/apps/web/tests/expected/new-session-refused/notice.expected.md new file mode 100644 index 0000000000..52e7072f0f --- /dev/null +++ b/apps/web/tests/expected/new-session-refused/notice.expected.md @@ -0,0 +1 @@ +- alert: "新建会话失败:agent-preset/invalid: agent-presets: preset \"renamed-plugin\" failed to mount: row \"ghost\" names a plugin that cannot be resolved: @deepseek-ai/dsh-no-such-plugin" diff --git a/apps/web/tests/new-session-refused.e2e.ts b/apps/web/tests/new-session-refused.e2e.ts new file mode 100644 index 0000000000..80dcb8e990 --- /dev/null +++ b/apps/web/tests/new-session-refused.e2e.ts @@ -0,0 +1,70 @@ +/** An explicit New Session the Host refuses reports through the Workspace notice in the shipped Web composition. */ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { describe, expect, it, onTestFinished } from 'vitest' +import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const NOTICE_EXPECTED = fileURLToPath(new URL('./expected/new-session-refused/notice.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +/** The default preset of this lane: its one row names a package the Host cannot resolve, as a preset copied before a plugin rename does. */ +const PRESET_ID = 'renamed-plugin' + +/** + * Seed a user preset root holding one preset the roster reads as broken. + * + * Discovery reports the unresolvable row before any mount, so the refusal + * carries no temp path and the notice text is stable across runs. + * @param root - the lane-owned preset root. + */ +async function seedBrokenPreset(root: string): Promise { + const directory = join(root, PRESET_ID) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'agent.cordis.yml'), '- id: ghost\n name: \'@deepseek-ai/dsh-no-such-plugin\'\n') + await writeFile(join(directory, 'preset.yml'), 'name: Renamed plugin\ndescription: Names a plugin that no longer resolves.\n') +} + +describe.skipIf(MODE === 'record')('web e2e: refused New Session', () => { + it('shows the Host refusal as a notice and leaves the Workspace without a Session', async () => { + const presetRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-broken-preset-'))) + onTestFinished(() => rm(presetRoot, { recursive: true, force: true })) + await seedBrokenPreset(presetRoot) + const scaffold = await launchWebScaffold({ + firstUse: true, + agentPresets: { roots: [{ path: presetRoot, trust: 'user' }], default: PRESET_ID }, + }) + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + try { + const page = await browser.newPage({ locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) + const tripwire = watchConsole(page) + try { + await page.goto(scaffold.authenticatedUrl) + // Startup prepares the default Workspace, and its own blank-Session + // attempt is refused without a notice: the hero stays on the picker. + await page.getByRole('button', { name: '选择工作区', exact: true }).waitFor() + await expect.poll(() => scaffold.ctx.workspaceRegistry.list().length).toBe(1) + expect(scaffold.ctx.sessions.list()).toEqual([]) + expect(await page.getByRole('alert').count()).toBe(0) + + await page.getByRole('button', { name: '新建会话', exact: true }).first().click() + const notice = page.getByRole('alert').filter({ hasText: '新建会话失败' }) + await notice.waitFor() + await compareOrRefreshGolden(NOTICE_EXPECTED, await notice.ariaSnapshot(), MODE) + expect(scaffold.ctx.sessions.list()).toEqual([]) + expect(scaffold.ctx.workspaceRegistry.list()).toHaveLength(1) + expect(await page.locator('[data-composer-input][contenteditable="true"]').count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'web-e2e-new-session-refused') + throw error + } + } finally { + await browser.close() + } + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8560d4f99e..4e8faba00b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -64,6 +64,7 @@ "tests/models-settings-recovery.e2e.ts", "tests/default-model.e2e.ts", "tests/default-workspace.e2e.ts", + "tests/new-session-refused.e2e.ts", "tests/github-ready-review.e2e.ts", "tests/streaming-fence-highlight.e2e.ts", "tests/declared-reasoning.e2e.ts", diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 6c1b132770..3a35e45163 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -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/ui-workspace/README.md -README.md: d4396fd07a05791285f44a01a6cfb17aeac1ebe9 -README.zh.md: 31dca87e21c620271f5fe7307f07f9f437247742 +README.md: 3fd9308f0f47a561a08f831f7d923aad2863a25b +README.zh.md: 97bcac6456bb68606a4747984c24f5f618fa9bb4 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index d4396fd07a..3fd9308f0f 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -69,7 +69,7 @@ The value is intentionally best effort for cold Sessions. An identity-matching u Navigation and startup restoration obtain the selected Session's projections from its `follow`; neither issues a separate projection refresh for that Session or its parent. -New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request: an explicit New Session or hero Workspace pick shows the failure as a transient notice quoting the Host's code and message (for example a preset that fails to mount), while startup restoration reports only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. +New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request: an explicit New Session or hero Workspace pick shows the failure as a transient notice quoting the Host's code and message (for example a preset that fails to mount) or, for a failure that is not a Host refusal, that failure's own message; a request a later navigation or owner disposal superseded raises no notice, and startup restoration reports only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. Once both Workspace and Session startup baselines are ready, an empty installation calls `workspaces.initializeDefault` and creates or reuses its blank Session. The composer becomes editable when that Session is selected; no message is submitted automatically. Later navigation or owner disposal prevents startup from selecting its result. Ineligible first use leaves the folder picker available without an error. Default Workspace creation failure shows a transient notice directing the user to Choose workspace, and is not retried until the next startup. Session creation failures use the ordinary restoration error handling. The registered Workspace remains available if Session creation or later submission fails. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 31dca87e21..97bcac6456 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -69,7 +69,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** 导航和启动恢复通过所选 Session 的 `follow` 获取投影,不会另行刷新该 Session 或其父会话的投影。 -新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求:显式新建会话或在 hero 中选择工作区时,失败以短暂提示展示,引用 Host 的错误码和消息(例如 preset 挂载失败);启动恢复仍只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 +新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求:显式新建会话或在 hero 中选择工作区时,失败以短暂提示展示,引用 Host 的错误码和消息(例如 preset 挂载失败),非 Host 拒绝的失败则显示其自身消息;已被后续导航或 owner 销毁取代的请求不弹提示,启动恢复仍只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `workspaces.initializeDefault`,创建或复用其空白 Session。选中该 Session 后输入框才可编辑,不会自动提交消息。后续导航或所属上下文销毁会阻止启动流程选中其结果。不符合首次使用条件时仍可选择文件夹,不显示错误。默认工作区创建失败时显示短暂提示,引导用户通过“选择工作区”选择文件夹,直到下次启动才重试。Session 创建失败沿用普通的恢复错误处理。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。 diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 0bd651602a..c37fa75e73 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -245,7 +245,11 @@ export type RowToast = | { kind: 'unpinFailed' } | { kind: 'archivedNotOpenable' } | { kind: 'defaultWorkspaceFailed' } - /** An explicit New Session request the Host refused; `message` is the failure in the Host's own words, untranslated. */ + /** + * An explicit New Session request that failed. `message` is untranslated: + * a Host refusal as `code: message` — the stable code stays in the copy so + * a report can be searched by it — and any other failure's own message. + */ | { kind: 'createFailed'; message: string } /** The notice on display; `seq` keys remounts so a repeated notice restarts its hold. */ diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 4a05f3fc62..eefaab86b5 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -39,7 +39,8 @@ export interface UiWorkspace { * @param workspaceId - target Workspace. * @param beforeOpen - optional synchronous preparation for the selected Session, skipped after supersession. * @returns completion; a superseded request may create a Session but does not open it. - * @throws when the Session cannot be created; the failure is also shown through the Workspace notice. + * @throws on failure; a refused creation is also shown through the Workspace + * notice unless a later navigation or disposal superseded the request. */ openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise /** @@ -205,9 +206,9 @@ class UiWorkspaceService extends Service implements UiWorkspace { try { sessionId = await this.connectWorkspace(workspaceId) } catch (error: unknown) { - // The user asked for this Session, so the refusal is theirs to read; - // startup restoration goes through connectWorkspace directly and stays quiet. - this.notify({ kind: 'createFailed', message: creationFailureMessage(error) }) + // Reported here, not in connectWorkspace: startup restoration calls that + // directly and stays console-only. + if (!navigation.aborted) this.notify({ kind: 'createFailed', message: creationFailureMessage(error) }) throw error } if (navigation.aborted) return @@ -410,8 +411,9 @@ class UiWorkspaceService extends Service implements UiWorkspace { } /** - * The Session Controller's creation failure `error` is, or undefined. Client - * plugin bundles do not share error-class identity, so the name decides. + * `error` as the Session Controller's creation failure, or undefined when it + * is not one. Client plugin bundles do not share error-class identity, so the + * name decides. */ function sessionCreateErrorOf(error: unknown): SessionCreateError | undefined { return error instanceof Error && error.name === 'SessionCreateError' ? error as SessionCreateError : undefined diff --git a/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx b/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx index 7833409ba8..cbc7076dc7 100644 --- a/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx +++ b/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx @@ -3,7 +3,8 @@ * One notice is visible at a time; a parent rerender does not extend its hold. */ import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' -import type { RowToastProps } from '../contract/slots.ts' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type { RowToastProps, RowToastState } from '../contract/slots.ts' /** * Hold for the notices that take longer to read than a one-line warning: the @@ -50,16 +51,28 @@ export function RowActionToast({ useToast, dismissToast, undoArchive, showArchiv /> ) } - const text = toast.kind === 'pinFailed' - ? t('toast.pinFailed') - : toast.kind === 'unpinFailed' ? t('toast.unpinFailed') - : toast.kind === 'defaultWorkspaceFailed' ? t('defaultWorkspace.failed') : t('toast.archivedNotOpenable') return ( } onDone={dismissToast} /> ) } + +/** The copy of one plain warning, keyed by the notice kind the union closes over. */ +function plainNoticeText( + toast: Exclude, + t: RowToastProps['t'], +): string { + switch (toast.kind) { + case 'pinFailed': return t('toast.pinFailed') + case 'unpinFailed': return t('toast.unpinFailed') + case 'defaultWorkspaceFailed': return t('defaultWorkspace.failed') + case 'archivedNotOpenable': return t('toast.archivedNotOpenable') + /* v8 ignore next 2 -- closed-union backstop; only reached if a notice kind is forged */ + default: + return assertNever(toast) + } +} diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 10fe3fdb65..fd8e77e237 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -490,6 +490,18 @@ describe('UiWorkspaceService', () => { } }) + it.each(['disposal', 'a later navigation'] as const)('keeps a creation refused after %s silent', async (kind) => { + const b = bench({ workspaces: workspaceState([workspace('a')]) }) + const created = Promise.withResolvers() + b.sessions.create.mockReturnValueOnce(created.promise) + const pending = b.uiWorkspace.openWorkspace(wid('a')) + if (kind === 'disposal') await b.ctx.fiber.dispose() + else b.uiWorkspace.openSession(sid('elsewhere')) + created.reject(new Error('late refusal')) + await expect(pending).rejects.toThrow('late refusal') + expect(b.notify).not.toHaveBeenCalled() + }) + it('ignores a rejected startup selection and stale catalog callbacks after disposal', async () => { const created = Promise.withResolvers() const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index fe5358eef9..6fe1e49f41 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -345,7 +345,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Connect a Workspace and open its Session unless a later navigation supersedes it.', parameters: [{ name: 'workspaceId', description: 'target Workspace.' }, { name: 'beforeOpen', description: 'optional synchronous preparation for the selected Session, skipped after supersession.' }], returns: 'completion; a superseded request may create a Session but does not open it.', - throws: ['when the Session cannot be created; the failure is also shown through the Workspace notice.'], + throws: ['on failure; a refused creation is also shown through the Workspace notice unless a later navigation or disposal superseded the request.'], }, { signature: 'forkSession(sessionId: SessionId): Promise', diff --git a/tsconfig.host.json b/tsconfig.host.json index 5285dffbe3..e15bf83b24 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -65,6 +65,7 @@ "apps/web/tests/workspace-new-session-folding.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/default-workspace.e2e.ts", + "apps/web/tests/new-session-refused.e2e.ts", "apps/web/tests/workspace-recency.e2e.ts", "apps/web/tests/session-unarchive.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", From c3c3d95608ebe26a6cfa8acad4f3197515e00ddd Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 23:36:20 +0800 Subject: [PATCH 238/284] test(web): align preview and fence checks with chat folding --- apps/web/tests/preview-boot.e2e.ts | 9 +++++++-- .../streaming-fence-highlight/mid-stream.expected.md | 1 - 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index bde54fe69e..270f19a181 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -40,7 +40,7 @@ import { buildVfsExampleFiles, } from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts' import { captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts' -import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts' const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url)) @@ -418,12 +418,17 @@ async function bootPreview(origin: string, browser: Browser): Promise { await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 }) expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0) + // Complete Turns can fold while earlier history is still unloaded. + const readTool = page.locator('[data-chat-call-id="preview-read"]') + await readTool.waitFor({ state: 'attached' }) + expect(await readTool.isVisible()).toBe(false) + await expandOwningTurnProcess(page, readTool) await page.getByRole('button', { name: 'PREVIEW.md', exact: true }).waitFor() await page.getByRole('button', { name: 'src/preview.ts', exact: true }).waitFor() await page.getByText('Update to-do list', { exact: true }).waitFor() await page.getByText('Error: ENOENT: no such file, open missing.txt', { exact: true }).waitFor() - const subagents = page.getByRole('button', { name: '2 subagents' }) + const subagents = page.getByRole('button', { name: '2 subagents', exact: true }) await subagents.waitFor({ timeout: 15_000 }) await subagents.hover() const catalog = page.getByRole('tree', { name: 'Subagent sessions' }) diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index 27e4c283ae..19c2becc76 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Stream one TypeScript fence for the highlighting snapshot. {{clock}} - button "Copy" - button "Context injection runtime-context" From 3f7016a4226777b2898eb1919f1527ab9915bbcd Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 21 Sep 2026 00:04:06 +0800 Subject: [PATCH 239/284] feat(config): add volatile schemas and watcher-driven updates (#4579) * feat(cordis): add volatile config references without remounting * refactor(cordis): use internal prefix for volatile updates * fix(schemastery): preserve metadata-free serialized schemas * refactor(config): move volatile updates into an opt-in Loader plugin * refactor(config): reuse Loader context patch for volatile updates * refactor(config): let the volatile watcher commit equal candidates * refactor(config): separate comparison from volatile reconciliation * refactor(loader): extract pure entry option diff * refactor(loader): compare volatile config through schema metadata * refactor(loader): make volatile comparison policy explicit * refactor: name config reload and volatile HMR events * refactor(loader): compare volatile config in one schema walk; declare hmr/volatile-update in volatile-hmr Replace the mask/project pair in vendor/loader/src/config/diff.ts with one recursive schema-guided comparison. Absent objects now compare as their declared schema default at every object node, not only under volatile ancestors; both inputs resolve to the same validated config, so the one changed assertion (undefined vs {} under a non-volatile object schema) becomes unchanged. The isJsExpr special case is dropped: expression keys fall through to raw comparison. Move the hmr/volatile-update Events declaration from vendored Cordis core to its producer, packages/boot/volatile-hmr, matching how dsh-hmr declares hmr/change and hmr/reload. Vendored cordis events.ts returns to upstream content; the inherited Cordis API catalog and the event matrix are regenerated, and the tutorial example imports the package types. * fix(cosmokit): compare sparse array slots in deepEqual; extend volatile diff coverage Array.prototype.every skipped holes, so deepEqual(Array(1), ['new']) was true in one direction. Compare every index with holes read as undefined. config/diff.ts treats expression objects as opaque values even when a schema field shares the marker name, and looks up field schemas with Object.hasOwn so unknown keys named after Object prototype members compare raw. Tests cover absent-object defaults, sparse arrays in both directions, prototype-named keys, metadata-free serialized schema nodes, and a runtime remount check in volatile-hmr. READMEs and the architecture note describe the object-default rule. * test(schemastery): build serialized schema fixtures without unknown casts verify-no-unknown-casts rejects the two as-unknown assertions in scripts/volatile-config.spec.ts. Parse the serialized nodes from JSON text instead, matching scripts/loader-config-diff.spec.ts. * docs(volatile-hmr): drop the scope-scan tag from hmr/volatile-update verify-scoped-events rejects @dshScopeScan on an event that carries no scoped keys. Catalogs and the event matrix are regenerated. * docs: regenerate module graph for the volatile-hmr dependency edge verify-module-graph reports the merged docs stale: volatile-hmr now lists its app-boot dependency. * test(volatile-hmr): watch the real YAML through an exact config watch The Windows coverage lane lost the whole spec: its vitest worker exited without a report once this test started a chokidar directory watch (root: ['.']), the only real directory watch in the test suite. Register the include file through hmr.watchConfig instead, the pattern the HMR package's own real-file tests run on Windows, and emit loader/config-reload from the refresh callback as the root-watch path does. packages/boot/hmr/tests/coordination.spec.ts already covers that root-watch emission with a mocked watcher; volatile-hmr and hmr sources stay at 100% coverage. * test(volatile-hmr): pass the Include path as a file URL On Windows the plain absolute path made the include subtree fail to load, so the profile-patch test found no consumer entry. Other Include fixtures in this spec already pass file URLs. * refactor(loader): commit volatile config in Entry.update and remove volatile-hmr loader/config-reload was emitted from two places (app-boot profile reconciliation and the HMR root watch) and by hand in tests, because a separate plugin needed a "configuration applied" signal to run its own pass. Every update path already funnels through Entry.update, which is where the Loader decides to skip a remount for a volatile-only change. Entry._commitVolatile now parses the candidate through the fiber's internal/config hook and schema, commits the new values into the running references when the ordinary effective values still match, and emits loader/volatile-update to the owning fiber. A changed ordinary effective value falls back to the ordinary remount instead of leaving the instance stale; an invalid candidate is logged and keeps the running references, with the raw config retained for the next activation. Forced group updates take the same path. Remove packages/boot/volatile-hmr, its event, the app-boot loader/config-reload declaration and emit, and the HMR emission. Loader runtime and real-file tests move to scripts/loader-volatile-update.spec.ts. Docs, catalogs, the event matrix, module graph and the architecture note follow. * test: share the volatile schema fixture between the schema and Loader specs The duplication gate flagged the identical Config fixture in scripts/volatile-config.spec.ts and scripts/loader-volatile-update.spec.ts. * refactor(loader): reduce config/diff.ts to equalExceptVolatile Entry.update now computes the strict raw option changes itself and only asks diff.ts whether two raw configs differ at most in volatile fields. The ignoreVolatile flag, the options object and the "config is the sole change" rule leave diff.ts; they were the Loader's decision and now sit next to the commit they gate. The raw-config deepEqual in the volatile branch is gone because the strict change list already answers it. * refactor(loader): type the volatile comparison against Schemastery's Schema config/diff.ts narrows the plugin's Standard Schema to Schemastery's own Schema type instead of a hand-written structural interface, which makes vendor/loader a type-only dependent of @deepseek-ai/schemastery. The rescope codemod treats the vendor identifier comparison like schemastery's own source and renames the import through an exact edit. * fix(loader): address review findings on the volatile commit path Entry.update only replaces the fiber's pending raw config for a volatile-only change, so a noSave direct update survives a no-op entry update. _commitVolatile logs at debug level when ordinary effective values changed and the ordinary update applies, dispatches loader/volatile-update with a Context receiver like Cordis' own filtered emits, and isolates a throwing listener. Schemastery reports unsupported volatile values as ValidationError with their path and validates a lazy inner once when it is built. The inherited-event catalog gains internal/config and corrected source lines; the Loader spec imports dsh-hmr by package name and covers the new behaviors. Docs record that class-instance ordinary fields always take the remount path and that .default() accepts the raw input type. --- ...09-18-volatile-config-references.i18n.yaml | 6 + .../2026-09-18-volatile-config-references.md | 33 + ...026-09-18-volatile-config-references.zh.md | 33 + docs/cordis-api/inherited.md | 28 +- docs/cordis-tutorial/05-config.i18n.yaml | 4 +- docs/cordis-tutorial/05-config.md | 27 + docs/cordis-tutorial/05-config.zh.md | 27 + packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/hmr/README.i18n.yaml | 4 +- packages/boot/hmr/README.md | 2 +- packages/boot/hmr/README.zh.md | 2 +- packages/boot/hmr/tests/modules.spec.ts | 26 + pnpm-lock.yaml | 3 + scripts/gen-cordis-catalog.ts | 28 +- scripts/loader-config-diff.spec.ts | 109 +++ scripts/loader-volatile-update.spec.ts | 634 ++++++++++++++++++ scripts/rescope-vendor.ts | 9 + scripts/volatile-config.fixture.ts | 11 + scripts/volatile-config.spec.ts | 151 +++++ vendor/README.md | 2 + vendor/cordis/README.md | 4 + vendor/cordis/src/index.ts | 2 + vendor/cosmokit/README.md | 6 + vendor/cosmokit/src/index.ts | 2 + vendor/cosmokit/src/types.ts | 70 +- vendor/cosmokit/src/volatile.ts | 86 +++ vendor/loader/README.md | 4 + vendor/loader/package.json | 3 +- vendor/loader/src/config/diff.ts | 45 ++ vendor/loader/src/config/entry.ts | 60 +- vendor/loader/src/index.ts | 14 +- vendor/loader/tsconfig.json | 3 + vendor/schemastery/README.md | 4 + vendor/schemastery/src/index.ts | 126 +++- 36 files changed, 1476 insertions(+), 100 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-18-volatile-config-references.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-18-volatile-config-references.md create mode 100644 .agents/notes/implemented/architecture/2026-09-18-volatile-config-references.zh.md create mode 100644 scripts/loader-config-diff.spec.ts create mode 100644 scripts/loader-volatile-update.spec.ts create mode 100644 scripts/volatile-config.fixture.ts create mode 100644 scripts/volatile-config.spec.ts create mode 100644 vendor/cosmokit/src/volatile.ts create mode 100644 vendor/loader/src/config/diff.ts diff --git a/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.i18n.yaml new file mode 100644 index 0000000000..ef7c78129d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-09-18-volatile-config-references.md +2026-09-18-volatile-config-references.md: afaa6affdc3d18da182ec069126033cb55393b72 +2026-09-18-volatile-config-references.zh.md: 3d8800cb92dec4da80b698cb8b3c073331ed266f diff --git a/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.md b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.md new file mode 100644 index 0000000000..afaa6affdc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.md @@ -0,0 +1,33 @@ +# Agent Note: Volatile configuration references + +Status: implemented + +English | [中文](2026-09-18-volatile-config-references.zh.md) + +## Problem + +Replacing an entire plugin to change a value also disposes its services and effects. Separate settings subscriptions require each consumer to implement another source of live configuration. + +## Decision + +Schemastery declares live fields with `.volatile()`, preserving node types and form metadata while returning stable references to immutable snapshots. Cosmokit owns the shared protocol. General comparison treats two volatile references as equal. Loader calculates raw config differences using schema-declared volatile paths and retains raw config for subsequent activation. When the raw diff reports no ordinary change but the raw config differs, Loader parses and validates the candidate through the fiber's `internal/config` hook, compares ordinary effective values, commits references when those values still match, and notifies the owning fiber; every update path, including profile reconciliation, HMR Include refreshes and direct `entry.update()` calls, reaches this step through `Entry.update`. Fiber does not own this update policy, and HMR supplies file watching. + +Only volatile changes preserve the instance. Ordinary-field changes use the existing remount lifecycle and do not update the old references. An instance-local `loader/volatile-update` event reports paths after the complete candidate is validated and all references are committed. It uses ordinary `emit`; listener completion is not a configuration commit condition. References can be retained, while values may only be captured for one operation. Missing optional values retain a readable reference. + +Config schemas own the field types, defaults, roles, and live-update declaration needed by schema-driven settings forms. Existing settings consumers remain unchanged; automatic form generation and their migration are separate work. The [configuration guide](../../../../docs/cordis-tutorial/05-config.md) owns plugin-author usage. + +## Alternatives considered + +**Restart for every configuration change.** This releases resources that remain valid when only operation-time parameters change. + +**Add another settings-specific live-value API.** It duplicates runtime update semantics and makes plugins depend on settings to consume ordinary configuration. + +**Wrap values only during mounting.** Direct schema parsing would return a different kind of value from the plugin's config, requiring separate consumer types. + +## Consequences + +Schemas reject independent volatile references inside dynamic containers; entire objects and arrays can be volatile values. Immutable snapshots accept plain data. Simplification unwraps references, and shared symbols support ESM/CJS copies. Group/Include retain dispatch while volatile-only target updates bypass `internal/update`. Direct `fiber.update()` retains its semantics, including custom handling, saving and `noSave`. Loader commits live volatile updates itself; no separate plugin is required. + +Schema metadata keeps comparison independent of parsing: the diff does not execute expressions, validators or config hooks. Object nodes compare missing or null values using their declared defaults, even without volatile descendants; this avoids remounts for equivalent object defaults. Ordinary expression edits retain raw comparison even when they resolve to equal values. Loader compares parsed ordinary values, including URLs by normalized address. If an ordinary expression produces a different value, Loader applies the ordinary remount instead of committing; strict comparison treats class instances other than URL, Date and RegExp by identity, so an ordinary transform that builds a fresh instance per parse always remounts. Listener failures during the notification are logged and do not fail the update. Loader validates volatile candidates before committing; invalid edits remain in raw config but leave running references unchanged until a later activation. + +The schema and lifecycle regression suite covers direct updates, invalid candidates, instance isolation, dependency replacement, real file watching, and save/reload behavior. The HMR module suite covers replacement after live config updates. These tests do not claim settings migration or automatic form rendering. diff --git a/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.zh.md b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.zh.md new file mode 100644 index 0000000000..3d8800cb92 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Volatile 配置引用 + +Status: implemented + +[English](2026-09-18-volatile-config-references.md) | 中文 + +## Problem + +为了改变一个值而替换整个插件,也会销毁插件的服务和副作用。独立的 settings 订阅要求每个消费者额外实现一套动态配置来源。 + +## Decision + +Schemastery 使用 `.volatile()` 声明动态字段,保留节点类型和表单元数据,解析结果是具有不可变快照的稳定引用。共享协议位于 Cosmokit。通用比较将两个 volatile 引用视为相等。Loader 根据 schema 声明的 volatile 路径计算原始配置差异,并保留原始配置供后续激活使用。原始 diff 未报告普通字段变化但原始配置有差异时,Loader 经 fiber 的 `internal/config` 钩子解析并校验候选配置,比较普通字段的有效值,在这些值仍匹配时提交引用并通知所属实例;所有更新路径,包括 profile 重载、HMR 的 Include 刷新和直接调用 `entry.update()`,都经 `Entry.update` 到达这一步。Fiber 不承担这项更新策略,文件监视由 HMR 提供。 + +仅 volatile 变化保留实例。普通字段变化沿用现有重新挂载生命周期,不更新旧引用。实例局部的 `loader/volatile-update` 事件在完整候选配置通过校验且所有引用提交后报告路径。事件使用普通 `emit`;监听器完成不是配置提交条件。可以保存引用,而值只能在单次操作中保存。缺省可选值仍具有可读取的引用。 + +Config schema 负责提供基于 schema 的 settings 表单需要的字段类型、默认值、角色和动态更新声明。现有 settings 消费者保持不变;自动表单生成及其迁移是独立工作。[配置指南](../../../../docs/cordis-tutorial/05-config.zh.md)负责插件作者的使用说明。 + +## Alternatives considered + +**每次配置变化都重启。** 当只有操作时读取的参数变化时,这会释放仍然有效的资源。 + +**增加另一套 settings 专用动态值 API。** 这会重复运行时更新语义,并使插件为了读取普通配置而依赖 settings。 + +**仅在挂载时包装值。** 直接解析 schema 与插件配置将返回不同种类的值,需要独立的消费者类型。 + +## Consequences + +Schema 拒绝动态容器内部的独立 volatile 引用;整个对象和数组可以作为一个 volatile 值。不可变快照接受普通数据。配置简化解包引用,共享 symbol 支持 ESM/CJS 副本互操作。Group/Include 保留分发,目标插件的仅 volatile 更新不进入 `internal/update`。直接调用 `fiber.update()` 保留原语义,包括自定义处理、保存及 `noSave`。动态 volatile 更新由 Loader 自己提交,不需要额外插件。 + +Schema 元数据使比较独立于解析:diff 不执行表达式、校验器或配置钩子。对象节点的缺省值或 null 按声明的默认值比较,即使没有 volatile 后代字段;等价的对象默认值不触发重新挂载。普通表达式即使求值结果相等,修改时仍按原始值比较。Loader 比较解析后的普通值,其中 URL 按规范化地址比较。普通表达式产生不同值时,Loader 改为按普通流程重新挂载而不提交;严格比较对 URL、Date、RegExp 之外的类实例按身份比较,因此每次解析都产生新实例的普通 transform 字段总是走重新挂载。通知阶段监听器抛错只记录日志,不使更新失败。Loader 在提交前校验 volatile 候选;非法编辑保留在原始配置中,但在后续激活之前不改变运行中的引用。 + +Schema 和生命周期回归测试覆盖直接更新、无效候选、实例隔离、依赖替换、真实文件监视及保存后重新加载。HMR 模块测试覆盖动态配置更新后的代码替换。这些测试不代表 settings 迁移或自动表单渲染已经完成。 diff --git a/docs/cordis-api/inherited.md b/docs/cordis-api/inherited.md index bb6cd40bb3..cc62683cb5 100644 --- a/docs/cordis-api/inherited.md +++ b/docs/cordis-api/inherited.md @@ -21,16 +21,18 @@ This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verifie ## Inherited events (cordis core + loader/hmr/timer) -- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:328`](../../vendor/cordis/src/events.ts)) -- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:330`](../../vendor/cordis/src/events.ts)) -- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:332`](../../vendor/cordis/src/events.ts)) -- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:334`](../../vendor/cordis/src/events.ts)) -- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:336`](../../vendor/cordis/src/events.ts)) -- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:338`](../../vendor/cordis/src/events.ts)) -- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:340`](../../vendor/cordis/src/events.ts)) -- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:342`](../../vendor/cordis/src/events.ts)) -- `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts)) -- `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:24`](../../vendor/loader/src/index.ts)) -- `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts)) -- `loader/partial-dispose` — An entry is being partially disposed on reload. ([`vendor/loader/src/index.ts:26`](../../vendor/loader/src/index.ts)) -- `loader/patch-context` — A context is being patched during a reload. ([`vendor/loader/src/index.ts:27`](../../vendor/loader/src/index.ts)) +- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:331`](../../vendor/cordis/src/events.ts)) +- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:333`](../../vendor/cordis/src/events.ts)) +- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:341`](../../vendor/cordis/src/events.ts)) +- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:343`](../../vendor/cordis/src/events.ts)) +- `internal/config` — Waterfall: a fiber config is being resolved before validation. ([`vendor/cordis/src/events.ts:339`](../../vendor/cordis/src/events.ts)) +- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:345`](../../vendor/cordis/src/events.ts)) +- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:347`](../../vendor/cordis/src/events.ts)) +- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:349`](../../vendor/cordis/src/events.ts)) +- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:351`](../../vendor/cordis/src/events.ts)) +- `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts)) +- `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:26`](../../vendor/loader/src/index.ts)) +- `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:27`](../../vendor/loader/src/index.ts)) +- `loader/partial-dispose` — An entry is being partially disposed on reload. ([`vendor/loader/src/index.ts:28`](../../vendor/loader/src/index.ts)) +- `loader/patch-context` — A context is being patched during a reload. ([`vendor/loader/src/index.ts:41`](../../vendor/loader/src/index.ts)) +- `loader/volatile-update` — Volatile config values committed into the running fiber without a remount; dispatched to the owning fiber only. ([`vendor/loader/src/index.ts:34`](../../vendor/loader/src/index.ts)) diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml index 2e7c3b22ec..3eb71ac95b 100644 --- a/docs/cordis-tutorial/05-config.i18n.yaml +++ b/docs/cordis-tutorial/05-config.i18n.yaml @@ -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 docs/cordis-tutorial/05-config.md -05-config.md: 17cccce2ec43be65477ce527800ee6a636ee5d96 -05-config.zh.md: e19dcb3ab0082cdc6ba2457158491ba47223409d +05-config.md: f278e8d06ef867d2944c279d067c36970582167f +05-config.zh.md: 65e9ea73a5ce31776dc1603339fa7c1c0250f856 diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md index 17cccce2ec..f278e8d06e 100644 --- a/docs/cordis-tutorial/05-config.md +++ b/docs/cordis-tutorial/05-config.md @@ -67,6 +67,33 @@ ValidationError: invalid config: The plugin's fiber goes to FAILED, and this tutorial's launcher exits with status 1 after printing the error. A plugin should also reject schema-valid config that names an unavailable resource or provider as soon as it can resolve that reference. + +## Volatile fields + +Use `.volatile()` for a field that the plugin reads during each operation. A change to that field updates its stable reference without remounting the plugin. Read it with `.get()`: + +```ts +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/cordis-plugin-loader' +import Schema from '@deepseek-ai/schemastery' + +export const Config = Schema.object({ + greeting: Schema.string().default('Hello').volatile(), +}) + +export function apply(ctx: Context, config: ReturnType) { + ctx.on('loader/volatile-update', () => { + console.log(config.greeting.get()) + }) +} +``` + +A direct `Config(raw)` call also returns references. An optional field without a default still has a reference; `.get()` returns `undefined` when it is absent. Keep the reference, or capture its value for one operation, including across `await`. Do not retain that value for later operations. Object and array values are detached, recursively frozen snapshots of plain data; functions, class instances, and cycles are rejected. + +Loader compares raw config while ignoring schema-declared volatile fields. A volatile-only change is parsed through the plugin’s `internal/config` hook and validated; when every ordinary effective value still matches, Loader commits the new values into the running references and emits one instance-local `loader/volatile-update` with the changed paths as arrays of keys, bypassing the target plugin’s `internal/update`. Equal values do not notify. An invalid candidate is logged and leaves the running references unchanged until the next activation. A changed ordinary effective value, including a changed expression result, and simultaneous ordinary-field changes retain the existing update flow, leaving old references unchanged and unnotified. Group/Include still dispatch child updates. Direct `fiber.update()`, code replacement, and dependency replacement retain their lifecycle; `noSave` still controls direct-update persistence. Notifications use ordinary `emit` without awaiting resource reconfiguration. + +Declare volatile fields at fixed object paths, or mark an entire object or array volatile. Independent references inside arrays, dictionaries, union or intersect branches, lazy schemas, transforms, or another volatile value are rejected. Configuration files store ordinary values; `simplify()` unwraps references. Serialized schemas preserve their field types, defaults, roles, and volatile marker for schema-driven forms. Existing settings consumers continue to use their own settings interfaces. + ## Computed config values The loader used in this repo supports a `!!js` tag for config values that must be computed at load time: diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md index e19dcb3ab0..65e9ea73a5 100644 --- a/docs/cordis-tutorial/05-config.zh.md +++ b/docs/cordis-tutorial/05-config.zh.md @@ -67,6 +67,33 @@ ValidationError: invalid config: 插件的 fiber 进入 FAILED 状态,本教程的启动器打印错误后以状态码 1 退出。如果某个插件的配置通过了 schema 验证,但其中指定的资源或提供方不可用,该插件也应当在能解析该引用时立即拒绝。 + +## Volatile 字段 + +对于插件在每次操作中读取的字段,可以使用 `.volatile()`。字段变化会更新稳定引用,无需重新挂载插件。通过 `.get()` 读取: + +```ts +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/cordis-plugin-loader' +import Schema from '@deepseek-ai/schemastery' + +export const Config = Schema.object({ + greeting: Schema.string().default('Hello').volatile(), +}) + +export function apply(ctx: Context, config: ReturnType) { + ctx.on('loader/volatile-update', () => { + console.log(config.greeting.get()) + }) +} +``` + +直接调用 `Config(raw)` 也会返回引用。没有默认值的可选字段仍然具有引用;字段缺省时,`.get()` 返回 `undefined`。可以保存引用,也可以在单次操作中保存读取值,包括跨越 `await`,但不能将该值用于后续操作。对象和数组值是脱离输入、递归冻结的普通数据快照;函数、类实例和循环引用会被拒绝。 + +Loader 比较原始配置时忽略 schema 声明的 volatile 字段。仅 volatile 变化会经目标插件的 `internal/config` 钩子解析并校验;当所有普通字段的有效值仍然一致时,Loader 把新值提交到运行中的引用,并向所属实例发出一次 `loader/volatile-update`,参数是以键数组表示的变化路径,不进入目标插件的 `internal/update`。等值更新不通知。无效候选会记录日志,运行中的引用保持不变,直到下次激活。普通字段有效值变化(包括表达式求值结果变化)以及普通字段同时变化时沿用原更新流程,旧引用不更新也不接收通知。Group/Include 仍分发子条目更新。直接 `fiber.update()`、代码替换和依赖替换保留原有生命周期;`noSave` 仍控制直接更新的持久化。通知使用普通 `emit`,不等待资源重新配置完成。 + +在固定对象路径上声明 volatile 字段,或将整个对象、数组标记为 volatile。数组、字典、union 或 intersect 分支、lazy、transform 或另一个 volatile 值内部的独立引用会被拒绝。配置文件保存普通值;`simplify()` 会解包引用。序列化 schema 保留字段类型、默认值、角色和 volatile 标记,供基于 schema 的表单使用。现有 settings 消费者继续使用原有 settings 接口。 + ## 计算得到的配置值 本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值: diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 75f348ee55..a8617d5fc1 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -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/boot/app-boot/README.md -README.md: ef98b3fd98829c14dc6614de321cc02837c8eb0c -README.zh.md: 2e0ce42936701ddd94d82dae5fcfee5d33e326e0 +README.md: 4763d5e0e22560373320b1f6a478e3a71212c4e6 +README.zh.md: 5fc90e3773ce152e8fca78c101753e746e986985 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index ef98b3fd98..4763d5e0e2 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -73,7 +73,7 @@ Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifi ### Startup and reload failures -Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation. +Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update. After the Loader settles, app-boot warns when only optional entries are inactive. If an enabled required entry cannot activate, `boot()` rejects with `StartupError` after disposal. An independently owned logger exporter retains warning and error records through asynchronous disposal and is released before `boot()` settles. Its message groups all failed plugins and pending services, marks required entries, and retains original stacks, nested causes, and aggregate members. The CLI prints that message once and saves [full startup diagnostics](../../../apps/cli/reference/README.md#startup-diagnostics) before exiting with code 1; unrelated exceptions retain their normal stack output. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 2e0ce42936..5fc90e3773 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -73,7 +73,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head ### 启动与重载失败 -profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。 +profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。 成功重载在生命周期结束及诊断检查通过后返回;仅 volatile 的条目变化由 Loader 在更新过程中提交。 Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如果已启用的 required 条目无法激活,`boot()` 会在释放资源后以 `StartupError` 拒绝。独立管理生命周期的 logger exporter 会保留异步资源释放期间的警告和错误记录,并在 `boot()` 结算前释放。其消息分组列出所有失败插件和等待的服务,标记 required 条目,并保留原始堆栈、嵌套原因和聚合错误成员。CLI 仅输出该消息一次,并在保存[完整启动诊断](../../../apps/cli/reference/README.zh.md#startup-diagnostics)后以退出码 1 结束;其他异常保留正常堆栈输出。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。 diff --git a/packages/boot/hmr/README.i18n.yaml b/packages/boot/hmr/README.i18n.yaml index bb50a0f843..2a8a9c45e5 100644 --- a/packages/boot/hmr/README.i18n.yaml +++ b/packages/boot/hmr/README.i18n.yaml @@ -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/boot/hmr/README.md -README.md: 735b37c41e50c6a1f663cbfa0cde42f7250ec415 -README.zh.md: b0d3196913cb11e434b397350c81ad1bb45dc0c6 +README.md: 9e2c62c739c420ceae3db3fd5ade58d6eeadacec +README.zh.md: fa926d5998f7f4b784fe2f848b5acc3047b91b77 diff --git a/packages/boot/hmr/README.md b/packages/boot/hmr/README.md index 735b37c41e..9e2c62c739 100644 --- a/packages/boot/hmr/README.md +++ b/packages/boot/hmr/README.md @@ -54,7 +54,7 @@ Chokidar options, including polling, retain their existing meaning. Exact config
    Implementation internals — click to expand -`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward. +`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward. Include refreshes and profile reconciliation reach plugins through ordinary Loader entry updates; Loader commits volatile-only changes in place. App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. HMR also owns module-cache replacement and reload scheduling. Configuration watchers start outside the active transaction context so later notifications can enter the queue. No invariant companion is published because the queue and watcher registrations have no independent persisted projection. diff --git a/packages/boot/hmr/README.zh.md b/packages/boot/hmr/README.zh.md index b0d3196913..fa926d5998 100644 --- a/packages/boot/hmr/README.zh.md +++ b/packages/boot/hmr/README.zh.md @@ -54,7 +54,7 @@ Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时
    实现细节——点击展开 -`watchConfig()` 注册会被等待的配置处理器。`runExclusive()` 将配置变更、Loader 更新与自动重载串行化,并拒绝嵌套事务。包安装和删除在该队列之外执行。HMR 不获取包操作写锁;manifest 通知仅在有序的 `dsh.profile.bundles` 列表变化时触发重载。profile 与 home patch 变化也会触发重新组合。配置事务期间收到的文件事件在事务结束后处理。 +`watchConfig()` 注册会被等待的配置处理器。`runExclusive()` 将配置变更、Loader 更新与自动重载串行化,并拒绝嵌套事务。包安装和删除在该队列之外执行。HMR 不获取包操作写锁;manifest 通知仅在有序的 `dsh.profile.bundles` 列表变化时触发重载。profile 与 home patch 变化也会触发重新组合。配置事务期间收到的文件事件在事务结束后处理。 Include 刷新和 profile 重载都通过普通的 Loader 条目更新到达插件;仅 volatile 变化由 Loader 就地提交。 App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。HMR 也负责模块缓存替换和重载调度。配置监听器在当前事务上下文之外启动,使后续通知可以进入队列。不发布 invariant 伴生入口,因为队列和监听注册没有独立的持久投影。 diff --git a/packages/boot/hmr/tests/modules.spec.ts b/packages/boot/hmr/tests/modules.spec.ts index 6e2a7e12a9..93b96a007c 100644 --- a/packages/boot/hmr/tests/modules.spec.ts +++ b/packages/boot/hmr/tests/modules.spec.ts @@ -7,6 +7,7 @@ import NodeModule, { createRequire } from 'node:module' import { Context, type Plugin } from '@deepseek-ai/cordis' import Loader, { type ModuleJob, type ModuleLoader } from '@deepseek-ai/cordis-plugin-loader' import Timer from '@deepseek-ai/cordis-plugin-timer' +import z from '@deepseek-ai/schemastery' import { expect, it, onTestFinished, vi } from 'vitest' import Hmr from '../src/index.ts' @@ -87,6 +88,31 @@ it.each(['v1', 'v2'] as const)('replaces a %s module and retains the latest Load expect(mounted.at(-1)).toBe('after:next') }) +it('replaces volatile references on code HMR after an in-place config update', async () => { + const { ctx, module, imports, reload } = await fixture() + const Config = z.object({ value: z.string().volatile() }) + const configs: ReturnType[] = [] + const before = { Config, apply(_ctx: Context, config: ReturnType) { configs.push(config) } } + const after = { Config, apply(_ctx: Context, config: ReturnType) { configs.push(config) } } + const job = module('volatile.mjs', before) + const entry = ctx.loader.resolve(await ctx.loader.create({ name: job.url, config: { value: 'initial' } })) + await ctx.loader.await() + await entry.update({ config: { value: 'current' } }) + await ctx.loader.await() + expect(configs).toHaveLength(1) + const old = configs[0]!.value + expect(old.get()).toBe('current') + imports.set(job.url, after) + reload.stashed.add(job.url) + await reload.partialReload() + expect(configs).toHaveLength(2) + expect(configs[1]!.value).not.toBe(old) + expect(configs[1]!.value.get()).toBe('current') + await entry.update({ config: { value: 'after-reload' } }) + expect(configs[1]!.value.get()).toBe('after-reload') + expect(old.get()).toBe('current') +}) + it('reactivates a source-replaced consumer after provider and consumer configuration changes', async () => { const { ctx, module, imports, reload } = await fixture() const mounted: string[] = [] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d3e3cf6f4..39acefeee7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12990,6 +12990,9 @@ importers: '@deepseek-ai/cosmokit': specifier: link:../cosmokit version: link:../cosmokit + '@deepseek-ai/schemastery': + specifier: link:../schemastery + version: link:../schemastery node-addon-require-builtin: specifier: ^0.1.6 version: 0.1.6 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 1b19d386b1..b742bbcc58 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -895,19 +895,21 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = { ], }], inheritedEvents: [ - { name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' }, - { name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' }, - { name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' }, - { name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' }, - { name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' }, - { name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' }, - { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' }, - { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' }, - { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' }, - { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' }, - { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' }, - { name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' }, - { name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' }, + { name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:331' }, + { name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:333' }, + { name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:341' }, + { name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:343' }, + { name: 'internal/config', summary: 'Waterfall: a fiber config is being resolved before validation.', source: 'vendor/cordis/src/events.ts:339' }, + { name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:345' }, + { name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:347' }, + { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:349' }, + { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:351' }, + { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:25' }, + { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:26' }, + { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:27' }, + { name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:28' }, + { name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:41' }, + { name: 'loader/volatile-update', summary: 'Volatile config values committed into the running fiber without a remount; dispatched to the owning fiber only.', source: 'vendor/loader/src/index.ts:34' }, ], inheritedServices: [ { name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' }, diff --git a/scripts/loader-config-diff.spec.ts b/scripts/loader-config-diff.spec.ts new file mode 100644 index 0000000000..0cd0a3eb50 --- /dev/null +++ b/scripts/loader-config-diff.spec.ts @@ -0,0 +1,109 @@ +/** Raw config comparison reads schema metadata without resolving config or committing references. */ +import { expect, it, vi } from 'vitest' +import z from '@deepseek-ai/schemastery' +import { equalExceptVolatile as equal } from '../vendor/loader/src/config/diff.ts' + +it('ignores volatile fields in frozen raw inputs without running schema callbacks', () => { + const validate = vi.fn((value: string) => value.trim()) + const schema = z.object({ title: z.transform(z.string(), validate).volatile(), fixed: z.string() }) + Object.freeze(schema.meta) + Object.freeze(schema.dict) + const previous = Object.freeze({ title: 'old', fixed: 'same' }) + const next = Object.freeze({ title: { __jsExpr: 'throw new Error("must not run")' }, fixed: 'same' }) + expect(equal(previous, next, schema)).toBe(true) + expect(equal(previous, next, schema)).toBe(true) + expect(validate).not.toHaveBeenCalled() + expect(previous.title).toBe('old') + expect(next.title).toEqual({ __jsExpr: 'throw new Error("must not run")' }) +}) + +it('ignores absent, nested and whole-object/array/union volatile fields', () => { + const schema = z.object({ + nested: z.object({ value: z.string().volatile() }), + block: z.object({ title: z.string() }).volatile(), + list: z.array(z.string()).volatile(), + choice: z.union([z.string(), z.number()]).volatile(), + }) + const next = { nested: { value: 'new' }, block: { title: 'new' }, list: ['new'], choice: 1 } + expect(equal({}, next, schema)).toBe(true) + expect(equal(next, {}, schema)).toBe(true) + expect(equal('old', 'new', z.string().volatile())).toBe(true) + const restored = new z(JSON.parse(JSON.stringify(schema)) as z) + expect(equal({}, next, restored)).toBe(true) +}) + +it('preserves ordinary and unknown fields, object defaults and parent expressions', () => { + const schema = z.object({ nested: z.object({ live: z.string().volatile(), fixed: z.string() }).default({ fixed: 'default' }) }) + expect(equal({}, { nested: { live: 'new', fixed: 'default' } }, schema)).toBe(true) + expect(equal({}, { nested: { live: 'new' } }, schema)).toBe(false) + expect(equal({}, { unknown: 'new' }, schema)).toBe(false) + expect(equal({ nested: { __jsExpr: 'source.a' } }, { nested: { __jsExpr: 'source.b' } }, schema)).toBe(false) + expect(equal({ nested: 'invalid' }, { nested: { live: 'new' } }, schema)).toBe(false) + expect(equal({ nested: new Date(0) }, { nested: new Date(1) }, schema)).toBe(false) + expect(equal({ nested: [] }, { nested: ['new'] }, schema)).toBe(false) +}) + +it('keeps ordinary raw changes even if schema validation would normalize them to equal values', () => { + const normalize = vi.fn(() => 'same') + const schema = z.object({ fixed: z.transform(z.string(), normalize), live: z.string().volatile() }) + expect(equal({ fixed: 'first' }, { fixed: 'second' }, schema)).toBe(false) + expect(normalize).not.toHaveBeenCalled() + const other = { '~standard': { version: 1 as const, vendor: 'other', validate: vi.fn() } } + expect(equal('first', 'second', other)).toBe(false) + expect(other['~standard'].validate).not.toHaveBeenCalled() + expect(equal('first', 'second', undefined)).toBe(false) +}) + +it('compares raw values strictly without a schema', () => { + expect(equal([undefined], [null], undefined)).toBe(false) + expect(equal(undefined, null, undefined)).toBe(false) + expect(equal({ fixed: 'same' }, { fixed: 'same' }, undefined)).toBe(true) + expect(equal({ list: Array(1) }, { list: ['new'] }, undefined)).toBe(false) +}) + +it('compares recursive schemas conservatively and visits shared schema nodes at each path', () => { + const recursive = z.object({ value: z.string() }).extra('default', undefined) + recursive.set('child', recursive) + expect(equal({ value: 'old' }, { value: 'new' }, recursive)).toBe(false) + const shared = z.object({ value: z.string().volatile() }) + const schema = z.object({ first: shared, second: shared }) + expect(equal({}, { first: { value: 'one' }, second: { value: 'two' } }, schema)).toBe(true) +}) + +it('equates absent objects with defaults while preserving ordinary field differences', () => { + const schema = z.object({ nested: z.object({ fixed: z.string() }).default({ fixed: 'default' }) }) + for (const absent of [undefined, null]) { + expect(equal(absent, {}, schema)).toBe(true) + expect(equal({}, absent, schema)).toBe(true) + expect(equal({ nested: absent }, { nested: { fixed: 'default' } }, schema)).toBe(true) + } + expect(equal({}, { nested: { fixed: 'changed' } }, schema)).toBe(false) + expect(equal({}, { nested: {} }, schema)).toBe(false) +}) + +it('preserves parent expression edits even when their marker matches a volatile field', () => { + const schema = z.object({ __jsExpr: z.string().volatile() }) + expect(equal({ __jsExpr: 'first' }, { __jsExpr: 'second' }, schema)).toBe(false) +}) + +it('detects ordinary sparse array edits in both directions', () => { + const schema = z.object({ list: z.array(z.string()), live: z.string().volatile() }) + const before = { list: Array(1), live: 'before' } + const after = { list: ['new'], live: 'after' } + expect(equal(before, after, schema)).toBe(false) + expect(equal(after, before, schema)).toBe(false) +}) + +it('compares unknown fields whose names match Object prototype properties', () => { + const schema = z.object({ live: z.string().volatile() }) + for (const key of ['constructor', 'toString', '__proto__']) { + const previous = { [key]: 'first', live: 'old' } + expect(equal(previous, { [key]: 'second', live: 'new' }, schema)).toBe(false) + expect(equal(previous, { [key]: 'first', live: 'new' }, schema)).toBe(true) + } +}) + +it('compares metadata-free serialized schema nodes', () => { + const schema = new z(JSON.parse('{"type":"object","dict":{"nested":{"type":"object","dict":{"enabled":{"type":"boolean"}}}}}') as z) + expect(equal({ nested: { enabled: true } }, { nested: { enabled: false } }, schema)).toBe(false) +}) diff --git a/scripts/loader-volatile-update.spec.ts b/scripts/loader-volatile-update.spec.ts new file mode 100644 index 0000000000..16d8f3b0a8 --- /dev/null +++ b/scripts/loader-volatile-update.spec.ts @@ -0,0 +1,634 @@ +/** Loader commits volatile config values into running fibers without remounting; ordinary changes keep the update lifecycle. */ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { Context, type Fiber, type Plugin } from '@deepseek-ai/cordis' +import Loader, { Group } from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import Timer from '@deepseek-ai/cordis-plugin-timer' +import z from '@deepseek-ai/schemastery' +import { Config } from './volatile-config.fixture.ts' +import Hmr from '@deepseek-ai/dsh-hmr' +import { boot, reconcileProfilePatches } from '@deepseek-ai/dsh-app-boot' + +function context() { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + return ctx +} + +async function runtimeContext() { + const ctx = context() + await ctx.plugin(Loader) + return ctx +} + +async function mountEntry(ctx: Context, plugin: Plugin, config: unknown = {}): Promise { + const name = `consumer-${Object.keys(ctx.loader.builtins).length}` + ctx.loader.builtins[name] = plugin + const id = await ctx.loader.create({ name: `cordis:${name}`, config }) + const fiber = ctx.loader.resolve(id).fiber! + await fiber.await() + return fiber +} + +async function update(fiber: Fiber, config: unknown): Promise { + await fiber.entry!.update({ config }) + await fiber.await() +} + +describe('volatile Loader updates', () => { + it('updates a whole-config reference and reports the root path', async () => { + const ctx = await runtimeContext() + const Config = z.object({ title: z.string() }).volatile() + const events = vi.fn() + const fiber = await mountEntry(ctx, { Config, apply(owner: Context) { + owner.on('loader/volatile-update', events) + } }, { title: 'first' }) + const reference = fiber.config as ReturnType + await update(fiber, { title: 'second' }) + expect(fiber.config).toBe(reference) + expect(reference.get()).toEqual({ title: 'second' }) + expect(events).toHaveBeenCalledExactlyOnceWith([[]]) + }) + it('commits all references before one local event without remounting', async () => { + const ctx = await runtimeContext() + const configs: ReturnType[] = [] + const observed: unknown[] = [] + const mount = vi.fn(function (owner: Context, config: ReturnType) { + configs.push(config) + owner.on('loader/volatile-update', paths => observed.push({ paths, title: config.title.get(), count: config.nested.count.get() })) + }) + const first = await mountEntry(ctx, { Config, apply: mount }, {}) + await mountEntry(ctx, { Config, apply: mount }, {}) + const initial = configs[0]! + const title = initial.title + await update(first, { title: 'hello' }) + expect(observed).toEqual([]) + await update(first, { title: 'next', nested: { count: 2 } }) + await first.await() + expect(mount).toHaveBeenCalledTimes(2) + expect(first.config).toBe(initial) + expect(initial.title).toBe(title) + expect(configs[1]!.title.get()).toBe('hello') + expect(observed).toEqual([{ paths: [['title'], ['nested', 'count']], title: 'next', count: 2 }]) + await update(first, { title: 'next', nested: { count: 2 } }) + expect(observed).toHaveLength(1) + await update(first, { optional: 'added' }) + expect(initial.optional.get()).toBe('added') + await update(first, {}) + expect(initial.optional.get()).toBeUndefined() + }) + + it('keeps references on invalid updates and replaces them on mixed updates', async () => { + const ctx = await runtimeContext() + const notifications = vi.fn() + const disposed = vi.fn() + const configs: ReturnType[] = [] + const plugin = { Config, apply(owner: Context, config: ReturnType) { + configs.push(config) + owner.on('loader/volatile-update', notifications) + owner.effect(() => disposed) + } } + const fiber = await mountEntry(ctx, plugin, {}) + const before = configs[0]! + await update(fiber, { title: 'invalid candidate', nested: { count: -1 } }) + expect(before.title.get()).toBe('hello') + expect(notifications).not.toHaveBeenCalled() + await update(fiber, { title: 'new', fixed: 'restart' }) + await fiber.await() + expect(configs).toHaveLength(2) + expect(disposed).toHaveBeenCalledOnce() + expect(notifications).not.toHaveBeenCalled() + expect(before.title.get()).toBe('hello') + expect(configs[1]!.title.get()).toBe('new') + expect(configs[1]!.title).not.toBe(before.title) + }) + + it('bypasses custom update hooks for volatile changes and retains them for mixed changes', async () => { + const ctx = await runtimeContext() + const hooks: string[] = [] + const globalHook = vi.fn((_config: unknown, _noSave: boolean, next: () => void) => { next() }) + ctx.on('internal/update', globalHook, { global: true }) + const fiber = await mountEntry(ctx, { Config, apply(owner: Context) { + owner.on('internal/update', (config: ReturnType) => { + hooks.push(config.title.get()) + }) + } }, {}) + const initial = fiber.config as ReturnType + await update(fiber, { title: 'accepted' }) + expect(fiber.config).toBe(initial) + expect(initial.title.get()).toBe('accepted') + await update(fiber, { title: 'accepted' }) + expect(hooks).toEqual([]) + expect(globalHook).not.toHaveBeenCalled() + await update(fiber, { title: 'custom', fixed: 'changed' }) + expect(hooks).toEqual(['custom']) + expect(initial.title.get()).toBe('accepted') + }) + + it('compares ordinary dates and arrays and retains the existing nonvolatile restart behavior', async () => { + const ctx = await runtimeContext() + const schema = z.object({ live: z.string().volatile(), date: z.date(), list: z.array(z.string()) }) + const apply = vi.fn(function () {}) + const fiber = await mountEntry(ctx, { Config: schema, apply }, { live: 'a', date: '2026-01-01', list: ['a'] }) + await update(fiber, { live: 'b', date: '2026-01-01', list: ['a'] }) + await fiber.await() + expect(apply).toHaveBeenCalledOnce() + await update(fiber, { live: 'c', date: '2026-01-02', list: ['a'] }) + await fiber.await() + expect(apply).toHaveBeenCalledTimes(2) + await update(fiber, { live: 'd', date: '2026-01-02', list: ['b'] }) + await fiber.await() + expect(apply).toHaveBeenCalledTimes(3) + const ordinary = await mountEntry(ctx, { Config: z.object({ value: z.string() }), apply() { apply() } }, { value: 'same' }) + ordinary.update({ value: 'same' }) + await ordinary.await() + expect(apply).toHaveBeenCalledTimes(5) + }) + + it('resolves pending updates and creates fresh references after dependency replacement', async () => { + const ctx = await runtimeContext() + const configs: ReturnType[] = [] + const consumer = await mountEntry(ctx, { Config, inject: ['volatile-test-provider'], apply(_ctx: Context, config: ReturnType) { + configs.push(config) + } }, {}) + await update(consumer, { title: 'pending' }) + const provider = await ctx.plugin({ apply(owner: Context) { owner.provide('volatile-test-provider', {}) } }) + await consumer.await() + expect(configs[0]!.title.get()).toBe('pending') + await update(consumer, { title: 'live' }) + await provider.dispose() + const before = configs[0]!.title + await ctx.plugin({ apply(owner: Context) { owner.provide('volatile-test-provider', {}) } }) + await consumer.await() + expect(configs).toHaveLength(2) + expect(configs[1]!.title.get()).toBe('live') + expect(configs[1]!.title).not.toBe(before) + }) + + it('remounts for changed class instances and cyclic ordinary config', async () => { + const ctx = await runtimeContext() + const schema = z.object({ + live: z.string().volatile(), + url: z.transform(z.string(), value => new URL(value)), + data: z.transform(z.string(), () => { const data: Record = {}; data.self = data; return data }), + }) + const apply = vi.fn(function () {}) + const oldUrl = 'https://a.example/' + const newUrl = 'https://b.example/' + const fiber = await mountEntry(ctx, { Config: schema, apply }, { live: 'a', url: oldUrl }) + const before = fiber.config as ReturnType + await update(fiber, { live: 'b', url: newUrl }) + await fiber.await() + expect(apply).toHaveBeenCalledTimes(2) + expect((fiber.config as ReturnType).url.href).toBe(newUrl) + expect(before.live.get()).toBe('a') + await update(fiber, { live: 'c', url: newUrl, data: 'first' }) + await update(fiber, { live: 'd', url: newUrl, data: 'second' }) + expect(apply).toHaveBeenCalledTimes(4) + }) + + it('distinguishes undefined from null inside volatile arrays', async () => { + const ctx = await runtimeContext() + const schema = z.object({ values: z.array(z.any()).volatile() }) + const events = vi.fn() + const fiber = await mountEntry(ctx, { Config: schema, apply(owner: Context) { + owner.on('loader/volatile-update', events) + } }, { values: [undefined] }) + await update(fiber, { values: [null] }) + expect((fiber.config as ReturnType).values.get()).toEqual([null]) + expect(events).toHaveBeenCalledExactlyOnceWith([['values']]) + }) +}) + +it('reloads real YAML and persists plain values independently of custom update hooks', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-volatile-')) + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) }) + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + const configs: ReturnType[] = [] + ctx.loader.builtins.include = Include + ctx.loader.builtins.group = Group + const updates = vi.fn() + const consumer = { Config, apply(owner: Context, config: ReturnType) { + configs.push(config) + owner.on('internal/update', updates) + } } + ctx.loader.builtins['volatile-test'] = consumer + const filename = join(root, 'cordis.yml') + await writeFile(filename, '- id: group\n name: cordis:group\n group: true\n config:\n - id: consumer\n name: cordis:volatile-test\n config:\n title: initial\n') + const rootId = await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(filename).href } }) + await ctx.loader.await() + const include = ctx.loader.resolve(rootId).subtree as Include + const group = include.store.group!.fiber! + const groupUid = group.uid + const entry = [...ctx.loader.entries()].find(entry => entry.options.id === 'consumer')! + expect(configs[0]!.title.get()).toBe('initial') + await ctx.plugin(Timer) + // An exact-file config watch mirrors the HMR root-watch Include path, which packages/boot/hmr covers with a mocked watcher. + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + const stopWatching = await ctx.hmr.watchConfig(filename, async () => { + await include.refresh() + await ctx.loader.await() + }) + await writeFile(filename, '- id: group\n name: cordis:group\n group: true\n config:\n - id: consumer\n name: cordis:volatile-test\n config:\n title: file-update\n') + // The config watcher stabilizes writes for two seconds before publishing a change. + await vi.waitFor(() => { expect(configs[0]!.title.get()).toBe('file-update') }, { timeout: 10_000 }) + await stopWatching() + expect(configs).toHaveLength(1) + expect(include.store.group!.fiber).toBe(group) + expect(group.uid).toBe(groupUid) + expect(configs[0]!.title.get()).toBe('file-update') + entry.fiber!.update({ title: 'program-update', group: { list: ['item'] } }) + expect(entry.options.config).toEqual({ title: 'program-update', group: { list: ['item'] } }) + entry.fiber!.update({ title: 'ephemeral' }, true) + expect(entry.options.config).toEqual({ title: 'program-update', group: { list: ['item'] } }) + await ctx.fiber.dispose() + const saved = await readFile(filename, 'utf8') + expect(saved).toContain('program-update') + expect(saved).not.toContain('get:') + expect(saved).not.toContain('ephemeral') + expect(updates).toHaveBeenCalledTimes(2) + const restored = context() + restored.baseUrl = ctx.baseUrl + await restored.plugin(Loader) + restored.loader.builtins.include = Include + restored.loader.builtins.group = Group + restored.loader.builtins['volatile-test'] = consumer + await restored.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(filename).href } }) + await restored.loader.await() + expect(configs).toHaveLength(2) + expect(configs[1]!.title.get()).toBe('program-update') + expect(configs[1]!.group.get()).toEqual({ list: ['item'] }) + await restored.fiber.dispose() +}) + +it('updates volatile children through Group and Include custom updates and saves ordinary config', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-volatile-tree-')) + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) }) + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + ctx.loader.builtins.group = Group + const configs: ReturnType[] = [] + const notifications = vi.fn() + const childUpdates = vi.fn() + ctx.loader.builtins['volatile-test'] = { Config, apply(owner: Context, config: ReturnType) { + configs.push(config) + owner.on('loader/volatile-update', notifications) + owner.on('internal/update', childUpdates) + } } + const child = (title: string) => ({ id: 'consumer', name: 'cordis:volatile-test', config: { title } }) + const filename = join(root, 'tree.json') + const includePath = pathToFileURL(filename).href + await writeFile(filename, JSON.stringify([{ id: 'group', name: 'cordis:group', group: true, config: [child('initial')] }])) + const rootId = await ctx.loader.create({ name: 'cordis:include', config: { path: includePath } }) + await ctx.loader.await() + const includeEntry = ctx.loader.resolve(rootId) + const include = includeEntry.subtree as Include + const group = include.store.group! + const consumer = include.store.consumer! + const originalInclude = includeEntry.fiber + const originalGroup = group.fiber + const originalConsumer = consumer.fiber + const initial = configs[0]! + + group.fiber!.update([child('group-update')]) + await ctx.loader.await() + expect(initial.title.get()).toBe('group-update') + expect(group.options.config).toEqual([child('group-update')]) + includeEntry.fiber!.update({ path: includePath, patches: [{ id: 'consumer', config: { title: 'include-update' } }] }) + await ctx.loader.await() + expect(initial.title.get()).toBe('include-update') + expect(includeEntry.fiber).toBe(originalInclude) + expect(group.fiber).toBe(originalGroup) + expect(consumer.fiber).toBe(originalConsumer) + expect(consumer.fiber!.config).toBe(initial) + expect(configs).toHaveLength(1) + expect(notifications).toHaveBeenCalledTimes(2) + expect(childUpdates).not.toHaveBeenCalled() + + const ordinary = await ctx.loader.create({ name: 'cordis:volatile-test', config: {} }) + const entry = ctx.loader.resolve(ordinary) + entry.fiber!.update({ title: 'custom-update', fixed: 'ordinary' }) + expect(childUpdates).toHaveBeenCalledOnce() + expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' }) + expect((entry.fiber!.config as ReturnType).title.get()).toBe('hello') + entry.fiber!.update({ title: 'ephemeral', fixed: 'ordinary' }, true) + expect(childUpdates).toHaveBeenCalledTimes(2) + expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' }) + expect(() => { entry.fiber!.update({ nested: { count: -1 } }) }).toThrow() + expect(childUpdates).toHaveBeenCalledTimes(2) + expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' }) +}) + +it('commits volatile edits in place and retains direct Fiber updates', async () => { + const ctx = await runtimeContext() + const notifications = vi.fn() + const apply = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', notifications) }) + const fiber = await mountEntry(ctx, { Config, apply }, {}) + const original = fiber.config as ReturnType + await ctx.loader.create({ name: 'cordis:disabled', disabled: true }) + await update(fiber, { title: 'enabled' }) + expect(apply).toHaveBeenCalledOnce() + expect(fiber.config).toBe(original) + expect(original.title.get()).toBe('enabled') + expect(notifications).toHaveBeenCalledExactlyOnceWith([['title']]) + await update(fiber, { title: 'invalid', nested: { count: -1 } }) + expect(original.title.get()).toBe('enabled') + await update(fiber, { title: 'mixed', fixed: 'changed' }) + expect(apply).toHaveBeenCalledTimes(2) + expect(original.title.get()).toBe('enabled') + const current = fiber.config as ReturnType + expect(current.title.get()).toBe('mixed') + + const hook = vi.fn((_config: unknown, _noSave: boolean, next: () => void) => { next() }) + fiber.ctx.on('internal/update', hook) + fiber.update({ title: 'direct' }) + await fiber.await() + expect(hook).toHaveBeenCalledOnce() + expect(apply).toHaveBeenCalledTimes(3) + expect(current.title.get()).toBe('mixed') + expect(notifications).toHaveBeenCalledOnce() +}) + +it('parses a volatile-only update through the config hook without patching the entry context', async () => { + const ctx = await runtimeContext() + const trace: string[] = [] + const trim = vi.fn((value: string) => { trace.push('validate'); return value.trim() }) + const Config = z.object({ block: z.object({ title: z.transform(z.string(), trim, true) }).volatile() }) + const fiber = await mountEntry(ctx, { Config, apply(owner: Context) { + owner.on('loader/volatile-update', () => { trace.push('notify') }) + } }, { block: { title: 'initial' } }) + const current = fiber.config as ReturnType + const resolveHook = vi.fn((_raw: unknown, next: () => unknown) => next()) + fiber.ctx.on('internal/config', resolveHook) + const contextPatch = vi.fn((_entry: unknown, next: () => void) => { next() }) + ctx.on('loader/patch-context', contextPatch) + trim.mockClear() + trace.length = 0 + const raw = { block: { title: ' changed ' } } + await fiber.entry!.update({ config: raw }) + expect(contextPatch).not.toHaveBeenCalled() + expect(trim).toHaveBeenCalledExactlyOnceWith(' changed ') + expect(resolveHook).toHaveBeenCalledOnce() + expect(trace).toEqual(['validate', 'notify']) + expect(current.block.get()).toEqual({ title: 'changed' }) + expect(fiber._config).toBe(raw) +}) + +it.each([true, false])('keeps old references untouched when isolation replaces a dependency (available: %s)', async (available) => { + const ctx = await runtimeContext() + const configs: ReturnType[] = [] + const notifications = vi.fn() + ctx.loader.builtins.provider = { apply(owner: Context) { owner.provide('volatile-isolated-service', {}) } } + ctx.loader.builtins.consumer = { + inject: ['volatile-isolated-service'], Config, + apply(owner: Context, config: ReturnType) { + configs.push(config) + owner.on('loader/volatile-update', notifications) + }, + } + const provider = (realm: string) => ctx.loader.create({ + name: 'cordis:provider', isolate: { 'volatile-isolated-service': realm }, + }) + await provider('a') + if (available) await provider('b') + const id = await ctx.loader.create({ + name: 'cordis:consumer', isolate: { 'volatile-isolated-service': 'a' }, config: { title: 'old' }, + }) + await ctx.loader.await() + const original = configs[0]! + await ctx.loader.resolve(id).update({ isolate: { 'volatile-isolated-service': 'b' }, config: { title: 'new' } }) + await ctx.loader.await() + expect(configs).toHaveLength(available ? 2 : 1) + expect(original.title.get()).toBe('old') + expect(notifications).not.toHaveBeenCalled() + if (!available) { + await provider('b') + await ctx.loader.await() + } + expect(configs).toHaveLength(2) + expect(configs[1]!.title.get()).toBe('new') +}) + +it.each(['group', 'include'] as const)('applies profile patches to a %s subtree without HMR and keeps mixed updates on the normal lifecycle', async (kind) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-volatile-profile-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + const configs: ReturnType[] = [] + const events = vi.fn() + const dispose = vi.fn() + const consumer = { Config, apply(owner: Context, config: ReturnType) { + configs.push(config) + owner.on('loader/volatile-update', events) + owner.effect(() => dispose) + } } + const child = (title: string, fixed = 'fixed') => ({ id: 'consumer', name: 'cordis:consumer', config: { title, fixed } }) + const nested = join(root, 'nested.json') + await writeFile(nested, JSON.stringify([child('initial')])) + const carrierConfig = (title: string, fixed = 'fixed') => kind === 'group' + ? [child(title, fixed)] + : { path: pathToFileURL(nested).href, patches: [{ id: 'consumer', config: { title, fixed } }] } + const filename = join(root, 'cordis.yml') + await writeFile(filename, JSON.stringify([{ id: 'parent', name: `cordis:${kind}`, group: kind === 'group', config: carrierConfig('initial') }])) + const ctx = await boot('volatile-test', filename, undefined, async (ctx) => { + ctx.loader.builtins.consumer = consumer + }) + onTestFinished(() => ctx.fiber.dispose()) + expect(ctx.get('hmr')).toBeUndefined() + const parent = [...ctx.loader.entries()].find(entry => entry.options.id === 'parent')! + const entry = [...ctx.loader.entries()].find(entry => entry.options.id === 'consumer')! + const parentFiber = parent.fiber + const childFiber = entry.fiber + const original = configs[0]! + const reference = original.title + const patch = (title: string, fixed = 'fixed') => [{ id: 'parent', config: carrierConfig(title, fixed) }] + await reconcileProfilePatches(ctx, patch('first'), 'volatile-test') + await reconcileProfilePatches(ctx, patch('second'), 'volatile-test') + expect(parent.fiber).toBe(parentFiber) + expect(entry.fiber).toBe(childFiber) + expect(entry.fiber!.config).toBe(original) + expect(original.title).toBe(reference) + expect(reference.get()).toBe('second') + expect(configs).toHaveLength(1) + expect(dispose).not.toHaveBeenCalled() + expect(events).toHaveBeenCalledTimes(2) + expect(events).toHaveBeenLastCalledWith([['title']]) + await reconcileProfilePatches(ctx, patch('second'), 'volatile-test') + expect(events).toHaveBeenCalledTimes(2) + await reconcileProfilePatches(ctx, patch('mixed', 'changed'), 'volatile-test') + expect(parent.fiber).toBe(parentFiber) + expect(configs).toHaveLength(2) + expect(dispose).toHaveBeenCalledOnce() + expect(reference.get()).toBe('second') + expect(configs[1]!.title.get()).toBe('mixed') + expect(events).toHaveBeenCalledTimes(2) +}) + +it('logs an invalid candidate and keeps its references while other entries commit', async () => { + const ctx = await runtimeContext() + const events = vi.fn() + const plugin = { Config, apply(owner: Context) { owner.on('loader/volatile-update', events) } } + const invalid = await mountEntry(ctx, plugin) + const healthy = await mountEntry(ctx, plugin) + const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warning.mockRestore() }) + await invalid.entry!.update({ config: { title: 'invalid', nested: { count: -1 } } }) + await healthy.entry!.update({ config: { title: 'healthy' } }) + expect(warning).toHaveBeenCalledTimes(2) + expect((invalid.config as ReturnType).title.get()).toBe('hello') + expect(invalid._config).toEqual({ title: 'invalid', nested: { count: -1 } }) + expect((healthy.config as ReturnType).title.get()).toBe('healthy') + expect(events).toHaveBeenCalledExactlyOnceWith([['title']]) +}) + +it.each([false, true])('retains raw expression edits for dependency restoration (volatile: %s)', async (volatile) => { + const ctx = await runtimeContext() + const schema = z.object({ value: volatile ? z.string().volatile() : z.string() }) + const values: unknown[] = [] + const provide = (a: string, b: string) => ctx.plugin({ apply(owner: Context) { owner.provide('volatile-expression-source', { a, b }) } }) + const provider = await provide('same', 'same') + const fiber = await mountEntry(ctx, { Config: schema, inject: ['volatile-expression-source'], apply(_owner: Context, config: ReturnType) { + values.push(typeof config.value === 'string' ? config.value : config.value.get()) + } }, { value: { __jsExpr: "ctx.get('volatile-expression-source').a" } }) + const raw = { value: { __jsExpr: "ctx.get('volatile-expression-source').b" } } + await fiber.entry!.update({ config: raw }) + await fiber.await() + expect(values).toEqual(volatile ? ['same'] : ['same', 'same']) + expect(fiber._config).toBe(raw) + await provider.dispose() + await provide('old-route', 'new-route') + await fiber.await() + expect(values).toEqual(volatile ? ['same', 'new-route'] : ['same', 'same', 'new-route']) +}) + +it('remounts when changed expressions resolve to ordinary values different from the live config', async () => { + const ctx = await runtimeContext() + const source = { a: 'initial', b: 'initial' } + ctx.provide('volatile-expression-source', source) + const configs: ReturnType[] = [] + const events = vi.fn() + const fiber = await mountEntry(ctx, { Config, apply(owner: Context, config: ReturnType) { + configs.push(config) + owner.on('loader/volatile-update', events) + } }, { fixed: { __jsExpr: "ctx.get('volatile-expression-source').a" }, title: 'old' }) + source.a = source.b = 'changed' + await update(fiber, { fixed: { __jsExpr: "ctx.get('volatile-expression-source').b" }, title: 'new' }) + expect(configs).toHaveLength(2) + expect(configs[0]!.title.get()).toBe('old') + expect(configs[1]!.fixed).toBe('changed') + expect(configs[1]!.title.get()).toBe('new') + expect(events).not.toHaveBeenCalled() +}) + +it('compares ordinary URL transforms by value and remounts when an expression result changes', async () => { + const ctx = await runtimeContext() + const source = { value: 'initial' } + ctx.provide('volatile-transform-source', source) + const schema = z.object({ + url: z.transform(z.string(), value => new URL(value), true), + fixed: z.string(), + live: z.string().volatile(), + }) + const events = vi.fn() + const mount = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', events) }) + const fixed = { __jsExpr: "ctx.get('volatile-transform-source').value" } + const raw = { url: 'https://example.com/', fixed, live: 'old' } + const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, raw) + const current = fiber.config as ReturnType + await update(fiber, { ...raw, live: 'new' }) + expect(mount).toHaveBeenCalledOnce() + expect(fiber.config).toBe(current) + expect(current.url.href).toBe(raw.url) + expect(current.fixed).toBe('initial') + expect(current.live.get()).toBe('new') + expect(events).toHaveBeenCalledExactlyOnceWith([['live']]) + source.value = 'changed' + await update(fiber, { ...raw, live: 'remounted' }) + expect(mount).toHaveBeenCalledTimes(2) + expect(current.fixed).toBe('initial') + expect(current.live.get()).toBe('new') + expect(events).toHaveBeenCalledOnce() + const replaced = fiber.config as ReturnType + expect(replaced.fixed).toBe('changed') + expect(replaced.live.get()).toBe('remounted') +}) + +it('remounts when an ordinary parent expression removes a live reference path', async () => { + const ctx = await runtimeContext() + const source: { block: { value: string } | undefined } = { block: { value: 'old' } } + ctx.provide('volatile-path-source', source) + const schema = z.object({ block: z.object({ value: z.string().volatile() }).extra('default', undefined), live: z.string().volatile() }) + const events = vi.fn() + const mount = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', events) }) + const raw = { block: { __jsExpr: "ctx.get('volatile-path-source').block" }, live: 'old' } + const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, raw) + const current = fiber.config as ReturnType + source.block = undefined + await update(fiber, { ...raw, live: 'new' }) + expect(mount).toHaveBeenCalledTimes(2) + expect(current.block.value.get()).toBe('old') + expect(events).not.toHaveBeenCalled() + expect((fiber.config as ReturnType).block).toBeUndefined() +}) + +it('keeps an ordinary plugin mounted when an absent object becomes its explicit default', async () => { + const ctx = await runtimeContext() + const Config = z.object({ nested: z.object({ fixed: z.string() }).default({ fixed: 'default' }) }) + const mount = vi.fn() + const fiber = await mountEntry(ctx, { Config, apply: mount }, {}) + const config: unknown = fiber.config + await update(fiber, { nested: { fixed: 'default' } }) + expect(fiber.config).toBe(config) + expect(mount).toHaveBeenCalledTimes(1) + await update(fiber, { nested: { fixed: 'changed' } }) + expect(mount).toHaveBeenCalledTimes(2) + expect(fiber.config).toEqual({ nested: { fixed: 'changed' } }) +}) + +it('keeps a noSave volatile value as the pending raw config across a no-op entry update', async () => { + const ctx = await runtimeContext() + const fiber = await mountEntry(ctx, { Config, apply() {} }, { title: 'saved' }) + fiber.update({ title: 'ephemeral' }, true) + await fiber.await() + expect((fiber.config as ReturnType).title.get()).toBe('ephemeral') + expect(fiber.entry!.options.config).toEqual({ title: 'saved' }) + await fiber.entry!.update({ config: { title: 'saved' } }) + expect(fiber._config).toEqual({ title: 'ephemeral' }) + expect((fiber.config as ReturnType).title.get()).toBe('ephemeral') +}) + +it('isolates a failing notification listener and dispatches with a Context receiver', async () => { + const ctx = await runtimeContext() + const receivers: unknown[] = [] + const fiber = await mountEntry(ctx, { Config, apply(owner: Context) { + owner.on('loader/volatile-update', function (this: Context) { receivers.push(this.fiber); throw new Error('listener boom') }) + } }, {}) + const other = await mountEntry(ctx, { Config, apply() {} }, {}) + const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warning.mockRestore() }) + await update(fiber, { title: 'committed' }) + expect((fiber.config as ReturnType).title.get()).toBe('committed') + expect(receivers).toEqual([fiber]) + expect(warning).toHaveBeenCalledWith(expect.objectContaining({ message: 'listener boom' })) + await update(other, { title: 'unaffected' }) + expect((other.config as ReturnType).title.get()).toBe('unaffected') +}) + +it('remounts instead of committing when an ordinary field parses into a fresh class instance', async () => { + const ctx = await runtimeContext() + class Handle { constructor(public readonly name: string) {} } + const schema = z.object({ handle: z.transform(z.string(), value => new Handle(value)), live: z.string().volatile() }) + const mount = vi.fn() + const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, { handle: 'h', live: 'old' }) + const before = fiber.config as ReturnType + await update(fiber, { handle: 'h', live: 'new' }) + expect(mount).toHaveBeenCalledTimes(2) + expect(before.live.get()).toBe('old') + expect((fiber.config as ReturnType).live.get()).toBe('new') +}) diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 63d887a098..dd281965df 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -78,6 +78,8 @@ interface GenericSkip { const GENERIC_SKIPS: readonly GenericSkip[] = [ // `Symbol.for('schemastery')` and the `vendor:` metadata field are upstream identifiers. { file: 'vendor/schemastery/src/index.ts', upstream: ['schemastery'] }, + // Narrows a Standard Schema by the same upstream `vendor:` identifier. + { file: 'vendor/loader/src/config/diff.ts', upstream: ['schemastery'] }, // Asserts the vendored-manifest table, which gains an upstream-name column. { file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) }, // `cordis` is also an agent-preset id — the directory name under @@ -172,6 +174,13 @@ const POSTCONDITIONS: readonly PostCondition[] = [ * quote a neighbouring line the generic pass would rewrite. */ const EXACT_EDITS: readonly ExactEdit[] = [ + { + id: 'loader-diff-schemastery-import', + file: 'vendor/loader/src/config/diff.ts', + find: "import type Schema from 'schemastery'", + replace: "import type Schema from '@deepseek-ai/schemastery'", + expect: 1, + }, { id: 'cordis-walk-merge-head', file: 'scripts/cordis-walk.ts', diff --git a/scripts/volatile-config.fixture.ts b/scripts/volatile-config.fixture.ts new file mode 100644 index 0000000000..a10df387fd --- /dev/null +++ b/scripts/volatile-config.fixture.ts @@ -0,0 +1,11 @@ +/** Shared volatile schema fixture for the schema and Loader volatile specs. */ +import z from '@deepseek-ai/schemastery' + +/** Mixed ordinary, volatile, nested and whole-object volatile fields. */ +export const Config = z.object({ + fixed: z.string().default('fixed'), + title: z.string().default('hello').volatile(), + optional: z.string().volatile(), + nested: z.object({ count: z.number().min(0).volatile().default(1) }).default({}), + group: z.object({ list: z.array(z.string()) }).default({ list: [] }).volatile(), +}) diff --git a/scripts/volatile-config.spec.ts b/scripts/volatile-config.spec.ts new file mode 100644 index 0000000000..75d2a5f1f6 --- /dev/null +++ b/scripts/volatile-config.spec.ts @@ -0,0 +1,151 @@ +/** Schemastery and Cordis config reference behavior through direct and Loader updates. */ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { type Volatile } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { Config } from './volatile-config.fixture.ts' +import { createVolatile, deepEqual, volatileEntries } from '@deepseek-ai/cosmokit' +import { redactSecrets } from '../packages/settings/settings/src/redact.ts' + +describe('volatile schemas', () => { + it('parses references with defaults and absent values while preserving schema metadata', () => { + const parsed = Config({}) + expect(parsed.title.get()).toBe('hello') + expect(parsed.optional.get()).toBeUndefined() + expect(parsed.nested.count.get()).toBe(1) + expectTypeOf(parsed.title).toEqualTypeOf>() + expectTypeOf(parsed.optional).toEqualTypeOf>() + expectTypeOf(parsed.nested.count).toEqualTypeOf>() + expectTypeOf(z.string().volatile().description('Live text').default('text')().get()).toEqualTypeOf() + expectTypeOf(z.string().required().volatile()('text').get()).toEqualTypeOf() + expect(Config.dict?.title?.type).toBe('string') + expect(Config.dict?.title?.meta.default).toBe('hello') + expect(Config.simplify(parsed)).toBeNull() + const reconstructed = new z(JSON.parse(JSON.stringify(Config.toJSON())) as z) + expect((reconstructed({ title: 'changed' }) as ReturnType).title.get()).toBe('changed') + expect(reconstructed.simplify(reconstructed({ title: 'changed' }))).toEqual({ title: 'changed' }) + expect(z.string().volatile().required()('present').get()).toBe('present') + expect(() => z.string().volatile().required()()).toThrow('missing required value') + expect(() => Config({ nested: { count: -1 } })).toThrow() + expect(Config({ nested: { count: -1 } }, { autofix: true }).nested.count.get()).toBe(1) + }) + + it('copies and freezes data snapshots without freezing the input', () => { + const input = { group: { list: ['a'] } } + const parsed = Config(input) + input.group.list.push('b') + expect(parsed.group.get()).toEqual({ list: ['a'] }) + expect(Object.isFrozen(parsed.group.get())).toBe(true) + expect(Object.isFrozen(parsed.group.get().list)).toBe(true) + expect(() => (parsed.group.get().list as string[]).push('c')).toThrow() + expect(Object.keys(parsed.group)).toEqual(['get']) + expect(() => z.any().volatile()(new Date())).toThrow('plain objects') + const cyclic: Record = {} + cyclic.self = cyclic + expect(() => z.any().volatile()(cyclic)).toThrow('cycles') + }) + + it.each([ + ['array', () => z.object({ items: z.array(z.string().volatile()) }), 'items'], + ['dict', () => z.object({ items: z.dict(z.string().volatile()) }), 'items'], + ['union', () => z.union([z.object({ title: z.string().volatile() }), z.string()]), 'title'], + ['tuple', () => z.object({ items: z.tuple([z.string().volatile()]) }), 'items'], + ['intersect', () => z.intersect([z.object({ title: z.string().volatile() })]), 'title'], + ['transform', () => z.transform(z.object({ title: z.string().volatile() }), value => value), 'title'], + ['nested', () => z.object({ block: z.object({ title: z.string().volatile() }).volatile() }), 'block'], + ])('rejects %s placements even when input omits the field', (_name, schema, path) => { + expect(() => schema()({})).toThrow(path) + }) + + it('rejects repeat wrapping and supports an entire array or union as a value', () => { + expect(() => z.string().volatile().volatile()).toThrow('already wrapped') + expect(z.array(z.string()).volatile()(['a']).get()).toEqual(['a']) + expect(z.union([z.string(), z.number()]).volatile()(1).get()).toBe(1) + const schema = z.string().volatile() + expect(schema('first')).not.toBe(schema('first')) + }) + + it('retains ordinary fields and secret traversal in serialized schemas', () => { + const schema = z.object({ block: z.object({ apiKey: z.string().role('secret'), title: z.string() }).volatile() }) + const restored = new z(JSON.parse(JSON.stringify(schema.toJSON())) as z) + expect(restored.dict?.block?.type).toBe('object') + expect(restored.dict?.block?.dict?.apiKey?.meta.role).toBe('secret') + expect(redactSecrets(restored as z, { block: { apiKey: 'secret', title: 'public' } }).value) + .toEqual({ block: { title: 'public' } }) + }) + + it('accepts metadata-free serialized nodes and checks their nested volatile declarations', () => { + const schema = new z<{ enabled: boolean }>(JSON.parse('{"type":"object","dict":{"enabled":{"type":"boolean"}}}') as z<{ enabled: boolean }>) + expect(schema({ enabled: true })).toEqual({ enabled: true }) + const dynamic = new z(JSON.parse( + '{"type":"object","dict":{"items":{"type":"array","inner":{"type":"string","meta":{"volatile":true}}}}}', + ) as z) + expect(() => dynamic({ items: [] })).toThrow('items') + }) + + it('resolves recursive lazy schemas only for the supplied data', () => { + interface Tree { name: string; children?: Tree[] } + function tree(): z { + return z.object({ name: z.string(), children: z.array(z.lazy(tree)).default([]) }) + } + expect(tree()({ name: 'root' })).toEqual({ name: 'root', children: [] }) + expect(tree()({ name: 'root', children: [{ name: 'leaf' }] })) + .toEqual({ name: 'root', children: [{ name: 'leaf', children: [] }] }) + const schema = z.object({ lazy: z.lazy(() => z.object({ live: z.string().volatile() })) }) + expect(() => schema({ lazy: { live: 'value' } })).toThrow('lazy') + }) +}) + +describe('volatile comparison semantics', () => { + it('ignores reference contents while retaining ordinary differences', () => { + const first = Config({ title: 'a' }) + const second = Config({ title: 'b' }) + expect(deepEqual(first, second, true)).toBe(true) + expect(deepEqual(first, Config({ fixed: 'changed' }), true)).toBe(false) + expect(deepEqual(first.title, 'a', true)).toBe(false) + expect(deepEqual('a', first.title, true)).toBe(false) + expect(deepEqual([undefined], [null], true)).toBe(false) + expect(deepEqual([undefined], [null])).toBe(true) + expect(deepEqual(new Date(0), new Date(0), true)).toBe(true) + expect(deepEqual(/a/g, /a/i, true)).toBe(false) + expect(deepEqual(new URL('https://a.example'), new URL('https://b.example'), true)).toBe(false) + expect(deepEqual(new URL('https://EXAMPLE.com:443/'), new URL('https://example.com'), true)).toBe(true) + expect(deepEqual(new URL('https://example.com'), {}, true)).toBe(false) + expect(deepEqual({}, new URL('https://example.com'), true)).toBe(false) + expect(deepEqual(new Uint8Array([1]).buffer, new Uint8Array([2]).buffer, true)).toBe(false) + const firstCycle: Record = {}; firstCycle.self = firstCycle + const secondCycle: Record = {}; secondCycle.self = secondCycle + expect(deepEqual(firstCycle, secondCycle, true)).toBe(false) + }) +}) + +it('enumerates references through shared config containers without descending into snapshots or cycles', () => { + const ref = createVolatile({ nested: 'value' }) + const shared = Object.assign(Object.create(null), { live: ref }) as Record + const config: Record = { list: [shared, shared], opaque: new URL('https://example.com'), plain: 'value' } + config.self = config + expect(volatileEntries(config)).toEqual([ + { path: ['list', '0', 'live'], ref }, + { path: ['list', '1', 'live'], ref }, + ]) + expect(volatileEntries(ref)).toEqual([{ path: [], ref }]) +}) + +it('compares sparse array slots as undefined in both directions', () => { + for (const strict of [true, false]) { + expect(deepEqual(Array(1), [undefined], strict)).toBe(true) + expect(deepEqual([undefined], Array(1), strict)).toBe(true) + expect(deepEqual(Array(1), ['new'], strict)).toBe(false) + expect(deepEqual(['new'], Array(1), strict)).toBe(false) + expect(deepEqual(Array(1), [], strict)).toBe(false) + expect(deepEqual([], Array(1), strict)).toBe(false) + } + expect(deepEqual(Array(1), [null], true)).toBe(false) + expect(deepEqual([null], Array(1), true)).toBe(false) +}) + +it('reports unsupported volatile values as validation issues with their path', () => { + const schema = z.object({ block: z.object({ when: z.date().volatile() }) }) + expect(() => schema({ block: { when: new Date(0) } })).toThrow(z.ValidationError) + expect(() => schema({ block: { when: new Date(0) } })).toThrow('$.block.when') + expect(schema({ block: {} }).block.when.get()).toBeUndefined() +}) diff --git a/vendor/README.md b/vendor/README.md index 4c9e1832ae..f6e31b6617 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -54,6 +54,8 @@ Keep this log exhaustive — every divergence from upstream must be listed. 21. **`cordis/src/logger.ts` exporter disposal**: each disposer retains its registration id, so removing an earlier exporter cannot delete a later console or telemetry exporter. Covered by startup collector cleanup in `packages/boot/app-boot/tests/app-boot.spec.ts` and disabled-feedback output in `packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts`. +22. **Volatile config across `cosmokit`, `schemastery`, `cordis`, and `loader`**: Cosmokit supplies immutable references and framework-owned commits; `deepEqual` treats two references as equal, propagates strict comparison into arrays, compares every array slot with holes treated as undefined, handles cycles conservatively, compares URLs by normalized href, and compares other opaque objects by identity in strict mode. Schemastery adds `.volatile()`, optional/default inference, placement validation without eager lazy-schema expansion (a lazy inner is validated once when built), metadata-free serialized node compatibility, serialized metadata, reference-aware simplification, unsupported volatile values reported as `ValidationError` with their path, and `.default()` accepting the raw input type `Partial` because a volatile field makes the output type carry references. Loader's save path calls `simplify` with its schema receiver (`unparse.call`), an independent fix for the unbound-`this` call. Cordis exports consumer types without changing Fiber lifecycle. Loader compares raw options strictly in `Entry.update`; for a config-only change on an active fiber it calls the pure `equalExceptVolatile` function in `config/diff.ts`, which narrows the Standard Schema to Schemastery's `Schema` type (a new type-only `@deepseek-ai/schemastery` dependency of `loader`) and recursively skips fixed volatile paths using schema metadata without executing config hooks or validation. Missing or null object nodes compare using their declared defaults, including nodes without volatile descendants; schema backedges retain raw comparison. Entry passes its actual schema and preserves ordinary context and custom-update lifecycles. For a volatile-only change, `Entry._commitVolatile` re-parses the raw config through the fiber's `internal/config` hook and `resolveConfig`, compares ordinary effective values, commits references and emits the instance-local `loader/volatile-update` declared in `loader/src/index.ts`; a changed ordinary effective value falls back to the ordinary remount, and an invalid candidate is logged while the raw config stays retained for the next activation. Cosmokit provides reference enumeration and commits, and `simplify` is called with its schema receiver. The package READMEs document these additions; schema tests live in `scripts/volatile-config.spec.ts`, Loader runtime and real-file tests in `scripts/loader-volatile-update.spec.ts`, and code replacement tests in the HMR module suite. + ## Sync procedure To update a vendored package from upstream: diff --git a/vendor/cordis/README.md b/vendor/cordis/README.md index 538809ddfb..d0181dbf76 100644 --- a/vendor/cordis/README.md +++ b/vendor/cordis/README.md @@ -99,3 +99,7 @@ yarn lint The monorepo uses Yakumo to build and test all packages. Most examples in the docs use public APIs from `cordis`; loader examples additionally use `@cordisjs/plugin-loader` and `@cordisjs/plugin-include`. + +## Volatile configuration + +Schemas may return `Volatile` references, whose values are read through `.get()`. Cordis exports the consumer types; Loader commits volatile-only changes into the running references without restarting and notifies the owning fiber through `loader/volatile-update`. Direct `fiber.update()` retains its existing update waterfall and default restart. diff --git a/vendor/cordis/src/index.ts b/vendor/cordis/src/index.ts index d0814213e0..5c27008b44 100644 --- a/vendor/cordis/src/index.ts +++ b/vendor/cordis/src/index.ts @@ -12,3 +12,5 @@ export * from './registry.ts' export * from './service.ts' /** Shared internal helpers used by context, services, and plugin fibers. */ export * from './utils.ts' +/** Readonly references returned by volatile config schemas. */ +export type { Volatile, VolatileSnapshot } from '@deepseek-ai/cosmokit' diff --git a/vendor/cosmokit/README.md b/vendor/cosmokit/README.md index 5398a146c2..7cdc1d1704 100644 --- a/vendor/cosmokit/README.md +++ b/vendor/cosmokit/README.md @@ -22,3 +22,9 @@ import cosmokit from 'cosmokit' ```ts import cosmokit from 'npm:cosmokit@latest' ``` + +## Config references + +`Volatile` exposes `get()` for immutable data snapshots. `createVolatile()` copies and freezes validated data; framework-owned `updateVolatile()` transfers a candidate snapshot into an existing reference. Framework-owned `volatileEntries()` enumerates references and their paths through plain config containers, stopping at snapshots and opaque objects. The shared symbol protocol recognizes references across ESM/CJS copies. Plugin consumers import the type from Cordis and retain the reference rather than caching values between operations. + +`deepEqual()` treats two volatile references as equal regardless of their snapshots. Strict comparisons distinguish null from undefined recursively, compare URLs by normalized `href` and other opaque objects by identity, and treat distinct cyclic structures as unequal. Array comparison visits every index and treats holes as undefined. Snapshot-change detection must compare `.get()` results separately. diff --git a/vendor/cosmokit/src/index.ts b/vendor/cosmokit/src/index.ts index 9fe48de069..b6b7144168 100644 --- a/vendor/cosmokit/src/index.ts +++ b/vendor/cosmokit/src/index.ts @@ -8,3 +8,5 @@ export * from './misc.ts' export * from './string.ts' /** Time constants, parsing, and formatting helpers. */ export * from './time.ts' +/** Immutable config references shared by schema validators and runtimes. */ +export * from './volatile.ts' diff --git a/vendor/cosmokit/src/types.ts b/vendor/cosmokit/src/types.ts index 499a46273a..0aab4420c1 100644 --- a/vendor/cosmokit/src/types.ts +++ b/vendor/cosmokit/src/types.ts @@ -1,4 +1,5 @@ import { isNullable } from './misc.ts' +import { isVolatile } from './volatile.ts' type GlobalConstructorNames = keyof { [K in keyof typeof globalThis as typeof globalThis[K] extends abstract new (...args: any) => any ? K : never]: K @@ -114,29 +115,54 @@ export function clone(source: any, refs = new Map()) { return result } -/** Deeply compare arrays, dates, regexps, buffers, and plain object fields. */ +/** + * Compare values recursively, treating two volatile references as equal regardless of value. + * Strict comparison distinguishes null/undefined, treats opaque objects by identity, + * compares URLs by normalized href, treats array holes as undefined, and considers distinct cyclic structures unequal. + * @param a - first value. + * @param b - second value. + * @param strict - whether to require strict data equality outside volatile references. + * @returns whether the values compare equal. + */ export function deepEqual(a: any, b: any, strict?: boolean): boolean { - if (a === b) return true - if (!strict && isNullable(a) && isNullable(b)) return true - if (typeof a !== typeof b) return false - if (typeof a !== 'object') return false - if (!a || !b) return false + const ancestors = new Set() + function compare(a: any, b: any): boolean { + if (a === b) return true + if (isVolatile(a) || isVolatile(b)) return isVolatile(a) && isVolatile(b) + if (!strict && isNullable(a) && isNullable(b)) return true + if (typeof a !== typeof b || typeof a !== 'object' || !a || !b) return false + if (ancestors.has(a)) return false - function check(test: (x: any) => x is T, then: (a: T, b: T) => boolean) { - return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : undefined + function check(test: (x: any) => x is T, then: (a: T, b: T) => boolean) { + return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : undefined + } + + ancestors.add(a) + try { + return check(Array.isArray, (a, b) => { + if (a.length !== b.length) return false + for (let index = 0; index < a.length; index++) { + if (!compare(a[index], b[index])) return false + } + return true + }) + ?? check(is('Date'), (a, b) => a.valueOf() === b.valueOf()) + ?? check(is('URL'), (a, b) => a.href === b.href) + ?? check(is('RegExp'), (a, b) => a.source === b.source && a.flags === b.flags) + ?? check(isArrayBufferLike, (a, b) => { + if (a.byteLength !== b.byteLength) return false + const viewA = new Uint8Array(a) + const viewB = new Uint8Array(b) + for (let i = 0; i < viewA.length; i++) { + if (viewA[i] !== viewB[i]) return false + } + return true + }) + ?? ((!strict || [a, b].every(value => Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) + && Object.keys({ ...a, ...b }).every(key => compare(a[key], b[key]))) + } finally { + ancestors.delete(a) + } } - - return check(Array.isArray, (a, b) => a.length === b.length && a.every((item, index) => deepEqual(item, b[index]))) - ?? check(is('Date'), (a, b) => a.valueOf() === b.valueOf()) - ?? check(is('RegExp'), (a, b) => a.source === b.source && a.flags === b.flags) - ?? check(isArrayBufferLike, (a, b) => { - if (a.byteLength !== b.byteLength) return false - const viewA = new Uint8Array(a) - const viewB = new Uint8Array(b) - for (let i = 0; i < viewA.length; i++) { - if (viewA[i] !== viewB[i]) return false - } - return true - }) - ?? Object.keys({ ...a, ...b }).every(key => deepEqual(a[key], b[key], strict)) + return compare(a, b) } diff --git a/vendor/cosmokit/src/volatile.ts b/vendor/cosmokit/src/volatile.ts new file mode 100644 index 0000000000..a64c35f253 --- /dev/null +++ b/vendor/cosmokit/src/volatile.ts @@ -0,0 +1,86 @@ +/** Shared config references used by schema validators and plugin runtimes. */ + +const write = Symbol.for('cosmokit.volatile.write') + +/** Recursively readonly data returned by a volatile config reference. */ +export type VolatileSnapshot = T extends object ? { readonly [K in keyof T]: VolatileSnapshot } : T + +/** A stable reference; keep the reference, or capture its value for one operation only. */ +export interface Volatile { + /** @returns the current immutable snapshot, including undefined for an absent value. */ + get(): VolatileSnapshot +} + +interface WritableVolatile extends Volatile { + [write](value: unknown): void +} + +function snapshot(value: unknown, ancestors = new Set()): unknown { + if (typeof value === 'function') throw new TypeError('volatile config cannot contain functions') + if (value === null || typeof value !== 'object') return value + if (ancestors.has(value)) throw new TypeError('volatile config cannot contain cycles') + ancestors.add(value) + try { + if (Array.isArray(value)) return Object.freeze(value.map(item => snapshot(item, ancestors))) + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) { + throw new TypeError('volatile config objects must be plain objects or arrays') + } + return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, item]) => [key, snapshot(item, ancestors)]))) + } finally { + ancestors.delete(value) + } +} + +/** + * Create a detached reference containing an immutable copy of the supplied data. + * @param value - validated config data; class instances and functions are unsupported. + * @returns a reference whose value is updated only by its owning runtime. + */ +export function createVolatile(value: T): Volatile { + let current = snapshot(value) + return Object.freeze({ + get: () => current, + [write]: (value: unknown) => { current = value }, + }) as Volatile +} + +/** + * Identify references across ESM/CJS copies of the shared library. + * @param value - a parsed config value. + * @returns whether the value implements the shared reference protocol. + */ +export function isVolatile(value: unknown): value is Volatile { + return typeof value === 'object' && value !== null && write in value +} + +/** + * Collect config references without descending into their snapshots or opaque objects. + * @internal + * @param value - parsed config; cyclic ordinary fields are visited once per path. + * @returns references and their object-key paths, including an empty path for a root reference. + */ +export function volatileEntries(value: unknown): { path: string[]; ref: Volatile }[] { + const ancestors = new Set() + function visit(value: unknown, path: string[]): { path: string[]; ref: Volatile }[] { + if (isVolatile(value)) return [{ path, ref: value }] + if (!value || typeof value !== 'object' || ancestors.has(value)) return [] + if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return [] + ancestors.add(value) + try { + return Object.entries(value).flatMap(([key, child]) => visit(child, [...path, key])) + } finally { + ancestors.delete(value) + } + } + return visit(value, []) +} + +/** + * Commit an already validated immutable snapshot from another reference. + * @internal + * @param target - the owning plugin's stable reference. + * @param source - a newly parsed candidate reference. + */ +export function updateVolatile(target: Volatile, source: Volatile): void { + ;(target as WritableVolatile)[write](source.get()) +} diff --git a/vendor/loader/README.md b/vendor/loader/README.md index fb71b774a8..9001b2a8c2 100644 --- a/vendor/loader/README.md +++ b/vendor/loader/README.md @@ -46,3 +46,7 @@ root.loader.update(id, { config: { enabled: false } }) | `loader.locate(fiber?)` | Return the loader entry id that owns a fiber. | For file-backed trees, use `@cordisjs/plugin-include`. + +## Volatile configuration + +`Entry.update` compares raw entry options strictly; when only `config` changed on an active fiber in an unchanged context, the pure `equalExceptVolatile` function in `config/diff.ts` compares the raw configs using Schemastery metadata. It ignores volatile fields at fixed object paths without executing expressions, validators or `internal/config` hooks. Object nodes use their declared defaults for null or absent values, including objects without volatile fields; equivalent defaults skip updates. Other ordinary values, parent expressions and unknown fields retain raw comparison. Recursive schema backedges use raw comparison. Entry passes the actual plugin schema; a non-Schemastery schema compares raw. Context changes, other option changes and pending fibers use ordinary updates. A volatile-only change retains the raw Fiber config for later activation, then `_commitVolatile` parses it through the fiber's `internal/config` hook and schema, commits the new values into the running references when every ordinary effective value still matches, and emits `loader/volatile-update` to the owning fiber with the changed paths. A changed ordinary effective value uses the ordinary remount with a debug log naming the entry; because strict comparison treats class instances other than URL, Date and RegExp by identity, an ordinary field whose transform builds a fresh instance on every parse always takes that remount. An invalid candidate is logged and leaves the running references unchanged. A throwing `loader/volatile-update` listener is logged and does not fail the update; the values are already committed. Group/Include retain child dispatch, and programmatic Fiber updates preserve saving and `noSave`; `simplify` is called with its schema receiver so saved config contains plain values. diff --git a/vendor/loader/package.json b/vendor/loader/package.json index c8ad7bcd98..6ae5a43e9b 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -39,6 +39,7 @@ } }, "dependencies": { - "@deepseek-ai/cosmokit": "workspace:^" + "@deepseek-ai/cosmokit": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/vendor/loader/src/config/diff.ts b/vendor/loader/src/config/diff.ts new file mode 100644 index 0000000000..e97d53e5ad --- /dev/null +++ b/vendor/loader/src/config/diff.ts @@ -0,0 +1,45 @@ +/** Compare raw plugin configs using schema metadata without executing config hooks or validators. */ +import type { Plugin } from '@deepseek-ai/cordis' +import { deepEqual } from '@deepseek-ai/cosmokit' +import type Schema from '@deepseek-ai/schemastery' +import { isJsExpr } from './utils.ts' + +function isSchemastery(schema: Plugin.Runtime['Config']): schema is Schema { + return schema?.['~standard'].vendor === 'schemastery' +} + +function isRecord(value: unknown): value is Record { + if (!value || typeof value !== 'object' || isJsExpr(value)) return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function equal(a: unknown, b: unknown, schema: Schema | undefined, ancestors: Set): boolean { + if (schema?.meta?.volatile) return true + if (schema?.type !== 'object' || !schema.dict || ancestors.has(schema)) return deepEqual(a, b, true) + const left = a ?? schema.meta?.default + const right = b ?? schema.meta?.default + if (!isRecord(left) || !isRecord(right)) return deepEqual(left, right, true) + const { dict } = schema + ancestors.add(schema) + try { + return Object.keys({ ...left, ...right }).every(key => + equal(left[key], right[key], Object.hasOwn(dict, key) ? dict[key] : undefined, ancestors), + ) + } finally { + ancestors.delete(schema) + } +} + +/** + * Compare two raw configs, treating schema-declared volatile fields at fixed object paths as equal and absent objects as their schema default. + * Schema backedges, expressions, unknown fields and opaque values keep strict raw equality; an absent or non-Schemastery schema compares everything raw. + * @param previous - previous raw config. + * @param next - next raw config. + * @param schema - the plugin's config schema. + * @returns Whether the configs differ at most in volatile fields, without evaluating expressions, validating config or modifying inputs. + * @internal + */ +export function equalExceptVolatile(previous: unknown, next: unknown, schema: Plugin.Runtime['Config']): boolean { + return isSchemastery(schema) ? equal(previous, next, schema, new Set()) : deepEqual(previous, next, true) +} diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 4aa2c473fb..8ab9f8e6a0 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -1,9 +1,10 @@ -import { Context, Fiber, Inject } from '@deepseek-ai/cordis' -import { deepEqual, isNullable } from '@deepseek-ai/cosmokit' +import { Context, Fiber, FiberState, Inject, resolveConfig } from '@deepseek-ai/cordis' +import { deepEqual, isNullable, updateVolatile, volatileEntries, type Volatile } from '@deepseek-ai/cosmokit' import { Loader } from '../index.ts' import { EntryGroup } from './group.ts' import { EntryTree } from './tree.ts' import { evaluate, isJsExpr } from './utils.ts' +import { equalExceptVolatile } from './diff.ts' /** Serialized plugin entry options stored in loader config files. */ export interface EntryOptions { @@ -137,17 +138,62 @@ export class Entry { // step 3: check if options are changed if (this.fiber?.uid) { - const diff = Object - .keys({ ...this.options, ...legacy }) - .filter(key => !deepEqual(this.options[key], legacy[key])) - if (!diff.length && !force) return + const changes = Object.keys({ ...this.options, ...legacy }) + .filter(key => !deepEqual(this.options[key], legacy[key], key === 'config')) + // Only an active fiber in an unchanged context takes volatile-only config changes without a remount. + const volatileOnly = changes.length === 1 && changes[0] === 'config' + && this.fiber.state === FiberState.ACTIVE && Object.getPrototypeOf(this.ctx) === this.parent.ctx + && equalExceptVolatile(legacy.config, this.options.config, this.fiber.runtime?.Config) + if (volatileOnly) this.fiber._config = this.options.config + const pending = volatileOnly && this._commitVolatile() ? [] : changes + if (!pending.length && !force) return this.context.emit('loader/partial-dispose', this, legacy, true) - this._patchContext(diff) + this._patchContext(pending) } else { await this.init() } } + /** + * Parse a volatile-only raw config change and commit its values into the running fiber's references. + * An invalid candidate is logged and leaves the running references unchanged; the raw config stays retained for the next activation. + * @returns `false` when an ordinary effective value changed, so the caller applies the ordinary update lifecycle. + */ + private _commitVolatile(): boolean { + const fiber = this.fiber! + const refs = volatileEntries(fiber.config) + if (!refs.length) return true + const raw = this.options.config + let candidate: unknown + try { + candidate = resolveConfig(fiber.runtime!, fiber.ctx.waterfall(fiber, 'internal/config', raw, () => raw)) + } catch (error) { + this.ctx.logger.warn('volatile config update failed for %C', this.options.id) + this.ctx.logger.warn(error) + return true + } + if (!deepEqual(fiber.config, candidate, true)) { + this.ctx.logger.debug('ordinary config values of %C changed with its volatile values; applying the ordinary update', this.options.id) + return false + } + const paths = refs.flatMap(({ path, ref }) => { + const source = path.reduce((value, key) => Reflect.get(value as object, key), candidate) as Volatile + if (deepEqual(ref.get(), source.get(), true)) return [] + updateVolatile(ref, source) + return [path] + }) + if (!paths.length) return true + const self: Context = Object.create(fiber.ctx) + self[Context.filter] = (owner: Context) => owner.fiber === fiber + try { + fiber.ctx.emit(self, 'loader/volatile-update', paths) + } catch (error) { + // A listener failure must not fail the entry update; every value is already committed. + this.ctx.logger.warn(error) + } + return true + } + getOuterStack = () => { let entry: Entry | undefined = this const result: string[] = [] diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index ac736ae356..51d36260a8 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -26,6 +26,18 @@ declare module '@deepseek-ai/cordis' { 'loader/config-update'(): void 'loader/entry-init'(entry: Entry): void 'loader/partial-dispose'(entry: Entry, legacy: Partial, active: boolean): void + /** + * Volatile config values were committed into the running fiber without a remount; dispatched to the owning fiber only. + * @param paths - changed config paths as key arrays; every value is committed before dispatch. + * @mode emit + */ + 'loader/volatile-update'(paths: readonly (readonly string[])[]): void + /** + * Refresh entry context before applying config. + * @param entry - entry containing the new raw config and optional current fiber. + * @param next - continue context refresh and Loader's config update. + * @mode waterfall + */ 'loader/patch-context'(entry: Entry, next: () => void): void } @@ -103,7 +115,7 @@ export class Loader extends EntryTree { ctx.on('internal/update', function (config, noSave, next) { if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next() const unparse = this.runtime?.Config?.['simplify'] - this.entry.options.config = unparse ? unparse(config) : config + this.entry.options.config = unparse ? unparse.call(this.runtime!.Config, config) : config this.entry.parent.tree.write() return next() }, { global: true, prepend: true }) diff --git a/vendor/loader/tsconfig.json b/vendor/loader/tsconfig.json index 2db62c7b63..341a8f0949 100644 --- a/vendor/loader/tsconfig.json +++ b/vendor/loader/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../cordis" + }, + { + "path": "../schemastery" } ] } diff --git a/vendor/schemastery/README.md b/vendor/schemastery/README.md index 24fdf3662f..54972ce4c6 100644 --- a/vendor/schemastery/README.md +++ b/vendor/schemastery/README.md @@ -387,3 +387,7 @@ const schema2 = new Schema(JSON.parse(JSON.stringify(schema1))) Schemastery also exposes the Standard Schema `~standard` property, so compatible tools can validate values without depending on Schemastery-specific APIs. + +## Volatile configuration + +`.volatile()` preserves a field's schema type and metadata while parsing its value into a stable reference read with `.get()`. Defaults remain ordinary data; absent optional fields return references containing `undefined`. `simplify()` unwraps references for persistence. See the [configuration guide](../../docs/cordis-tutorial/05-config.md#volatile-fields) for supported placements and Cordis update semantics. diff --git a/vendor/schemastery/src/index.ts b/vendor/schemastery/src/index.ts index 56a499e1cc..6edc132c49 100644 --- a/vendor/schemastery/src/index.ts +++ b/vendor/schemastery/src/index.ts @@ -1,4 +1,5 @@ import { Binary, clone, deepEqual, filterKeys, isNullable, isPlainObject, pick, valueMap, type Dict } from '@deepseek-ai/cosmokit' +import { createVolatile, isVolatile, type Volatile } from '@deepseek-ai/cosmokit' import type { StandardSchemaV1 } from '@standard-schema/spec' const kSchema = Symbol.for('schemastery') @@ -9,7 +10,7 @@ declare global { /** Convert primitive constructors, constants, and existing schemas into a schema type. */ export type From = | X extends string | number | boolean ? Schema - : X extends Schema ? X + : X extends Schema ? X : X extends typeof String ? Schema : X extends typeof Number ? Schema : X extends typeof Boolean ? Schema @@ -17,8 +18,8 @@ declare global { : X extends Constructor ? Schema : never - type TypeS1 = X extends Schema ? S : never - type Inverse = X extends Schema ? (arg: Y) => void : never + type TypeS1 = X extends Schema ? S : never + type Inverse = X extends Schema ? (arg: SchemaOutput) => void : never /** Input type accepted by a schema-like value. */ export type TypeS = TypeS1> @@ -28,7 +29,7 @@ declare global { export type Resolve = (data: any, schema: Schema, options: Options, strict?: boolean) => [any, any?] /** Input type accepted by one schema in an intersection. */ - export type IntersectS = From extends Schema ? S : never + export type IntersectS = From extends Schema ? S : never /** Output type returned by one schema in an intersection. */ export type IntersectT = Inverse> extends ((arg: infer T) => void) ? T : never @@ -85,8 +86,8 @@ declare global { dict = Schema>(inner: X, sKey?: Y): Schema, TypeS>, Dict, TypeT>> /** Accept tuple arrays where each index matches the corresponding schema. */ tuple(list: X): Schema, TupleT> - /** Accept plain objects whose declared properties match the schema dictionary. */ - object(dict: X): Schema, ObjectT> + /** Accept plain objects; infer fields from the dictionary, not the enclosing schema's output type. */ + object(dict: X): Schema>, ObjectT>> /** Accept values matching at least one schema in `list`. */ union(list: readonly X[]): Schema, TypeT> /** Accept values matching every schema in `list`, merging object outputs. */ @@ -94,7 +95,7 @@ declare global { /** Validate with `inner`, then convert the result with `callback`. */ transform(inner: X, callback: (value: TypeS, options: Schemastery.Options) => T, preserve?: boolean): Schema, T> /** Defer construction of a recursive schema until validation or serialization. */ - lazy(callback: () => X): X + lazy>(callback: () => X): X ValidationError: typeof ValidationError } @@ -112,6 +113,8 @@ declare global { export interface Meta { default?: T extends {} ? Partial : T required?: boolean + /** Parse this node as a stable config reference; its type and UI metadata remain unchanged. */ + volatile?: boolean disabled?: boolean collapse?: boolean badges?: { text: string; type: string }[] @@ -130,9 +133,9 @@ declare global { } /** Callable schema instance that validates input and returns normalized output. */ - interface Schemastery { - (data?: S | null, options?: Schemastery.Options): T - new (data?: S | null, options?: Schemastery.Options): T + interface Schemastery { + (data?: S | null, options?: Schemastery.Options): SchemaOutput + new (data?: S | null, options?: Schemastery.Options): SchemaOutput [kSchema]: true uid: number meta: Schemastery.Meta @@ -152,49 +155,54 @@ declare global { /** Format this schema as a compact TypeScript-like type string. */ toString(inline?: boolean): string /** Serialize this schema, preserving shared and recursive references. */ - toJSON(): Schema + toJSON(): Schema /** Mark nullable input as invalid unless a default supplies a fallback. */ - required(value?: boolean): Schema + required(value?: R): Schema> + /** + * Parse this config field as a stable reference containing immutable data. + * @returns a schema whose output supports get(), including when the field is absent. + */ + volatile(): Schema, NoInfer, Mode extends 'defined' | 'volatile-defined' ? 'volatile-defined' : 'volatile'> /** Hide this schema node from UI renderers. */ - hidden(value?: boolean): Schema + hidden(value?: boolean): Schema /** Return the default value instead of throwing when validation fails. */ - loose(value?: boolean): Schema + loose(value?: boolean): Schema /** Attach a renderer role and optional role-specific metadata. */ - role(text: string, extra?: any): Schema + role(text: string, extra?: any): Schema /** Attach an external documentation link. */ - link(link: string): Schema + link(link: string): Schema /** Set the fallback value used for nullable input. */ - default(value: T): Schema + default(value: T | NoInfer>): Schema> /** Attach an auxiliary comment for documentation or form UIs. */ - comment(text: string): Schema + comment(text: string): Schema /** Attach a localized or plain description for documentation or form UIs. */ - description(text: string): Schema + description(text: string): Schema /** Mark this schema node as disabled for form UIs. */ - disabled(value?: boolean): Schema + disabled(value?: boolean): Schema /** Request collapsed rendering for nested form UIs. */ - collapse(value?: boolean): Schema + collapse(value?: boolean): Schema /** Add a deprecated badge to this schema node. */ - deprecated(): Schema + deprecated(): Schema /** Add an experimental badge to this schema node. */ - experimental(): Schema + experimental(): Schema /** Require strings to match a regular expression. */ - pattern(regexp: RegExp): Schema + pattern(regexp: RegExp): Schema /** Set an inclusive maximum for numbers or collection lengths. */ - max(value: number): Schema + max(value: number): Schema /** Set an inclusive minimum for numbers or collection lengths. */ - min(value: number): Schema + min(value: number): Schema /** Set the numeric increment constraint. */ - step(value: number): Schema + step(value: number): Schema /** Add or replace an object property schema. */ - set(key: string, value: Schema): Schema + set(key: string, value: Schema): Schema /** Append a tuple, union, or intersection member schema. */ - push(value: Schema): Schema + push(value: Schema): Schema /** Remove values equal to schema defaults from normalized output. */ simplify(value?: any): any /** Return a schema clone with descriptions merged from locale messages. */ - i18n(messages: Dict): Schema + i18n(messages: Dict): Schema /** Attach arbitrary metadata consumed by form renderers and downstream tools. */ - extra(key: K, value: Schemastery.Meta[K]): Schema + extra(key: K, value: Schemastery.Meta[K]): Schema } } @@ -234,7 +242,14 @@ Object.defineProperty(ValidationError.prototype, kValidationError, { value: true, }) -type Schema = Schemastery +type SchemaMode = 'plain' | 'defined' | 'volatile' | 'volatile-defined' +type SchemaOutput = M extends 'volatile' ? Volatile + : M extends 'volatile-defined' ? Volatile : T +type SetRequired = M extends 'volatile' | 'volatile-defined' + ? R extends true ? 'volatile-defined' : 'volatile' + : R extends true ? 'defined' : 'plain' + +type Schema = Schemastery const Schema = function (options: Schema) { const schema = function (data: any, options: Schemastery.Options = {}) { @@ -405,6 +420,7 @@ Schema.prototype.pattern = function pattern(regexp) { } Schema.prototype.simplify = function simplify(this: Schema, value) { + if (isVolatile(value)) value = value.get() if (deepEqual(value, this.meta.default, this.type === 'dict')) return null if (isNullable(value)) return value if (this.type === 'object' || this.type === 'dict') { @@ -461,7 +477,36 @@ for (const key of ['default', 'link', 'comment', 'description', 'max', 'min', 's }) } +Schema.prototype.volatile = function volatile() { + if (this.meta.volatile) throw new TypeError('volatile schema is already wrapped') + return this.extra('volatile', true) +} + const resolvers: Dict = {} +const checkedVolatile = Symbol('checked-volatile-schema') + +function validateVolatileSchema(schema: Schema, path: (keyof any)[] = [], blocked = false, seen = new Map>()) { + const states = seen.get(schema) ?? new Set() + if (states.has(blocked)) return + states.add(blocked) + seen.set(schema, states) + if (schema.meta?.volatile && blocked) { + throw new ValidationError('volatile fields require a fixed object path without an enclosing volatile field', { path }) + } + const nested = blocked || !!schema.meta?.volatile + if (schema.dict) { + for (const [key, child] of Object.entries(schema.dict)) validateVolatileSchema(child, [...path, key], nested, seen) + } + if (schema.sKey) validateVolatileSchema(schema.sKey, [...path, ''], true, seen) + if (schema.inner && (schema.type !== 'lazy' || schema.inner[kSchema])) { + validateVolatileSchema(schema.inner, [...path, '*'], true, seen) + } + if (schema.list) { + for (let index = 0; index < schema.list.length; index++) { + validateVolatileSchema(schema.list[index]!, [...path, String(index)], true, seen) + } + } +} Schema.extend = function extend(type, resolve) { resolvers[type] = resolve @@ -469,6 +514,20 @@ Schema.extend = function extend(type, resolve) { Schema.resolve = function resolve(data, schema, options = {}, strict = false) { if (!schema) return [data] + if (!(options as Schemastery.Options & { [checkedVolatile]?: boolean })[checkedVolatile]) { + validateVolatileSchema(schema, options.path) + options = { ...options, [checkedVolatile]: true } as Schemastery.Options + } + if (schema.meta?.volatile) { + const inner = Schema(schema) + inner.meta = { ...schema.meta, volatile: false } + const [value, adapted] = Schema.resolve(data, inner, options, strict) + try { + return [createVolatile(value), adapted] + } catch (error) { + throw new ValidationError(error instanceof Error ? error.message : String(error), options) + } + } if (options.ignore?.(data, schema)) return [data] if (isNullable(data) && schema.type !== 'lazy') { @@ -582,6 +641,7 @@ Schema.extend('lazy', (data, schema, options, strict) => { if (!schema.inner![kSchema]) { schema.inner = schema.builder!() schema.inner!.meta = { ...schema.meta, ...schema.inner!.meta } + validateVolatileSchema(schema.inner!, options.path, true) } return Schema.resolve(data, schema.inner!, options, strict) }) @@ -706,7 +766,7 @@ function property(data: any, key: keyof any, schema: Schema, options: Schemaster } catch (e) { if (!options?.autofix) throw e delete data[key] - return schema.meta.default + return schema.meta.volatile ? createVolatile(schema.meta.default) : schema.meta.default } } From 4533be9531abe13662ecc869912a0672d015d2d6 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 00:32:43 +0800 Subject: [PATCH 240/284] fix(chat): address presentation review findings --- .../reasoning-preview/running.expected.md | 2 + apps/web/tests/reasoning-preview.e2e.ts | 98 +++++++++++++++++++ apps/web/tsconfig.json | 1 + packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 26 +++-- packages/client/ui-chat/README.zh.md | 26 +++-- .../src/client/chat/AssistantMarkdown.tsx | 2 +- .../ui-chat/src/client/chat/ReasoningRow.tsx | 2 +- packages/client/ui-chat/src/client/index.ts | 1 - .../ui-chat/src/client/presentation-policy.ts | 6 +- .../ui-chat/tests/chat-apply.client.spec.tsx | 6 ++ .../tests/reasoning-row.client.spec.tsx | 6 ++ packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 2 + packages/client/ui-tool/README.zh.md | 2 + .../client/tool/components/ToolRow.module.css | 2 +- .../tool/toolviews/bash-sample.module.css | 2 +- .../tests/tool-row-styles.client.spec.ts | 8 ++ tsconfig.host.json | 1 + 19 files changed, 175 insertions(+), 26 deletions(-) create mode 100644 apps/web/tests/expected/reasoning-preview/running.expected.md create mode 100644 apps/web/tests/reasoning-preview.e2e.ts diff --git a/apps/web/tests/expected/reasoning-preview/running.expected.md b/apps/web/tests/expected/reasoning-preview/running.expected.md new file mode 100644 index 0000000000..a7837c2d66 --- /dev/null +++ b/apps/web/tests/expected/reasoning-preview/running.expected.md @@ -0,0 +1,2 @@ +- text: Running +- 'button "Think Next paragraph: inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools"' diff --git a/apps/web/tests/reasoning-preview.e2e.ts b/apps/web/tests/reasoning-preview.e2e.ts new file mode 100644 index 0000000000..c69e4618f7 --- /dev/null +++ b/apps/web/tests/reasoning-preview.e2e.ts @@ -0,0 +1,98 @@ +/** Streaming reasoning previews through the assembled Chat and a paused model adapter. */ +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-agent-default-model' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, writeComposerDraft } from './support.ts' + +const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}` +const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail'] +const UI_EXPECTED = fileURLToPath(new URL('./expected/reasoning-preview/running.expected.md', import.meta.url)) + +class PausedReasoningAdapter extends LlmAdapter { + readonly stages = DELTAS.map(text => ({ + text, + arrived: Promise.withResolvers(), + proceed: Promise.withResolvers(), + })) + + release(): void { + for (const stage of this.stages) stage.proceed.resolve(undefined) + } + + override async *stream(options: GenerateOptions): AsyncIterable { + yield { type: 'block-start', index: 0, blockType: 'reasoning' } + for (const stage of this.stages) { + yield { type: 'reasoning-delta', index: 0, text: stage.text } + stage.arrived.resolve(undefined) + await stage.proceed.promise + options.signal?.throwIfAborted() + } + yield { type: 'block-end', index: 0, block: { type: 'reasoning', text: DELTAS.join('') } } + yield { type: 'block-start', index: 1, blockType: 'text' } + yield { type: 'text-delta', index: 1, text: 'Done' } + yield { type: 'block-end', index: 1, block: { type: 'text', text: 'Done' } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +it('shows completed paragraph first lines across blank lines with a right-edge fade', async () => { + const scaffold = await launchWebScaffold() + const adapter = new PausedReasoningAdapter() + try { + scaffold.ctx.effect(() => scaffold.ctx.llm.registerAdapter(['reasoning-preview-test'], adapter)) + await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'reasoning-preview-test', model: 'paused' }) + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser) + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + await page.setViewportSize({ width: 480, height: 1000 }) + const input = page.locator('[data-composer-input]').first() + const settled = scaffold.whenTurnSettled() + await writeComposerDraft(page, input, 'Show a streamed reasoning preview.') + await input.press('Enter') + + const [first, second, third] = adapter.stages + if (first === undefined || second === undefined || third === undefined) throw new Error('preview stages are incomplete') + await first.arrived.promise + const reasoning = page.locator('[data-variant="think"][data-state="running"]') + await reasoning.waitFor() + expect(await reasoning.getAttribute('data-preview')).toBeNull() + + first.proceed.resolve(undefined) + await second.arrived.promise + const preview = reasoning.locator('[data-streaming]') + await expect.poll(() => preview.textContent()).toBe('First paragraph') + expect(await preview.isVisible()).toBe(true) + await preview.evaluate((element) => { element.setAttribute('data-retained-preview', 'true') }) + + second.proceed.resolve(undefined) + await third.arrived.promise + await expect.poll(() => preview.textContent()).toBe(SUMMARY) + expect(await preview.getAttribute('data-retained-preview')).toBe('true') + expect(await preview.evaluate(element => getComputedStyle(element).maskImage)).toContain('linear-gradient') + expect(await preview.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true) + expect(await reasoning.getByRole('button').getAttribute('aria-expanded')).toBe('false') + await compareOrRefreshGolden(UI_EXPECTED, + await captureStableAria(page, '[data-variant="think"]', scaffold.workspaceCwd), webSnapshotMode()) + + third.proceed.resolve(undefined) + await settled + await page.getByText('Done', { exact: true }).waitFor() + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) + } finally { + adapter.release() + await browser.close() + } + } finally { + adapter.release() + await scaffold.close() + } +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4e8faba00b..719f04f24d 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -44,6 +44,7 @@ "tests/chat-scroll-fixture.ts", "tests/trajectory-virtualization.e2e.ts", "tests/lifecycle-chrome.e2e.ts", + "tests/reasoning-preview.e2e.ts", "tests/details-session-lifecycle.e2e.ts", "tests/sessionless-header.e2e.ts", "tests/document-preview.e2e.ts", diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 489c1e8723..1eefd3ba94 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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/ui-chat/README.md -README.md: 3a0e74332a0ee2c5453a024f1ffe9dfbb9ae0163 -README.zh.md: 89a385a61f08efa86c98d59fd4271f99c3b25210 +README.md: 2beb4dfadddec0c834c3ef6f4b56826be120baa6 +README.zh.md: 5d643f4e90b77d2b79ab1bb1c003b399c202e004 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 3a0e74332a..2beb4dfadd 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -8,14 +8,14 @@ English | [中文](README.zh.md) ## Summary -Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Compact display folds completed-turn process rows while keeping the final answer and independently useful context visible; packed historical Assistant runs remain collapsed. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests. +Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Work-details modes control reasoning previews and fold eligible completed-turn process rows without hiding final answers. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests. File-mention providers receive the viewed Session ID with the closing-turn owner, so links into inherited history can address the fork itself. ## Table of Contents - [Reference previews](#reference-previews) -- [System prompt row](#system-prompt-row) +- [Hidden Chat rows](#system-prompt-row) - [Command and failure rows](#command-and-failure-rows) - [Turn token usage](#turn-token-usage) - [Completed-turn footer](#completed-turn-footer) @@ -35,9 +35,9 @@ Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: Built-in browser (default) opens a new right-Sidebar Browser tab, while New browser tab opens an external tab. The setting is shown only while the built-in browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on loopback browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. -## System prompt row +## Hidden Chat rows -Each nonempty appended `system/message` owns a collapsed prompt row, including a complete prompt at the start of a headerless window; the same-step header does not duplicate it. Chat also shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, or `system/message` surface node replacement whose text differs, reading the last nonempty surviving system node in surface order at the `request/header`; a non-initial request whose preceding header is outside the loaded history window also shows one. A resume repeats the row even when its system text is unchanged, including after pagination supplies the preceding header and system node; same-series config-only or tool-only changes, tool steps, and retries create no repetition, and a `system/message` event is never rendered as a transcript message. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A request whose system node is empty or outside the loaded window creates no row until the page holding the node arrives. +Chat omits system-prompt and `permission` command rows in every work-details mode. The filter changes neither recorded Session events nor Trajectory inspection. Ordinary Context injection and other command rows remain in Chat; Context injection can fold with the owning Turn's process content. ## Command and failure rows @@ -65,11 +65,23 @@ The completed-turn action footer starts 20px below the preceding prose or extens ## Turn Process Folding -Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete Markdown; incoming answer text, Tool calls, and stream completion preserve that choice. Expanded reasoning uses compact secondary typography: headings add bold weight without changing text size, line height, or color. The collapsed summary stays on one line, follows the latest reasoning line while streaming, and shows the first line after settlement. +Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete Markdown; incoming answer text, Tool calls, and stream completion preserve that choice. Expanded reasoning uses compact secondary typography: headings add bold weight without changing text size, line height, or color. While streaming, all modes preview the latest paragraph whose first line ends with a newline. Further text in that paragraph does not change the preview; an unfinished single line has no preview. One or more blank lines separate paragraphs. The one-line preview fades at the right edge. After settlement, Compact hides the preview, while Detailed and Expanded show the full text's first line. Reasoning rows retain the disclosure's callback, icon, and unchanged content references. Each reasoning row selects its preview visibility from the display policy; mode changes toggle CSS display without unmounting the collapsed summary or updating its parent Assistant renderer. Full Markdown elements are created only while expanded. -Settings → General exposes a persisted, localized `Normal` / `Compact` conversation-display preference in the `ui-chat` namespace; `Compact` is the default. Normal leaves process rows visible and renders no Turn-process control. In Compact mode, the System prompt remains independently visible before the opening User throughout the Turn. Context injection, reasoning, Assistant material, Tool rows, and Retry rows remain expanded while a Turn is open. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls; zero-valued segments are omitted, the Tool and subagent figures are mutually exclusive, and neither System prompt nor Context injection contributes a count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, System prompt, error, max-token, and turn-tail rows stay outside, and a closed Turn with no final answer keeps all process evidence visible. A newly available process control is inserted without changing the relative order of existing rows: opening human input precedes the control and process rows from their first projection, while System prompt remains above that input. While older history remains available through Load earlier, process controls stay absent and no members are hidden; once history is complete, every eligible closed Turn uses the collapsed default immediately. Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the group open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed. +Settings → General → Work details stores `ui-chat.transcriptView` as `compact` (default), `detailed`, or `expanded`. A saved legacy `normal` value reads as `detailed` without being written back; it is not offered as an option. + +| Behavior | Compact | Detailed | Expanded | +|---|---|---|---| +| Settled reasoning preview | Think title only | First line | First line | +| Individual reasoning and tool bodies | Manual expansion | Manual expansion | Manual expansion | +| Eligible completed-Turn process | Folded by default | Folded by default | Folded by default | + +While a Turn is open, its process rows remain in the transcript; individual reasoning and tool disclosures retain their own expansion state. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block. If that Turn's `turn/start` is loaded, preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows fold by default. Folding eligibility is per Turn: a complete Turn can fold while Load earlier remains available, and a Turn whose start is missing keeps its process content visible until that start arrives. A closed Turn with no final answer also keeps all process evidence visible. + +The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls. Zero-valued segments are omitted; the Tool and subagent figures are mutually exclusive, and Context injection contributes no count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, error, max-token, and turn-tail rows stay outside. A newly available process control preserves existing row order, with opening human input before the control and process rows. + +Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the process open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed. Switching work-details modes preserves manual expansion. ----- @@ -104,6 +116,8 @@ None; Chat presentation does not assemble or mutate provider requests. +- **Secondary process grouping is not implemented** — Detailed and Expanded currently display the same Chat content. Expanded retains whole-Turn folding and does not automatically open individual reasoning or tool bodies. + - **Developer messages are not displayed** — presentation is intentionally deferred; encountering `developer/message` throws instead of rendering a fallback row. - **The transcript reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. Turn navigation is wider than the window: the rail merges the loaded Turns with the host `turnOutline` projection, so every started Turn gets a fixed-pitch mark (10px apart; a ladder taller than the frame scrolls inside it with gradient fades), and activating an unloaded mark pages history through the Turn's `turn/start` seq before landing on its row. Without the projection (assemblies not mounting `dsh-session-turn-outline`) the rail falls back to loaded Turns only. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 89a385a61f..5d643f4e90 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -8,14 +8,14 @@ kind: "package-reference" ## 概述 -使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。紧凑显示会收起已完成轮次的过程行,同时保持最终答案和独立有用的上下文可见;已打包的历史 Assistant 连续消息保持收起。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。 +使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。工作过程展示模式控制思考预览,并收起符合条件的已完成轮次过程行,不隐藏最终答案。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。 文件提及提供方同时接收当前查看的会话 ID 与收尾轮次的属主信息,因此继承历史中的链接可以指向 fork 自身。 ## 目录 - [引用预览](#reference-previews) -- [系统提示词行](#system-prompt-row) +- [Chat 隐藏的行](#system-prompt-row) - [指令与失败行](#command-and-failure-rows) - [轮次 token 用量](#turn-token-usage) - [已完成轮次的页脚](#completed-turn-footer) @@ -35,9 +35,9 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H 设置 → 通用设置 → 聊天链接打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「内置浏览器」(默认)打开新的右侧 Sidebar Browser tab,「浏览器新标签页」打开外部标签页。该设置项仅在内置浏览器可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在回环地址浏览器中持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 -## 系统提示词行 +## Chat 隐藏的行 -每个非空追加的 `system/message` 都拥有一行折叠提示词,包括无 header 窗口起点的完整提示词;同一步骤的 header 不会重复它。Chat 也会为非空的初始请求、显式消息序列起点、文本发生变化的 `system/message` surface 节点替换(文本读取自 `request/header` 处 surface 顺序中最后一个非空存活系统节点),或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的 `系统提示词`。即使系统文本未变,恢复也会重复该行,包括分页补齐前序 header 和系统节点后;同一序列内仅配置或仅工具变化、工具步骤与重试不会重复,且 `system/message` 事件绝不会渲染为对话消息。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示模型所见的确切文本,并保留其原始换行。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的分页到达。 +Chat 在所有工作过程展示模式下都不显示系统提示词行和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。普通上下文注入和其他命令行仍保留在 Chat 中;上下文注入可随所属轮次的过程内容一起收起。 ## 指令与失败行 @@ -65,11 +65,23 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H ## 轮次过程折叠 -每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。折叠摘要保持单行,在流式输出期间跟随最新思考行,结算后显示第一行。 +每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。流式输出期间,三档都预览最近一个首行以换行符结束的段落;该段落后续文本不会改变预览,未写完的单行不显示预览。一个或多个空行分隔段落。预览保持单行,右侧渐隐。结算后,「简洁」隐藏预览,「详细」和「完全展开」显示全文首行。 思考行保留折叠控件的回调、图标和未变化内容的引用。每个思考行从展示策略中选择预览可见性;模式切换只改变 CSS 显示状态,不卸载折叠摘要,也不更新其父级 Assistant 渲染器。完整 Markdown 元素仅在展开时创建。 -「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「标准」/「紧凑」对话显示偏好,默认使用「紧凑」。标准模式保持所有过程行可见且不渲染轮次过程控件。紧凑模式下,系统提示词在整个轮次中始终独立显示于开场 User 上方。轮次打开期间,上下文注入、推理、Assistant 内容、工具行与重试行始终展开。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件展示覆盖整个轮次的非 subagent 工具调用数、最终答案之前带回复内容的 Assistant 消息数和 subagent 委派数;值为 0 的分段省略,工具调用与 subagent 两项互斥,系统提示词与上下文注入都不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、系统提示词、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次保留全部过程证据。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词则始终位于该输入上方。只要仍可通过「加载更早」获取历史,过程控件就不出现,也不会隐藏任何成员;历史加载完整后,每个合格的已关闭轮次立即使用默认收起状态。稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域存储只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起。 +「设置 → 通用设置 → 工作过程展示」将 `ui-chat.transcriptView` 保存为 `compact`(默认)、`detailed` 或 `expanded`。已保存的旧值 `normal` 读取为 `detailed`,不自动回写,也不再作为选项提供。 + +| 行为 | 简洁 | 详细 | 完全展开 | +|---|---|---|---| +| 已完成思考的预览 | 只显示「思考」标题 | 首行 | 首行 | +| 单个思考与工具正文 | 手动展开 | 手动展开 | 手动展开 | +| 符合条件的已完成轮次过程 | 默认收起 | 默认收起 | 默认收起 | + +轮次打开期间,过程行保留在 transcript 中,单个思考与工具控件各自保留开合状态。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界。若该轮次的 `turn/start` 已加载,边界之前的上下文注入、思考、较早 Assistant 内容、工具行与重试行默认收起。折叠资格逐轮判断:即使仍可「加载更早」,完整轮次也能收起;缺少起点的轮次则保持过程内容可见,直到该起点加载。关闭时没有最终答案的轮次也保留全部过程证据。 + +控件展示覆盖整个轮次的非 subagent 工具调用数、最终答案之前带回复内容的 Assistant 消息数和 subagent 委派数。值为 0 的分段省略;工具调用与 subagent 两项互斥,上下文注入不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、错误、最大 token 与 turn-tail 行留在过程之外。新的过程控件保留既有行的相对顺序,开场人工输入位于控件和过程行之前。 + +稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域存储只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起。切换工作过程展示模式会保留手动展开状态。 ----- @@ -104,6 +116,8 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 +- **二级过程分组尚未实现**——「详细」与「完全展开」目前展示相同的 Chat 内容。「完全展开」仍保留整轮折叠,不会自动打开单个思考或工具正文。 + - **不展示 developer 消息** — 展示能力有意留待后续实现;遇到 `developer/message` 时抛出错误,不渲染回退行。 - **transcript 只反映已加载的 Session 窗口**——只有会话控制器加载前一页事件后,更早的 transcript node 才会出现。轮次导航比窗口更宽:轨道把已加载的轮次与宿主 `turnOutline` 投影合并,每个已开始的轮次都有固定间距刻度(相隔 10px;阶梯高于外框时在框内滚动并以渐变淡出标示可滚方向),激活未加载刻度会先把历史分页拉到该轮次的 `turn/start` seq 再落到它的行上。没有该投影时(未挂载 `dsh-session-turn-outline` 的装配),轨道回退到仅显示已加载轮次。 diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 967a7a09b1..da45e543a9 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -29,7 +29,7 @@ export interface AssistantMarkdownProps { interrupted?: boolean | undefined /** Render consecutive image blocks through the attachment slot. */ renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] - /** Hide reasoning that belongs to the Turn-level process disclosure or a collapsed step group. */ + /** Hide reasoning that belongs to the Turn-level process disclosure. */ reasoningHidden?: boolean | undefined /** Live display policy for reasoning summaries. */ usePresentation: UsePresentation diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index 53d1ab2a55..6e24934099 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -16,7 +16,7 @@ function firstLine(text: string): string { function latestCompletedParagraphFirstLine(text: string): string { let summary = '' let paragraphStart = 0 - const separator = /\r?\n[\t ]*\r?\n/g + const separator = /\r?\n(?:[\t ]*\r?\n)+/g while (true) { const nextParagraph = separator.exec(text) const paragraphEnd = nextParagraph === null ? text.length diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 6bef23a2c5..20e33d8885 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -31,7 +31,6 @@ export type { ChatStoreState, ToolCallId, TurnProcessViewEntry } from './contrac export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx' export type { TranscriptViewMode } from '../chat-settings.ts' export type { ChatPresentationPolicy } from './presentation-policy.ts' -export { derivePresentationPolicy, presentationPolicyFor } from './presentation-policy.ts' export type { AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected, ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps, diff --git a/packages/client/ui-chat/src/client/presentation-policy.ts b/packages/client/ui-chat/src/client/presentation-policy.ts index 9221afa711..c1af53db51 100644 --- a/packages/client/ui-chat/src/client/presentation-policy.ts +++ b/packages/client/ui-chat/src/client/presentation-policy.ts @@ -13,11 +13,7 @@ export interface ChatPresentationPolicy { readonly mode: TranscriptViewMode /** Whether a normally completed Turn folds its process rows behind the whole-Turn control. */ readonly foldCompletedTurns: boolean - /** - * How consecutive process rows between Assistant replies present: `collapsed` - * groups them behind a step-group row that starts closed; `none` renders - * every row directly under the whole-Turn control with no group rows. - */ + /** Reserved secondary-grouping preference; no renderer consumes this field yet. */ readonly stepGrouping: 'collapsed' | 'none' /** Whether a settled reasoning row previews its first line beside the Think title. */ readonly settledReasoningPreview: boolean diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index b1d06a825d..54d79ecb0d 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -88,6 +88,12 @@ function storeOf(runtime: SlotTestRuntime, key: 'conversation.session' | 'conver } describe('Chat apply wiring', () => { + it('keeps presentation-policy helpers out of the public browser entry', async () => { + const entry = await import('../src/client/index.ts') + expect(entry).not.toHaveProperty('derivePresentationPolicy') + expect(entry).not.toHaveProperty('presentationPolicyFor') + }) + it('contributes Chat View, node renderers, and stats', async () => { const b = await bench() const views = b.runtime.slots.entries('conversation.view') diff --git a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index c729a91634..e0ce4a1b1e 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -26,6 +26,12 @@ describe('ReasoningRow', () => { ['First\nSecond\nThird\n', 'First'], ['First\n\nNext unfinished', 'First'], ['First\n\nNext complete\nDetails', 'Next complete'], + ['First\n\n\nNext complete\n', 'Next complete'], + ['First\n \n\t\nNext complete\nDetails', 'Next complete'], + ['First\r\n\r\n\r\nNext complete\r\n', 'Next complete'], + ['First\r\n \r\n\t\r\nNext complete\r\nDetails', 'Next complete'], + ['First\n\n\nNext unfinished', 'First'], + ['\n\n\nFirst complete\n', 'First complete'], ['First\r\n \t\r\nNext complete\r\n', 'Next complete'], ['First\n\n\n', 'First'], ])('previews the completed paragraph first line for %j', (text, summary) => { diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index ce636be50c..72abda5bc3 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -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/ui-tool/README.md -README.md: 6a63c14d163742b1818320925248405831bd87b6 -README.zh.md: e53a97404ae17f0e93242c11dcc0b1f066542c12 +README.md: 3e216d9670f4fa58b0590f8cede54927d947d56d +README.zh.md: 6445ee9d62555de36901300809955b7cb227f66f diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 6a63c14d16..3e216d9670 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -27,6 +27,8 @@ English | [中文](README.zh.md) Tool calls appear in the conversation as cards: a root call tree with its nested subcalls, each atomic call rendered by its owning view. Every lifecycle state retains the tool's ordinary business glyph; failure and interruption remain explicit through the frozen call/result state, accessible status text, and failure summary. Users can open files or inspect calls through the Host callbacks. +Shared Tool rows and Bash rows retain error and warning colors for failed and stopped summaries, including on hover. Hover darkens only summaries without those states. + ### Registering a business tool view An owning business package registers its wire Tool name into `tool.call.toolview`: diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index e53a97404a..6445ee9d62 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 工具调用在对话中显示为卡片:一个根调用树带其嵌套子调用,每个原子调用由所属视图渲染。所有生命周期状态都保留工具的普通业务图标;失败与中断仍通过冻结调用/结果状态、无障碍状态文本和失败摘要明确表达。用户可通过宿主回调打开文件或检查调用。 +共享工具行和 Bash 行的失败、停止摘要在悬停时仍保留错误色和警告色;只有不处于这两种状态的摘要会在悬停时加深。 + ### 注册业务工具视图 拥有该视图的业务包将其 wire 工具名称注册进 `tool.call.toolview`: diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index 3bf04ae4ac..1478b06961 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -76,7 +76,7 @@ } .row:hover .title, -.row:hover .summary:not(.errorSummary), +.row:hover .summary:not(.errorSummary):not(.stoppedSummary), .row:hover .summarySuffix { color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css index be9b547ce6..9ef5b4b829 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css @@ -172,7 +172,7 @@ } .root:hover .title, -.root:hover .summary:not(.errorSummary) { +.root:hover .summary:not(.errorSummary):not(.stoppedSummary) { color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts index 28913a2939..db05c7f769 100644 --- a/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts +++ b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts @@ -22,6 +22,14 @@ function declarations(selector: string): string[] { } describe('ToolRow.module.css summary line', () => { + it.each([ + ['ToolRow', '../src/client/tool/components/ToolRow.module.css'], + ['BashRow', '../src/client/tool/toolviews/bash-sample.module.css'], + ] as const)('%s keeps stopped and error summaries outside hover darkening', (_name, path) => { + const sheet = readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8') + expect(sheet).toContain('.summary:not(.errorSummary):not(.stoppedSummary)') + }) + it('keeps the summary suffix on one line and unshrunk', () => { // `flex: none` stops the box shrinking, not the text wrapping: without // `nowrap`, a row too narrow for title + separator + suffix wraps the `+n` diff --git a/tsconfig.host.json b/tsconfig.host.json index e15bf83b24..01cca0cb14 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -42,6 +42,7 @@ "apps/web/tests/chat-scroll-fixture.ts", "apps/web/tests/trajectory-virtualization.e2e.ts", "apps/web/tests/lifecycle-chrome.e2e.ts", + "apps/web/tests/reasoning-preview.e2e.ts", "apps/web/tests/details-session-lifecycle.e2e.ts", "apps/web/tests/sessionless-header.e2e.ts", "apps/web/tests/document-preview.e2e.ts", From bf712e1404246d0001eafb74384ad0c70d548ccc Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 00:53:57 +0800 Subject: [PATCH 241/284] feat(dockkit): add stable retained tab layout --- .../ui-dockkit/src/components/DockSurface.tsx | 27 ++- .../ui-dockkit/src/components/FloatLayer.tsx | 96 ++++++----- .../ui-dockkit/src/components/TabLayout.tsx | 155 ++++++++++++++++++ .../ui-dockkit/src/components/TabPanel.tsx | 65 ++++---- .../src/components/dockkit.module.css | 77 +++++++++ .../ui-dockkit/src/components/pointer.ts | 4 + packages/client/ui-dockkit/src/index.ts | 2 + 7 files changed, 354 insertions(+), 72 deletions(-) create mode 100644 packages/client/ui-dockkit/src/components/TabLayout.tsx diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx index 6c3e0ced4d..17ca70f0c9 100644 --- a/packages/client/ui-dockkit/src/components/DockSurface.tsx +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -21,6 +21,7 @@ import { import { fitOf, measurePaneFits, paneElements, sameFits } from './measure.ts' import { useGesture } from './pointer.ts' import { PaneTree, type SizePreview } from './PaneTree.tsx' +import { TabLayout, type TabRetention } from './TabLayout.tsx' import type { PaneCallbacks, SplitBlock } from './render.ts' import css from './dockkit.module.css' @@ -161,10 +162,11 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean { } /** The split tree and the gestures over it. */ -export function DockSurface({ +function Surface({ state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, + draw, dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false, -}: DockSurfaceProps): ReactNode { +}: DockSurfaceProps & { readonly draw: (callbacks: PaneCallbacks, preview: SizePreview | undefined) => ReactNode }): ReactNode { const surface = useRef(null) const [preview, setPreview] = useState(NO_PREVIEW) const [fits, setFits] = useState(NO_FITS) @@ -289,7 +291,26 @@ export function DockSurface({ return (
    - + {draw(callbacks, preview.sizes)}
    ) } + +/** Recursive docked layout; floating content is rendered separately by FloatLayer. */ +export function DockSurface(props: DockSurfaceProps): ReactNode { + return + } /> +} + +/** Horizontal Sidebar layout with stable tab containers across docking and floating. */ +export type DockLayoutProps = DockSurfaceProps & TabRetention + +/** + * Render one pane or two horizontal panes, and their floats, in one stable content tree. + * @param props - layout, gestures and lazy body-retention policy. + * @returns the layout; the containing ancestors must not clip or establish a fixed-position containing block. + */ +export function DockLayout(props: DockLayoutProps): ReactNode { + return + } /> +} diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx index a1efe4ce40..4253880f0b 100644 --- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -18,7 +18,7 @@ import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconCloseOutlineRegular, IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts' -import type { FloatRect, LayoutState, PaneId, TabId } from '../contract/types.ts' +import type { FloatRect, LayoutState, PaneId, TabId, TabRecord } from '../contract/types.ts' import { FLOAT_MIN_SIZE } from '../engine/constraints.ts' import { movedRect, resizedRect } from '../engine/geometry.ts' import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts' @@ -65,8 +65,13 @@ function raised(state: LayoutState, paneId: PaneId): boolean { return state.activePaneId === paneId && state.floats.at(-1) === paneId } -/** Every floating panel, in z order. */ -export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode { +/** + * Shared move/resize gestures; changing layout mode never owns the content DOM. + * @param state - committed layout and float stacking order. + * @param intents - gesture settlements. + * @returns the current preview and focus/gesture callbacks. + */ +export function useFloatGestures(state: LayoutState, intents: DockIntents) { const [preview, setPreview] = useState<{ paneId: PaneId; rect: FloatRect } | undefined>(undefined) const begin = useGesture(() => { setPreview(undefined) }) @@ -93,6 +98,54 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, }) } + return { preview, raise, drag } +} + +/** + * Header-only controls, usable above either retained or visibility-mounted bodies. + * @param props - owning pane/tab, localized labels and gesture callbacks. + * @returns the float header without a content container. + */ +export function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canCloseTab, drag }: { + readonly paneId: PaneId + readonly tab: TabRecord + readonly labels: DockLabels + readonly intents: DockIntents + readonly renderTabTitle: TabRenderer | undefined + readonly canCloseTab: ((id: TabId) => boolean) | undefined + readonly drag: ReturnType['drag'] +}): ReactNode { + return ( +
    { drag('move', paneId, event) }}> +
    + {renderTabTitle?.(tab) ?? tab.title} +
    +
    + + + + {(canCloseTab?.(tab.id) ?? true) && ( + + + + )} +
    + ) +} + +/** Every floating panel, in z order. */ +export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode { + const { preview, raise, drag } = useFloatGestures(state, intents) + return ( <> {state.floats.map((paneId, depth) => { @@ -117,42 +170,7 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, }} onPointerDown={() => { raise(paneId) }} > -
    { drag('move', paneId, event) }} - > -
    - {renderTabTitle?.(tab) ?? tab.title} -
    -
    - - - - {(canCloseTab?.(tab.id) ?? true) && ( - - - - )} -
    +
    {renderTab(tab)}
    boolean + /** Whether this layout's Session is on screen; defaults to true. */ + readonly active?: boolean +} + +interface LayoutProps extends TabRetention { + readonly state: LayoutState + readonly callbacks: PaneCallbacks + readonly preview: SizePreview | undefined + readonly intents: DockIntents +} + +interface TabHostProps extends LayoutProps { + readonly tab: TabRecord + readonly pane: PaneNode + readonly column: number + readonly floats: ReturnType + readonly focusRequest: MutableRefObject<{ readonly tabId: TabId; readonly origin: Element | null } | undefined> +} + +/** A tab's ancestors stay identical across selection, pane moves and floating. */ +function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRequest, keepMounted, active = true }: TabHostProps): ReactNode { + const floating = pane.host === 'float' + const selected = floating || pane.activeTabId === tab.id + const visible = active && selected && (floating || state.expanded) + const retained = keepMounted?.(tab) ?? false + const [visited, setVisited] = useState(visible) + if (visible && !visited) setVisited(true) + const host = useRef(null) + const body = useRef(null) + useLayoutEffect(() => { + if (host.current !== null) host.current.inert = !visible + const focused = document.activeElement + if (!visible && focused instanceof HTMLElement && host.current?.contains(focused)) focused.blur() + const request = focusRequest.current + if (!visible || request?.tabId !== tab.id) return + focusRequest.current = undefined + // Preserve deliberate focus taken by another control or the newly shown body. + if (focused !== null && focused !== document.body && focused !== document.documentElement && focused !== request.origin) return + const strip = host.current?.querySelector('[data-dockkit-strip]') + const chip = [...strip?.querySelectorAll('[data-dockkit-tab]') ?? []] + .find(element => element.dataset.dockkitTab === tab.id) + chip?.focus({ preventScroll: true }) + }, [visible, tab.id, focusRequest]) + // Electron emits a non-bubbling focus event on the webview element. + useEffect(() => { + const element = body.current + if (element === null) return + const focus = (): void => { + if (!visible) return + if (floating) floats.raise(pane.id) + else if (state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) + } + element.addEventListener('focus', focus, true) + return () => { element.removeEventListener('focus', focus, true) } + }, [visible, floating, floats, pane.id, state.activePaneId, callbacks]) + const lifted = floating && floats.preview?.paneId === pane.id ? floats.preview.rect : undefined + const rect = floating ? lifted ?? floatRect(pane) : undefined + const depth = lifted === undefined ? state.floats.indexOf(pane.id) + 1 : state.floats.length + 1 + return ( + + ) +} + +/** + * One or two horizontal panes; CSS resolves content sizes from track fractions. + * @param props - committed layout, gesture preview and body retention policy. + * @returns stable tab containers, including floating tabs. + * @throws if the docked tree is not one pane or two horizontal panes. + */ +export function TabLayout(props: LayoutProps): ReactNode { + const { state, callbacks, preview } = props + const focusRequest = useRef<{ readonly tabId: TabId; readonly origin: Element | null }>() + const tabCallbacks: PaneCallbacks = { + ...callbacks, + onFocusTab: tabId => { + // Only changing the selected body replaces its strip; pane-only focus does not. + focusRequest.current = findTabPane(state, tabId).activeTabId === tabId + ? undefined : { tabId, origin: document.activeElement } + callbacks.onFocusTab(tabId) + }, + } + const root = getNode(state, state.rootId) + if (root.kind === 'split' && (root.axis !== 'row' || root.children.length !== 2)) { + throw new Error('DockLayout requires one pane or two horizontally split panes') + } + const panes = root.kind === 'pane' ? [root] : root.children.map(id => getPane(state, id)) + const sizes = root.kind === 'split' ? preview?.splitId === root.id ? preview.sizes : root.sizes : [1] + const floats = useFloatGestures(state, props.intents) + const columns = sizes.map(size => `minmax(0, ${size}fr)`).join(' 0px ') + return ( +
    + {/* Logical order changes must not make React move a connected webview's ancestor. */} + {Object.values(state.tabs).sort((a, b) => a.id.localeCompare(b.id)).map(tab => { + const pane = findTabPane(state, tab.id) + return candidate.id === pane.id)} floats={floats} focusRequest={focusRequest} /> + })} + {panes.filter(pane => pane.tabs.length === 0).map(pane => ( +
    + +
    + ))} + {root.kind === 'split' &&
    { callbacks.onDividerPressed(root.id, 0, event) }} />} +
    + ) +} diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx index ae7d2e6fa7..9c31c43fbe 100644 --- a/packages/client/ui-dockkit/src/components/TabPanel.tsx +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -208,8 +208,8 @@ function splitBlockedTitle(labels: PaneCallbacks['labels'], block: SplitBlock): } } -/** The pane's tab strip, split control, and body. */ -export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { +/** @param props - pane state and gestures. @returns its tab strip without a content container. */ +export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode { // The open context menu and the chip that opened it; the menu positions // itself against that chip from its portal. const [menu, setMenu] = useState<{ readonly tabId: TabId; readonly anchor: HTMLElement } | undefined>(undefined) @@ -218,15 +218,11 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { const stripTabs = useRef(null) useStripScrollFades(stripTabs, pane.tabs) useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId) - const active = pane.activeTabId === undefined ? undefined : getTab(state, pane.activeTabId) const block = callbacks.splitBlock(pane.id) const target = callbacks.dropTarget const stripIndex = target !== undefined && target.kind === 'strip' && target.paneId === pane.id ? target.index : undefined - const zone = target !== undefined && target.kind === 'zone' && target.paneId === pane.id - ? target.zone - : undefined /** Select a tab from a click or a key, unless it is the active pane's selected tab already: that changes nothing. */ const activate = (tabId: TabId): void => { @@ -242,19 +238,6 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { } return ( -
    { - if (state.activePaneId === pane.id) return - callbacks.onFocusPane(pane.id) - }} - >
    {pane.tabs.map((tabId, index) => { @@ -407,21 +390,43 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
    )}
    + ) +} + +/** @param props - target pane and drag preview. @returns body-local drop feedback, or nothing. */ +export function PaneDropHints({ pane, callbacks }: Pick): ReactNode { + const target = callbacks.dropTarget + const zone = target?.kind === 'zone' && target.paneId === pane.id ? target.zone : undefined + if (zone === undefined) return null + return <> +
    + {callbacks.horizontalDrops && zone !== 'center' + ? <> + + + + : } + +} + +/** @param props - pane state and gestures. @returns one pane in the recursive, visibility-mounted layout. */ +export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { + const active = pane.activeTabId === undefined ? undefined : getTab(state, pane.activeTabId) + return ( +
    { + if (state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) + }} + > +
    {active === undefined ?

    {callbacks.labels.emptyPane}

    : callbacks.renderTab(active)} - {zone !== undefined && ( - <> -
    - {callbacks.horizontalDrops && zone !== 'center' - ? <> - - - - : } - - )} +
    ) diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index fab875c5b6..fd24348a10 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -129,6 +129,82 @@ min-height: 0; } +/* Every tab remains a sibling. Explicit tracks determine placement; order only + changes painting, so raising a float never moves a connected DOM ancestor. */ +.tabLayout { + display: grid; + flex: 1 1 auto; + grid-template-rows: minmax(0, 1fr); + min-width: 0; + min-height: 0; +} + +.tabCell, +.emptyTabHost { + display: flex; + min-width: 0; + min-height: 0; + z-index: var(--dsh-dockkit-dock-layer, 10); +} + +.tabCell[hidden] { + display: none; +} + +/* A zero-sized Grid item supplies a stacking context, not a containing block + for its fixed child. Floats share one layer and use Grid paint order within it. */ +.floatingCell { + width: 0; + height: 0; + z-index: var(--dsh-dockkit-float-layer, 60); +} + +.tabHost { + min-width: 0; + min-height: 0; + pointer-events: auto; +} + +.tabHost:not(.float), +.emptyTabHost { + background: var(--dsw-alias-bg-base); +} + +.tabCell[data-dockkit-host='dock'][data-dockkit-column='0'] > .tabHost { + border-left: 0.5px solid var(--dsw-alias-border-l4); +} + +.tabHostHeader { + flex: none; +} + +.tabHostBody { + position: relative; + min-width: 0; +} + +.tabLayoutDivider { + width: 0; + cursor: col-resize; + z-index: calc(var(--dsh-dockkit-dock-layer, 10) + 1); + pointer-events: auto; +} + +.tabLayoutDivider::before { + top: 0; + bottom: 0; + left: -0.25px; + width: 0.5px; +} + +.tabLayoutDivider::after { + top: 0; + bottom: 0; + left: -4px; + right: -4px; + background: linear-gradient(to bottom, transparent, var(--dsw-alias-label-caption) 50%, transparent) center / 1px 100% no-repeat; +} + /* A body that overflows scrolls inside its pane; nothing escapes the pane. */ .pane { display: flex; @@ -234,6 +310,7 @@ :global(html[data-platform='darwin']) .stripTabs, :global(html[data-platform='darwin']) .stripChrome, :global(html[data-platform='darwin']) .paneBody, +:global(html[data-platform='darwin']) .float, :global(html[data-platform='darwin']) .divider { -webkit-app-region: no-drag; } diff --git a/packages/client/ui-dockkit/src/components/pointer.ts b/packages/client/ui-dockkit/src/components/pointer.ts index a3b368ea9b..bd2f136bf4 100644 --- a/packages/client/ui-dockkit/src/components/pointer.ts +++ b/packages/client/ui-dockkit/src/components/pointer.ts @@ -40,6 +40,10 @@ export function followPointer(element: HTMLElement, pointerId: number, followers capturePointer(element, pointerId) const controller = new AbortController() const { signal } = controller + element.dataset.dockkitPointer = String(pointerId) + signal.addEventListener('abort', () => { + if (element.dataset.dockkitPointer === String(pointerId)) delete element.dataset.dockkitPointer + }, { once: true }) const own = (event: PointerEvent): boolean => event.pointerId === pointerId window.addEventListener('pointermove', (event) => { if (own(event)) followers.move(event) }, { signal }) window.addEventListener('pointerup', (event) => { diff --git a/packages/client/ui-dockkit/src/index.ts b/packages/client/ui-dockkit/src/index.ts index f72adb9271..0632f84fe5 100644 --- a/packages/client/ui-dockkit/src/index.ts +++ b/packages/client/ui-dockkit/src/index.ts @@ -66,3 +66,5 @@ export { DockSurface } from './components/DockSurface.tsx' export type { DockSurfaceProps } from './components/DockSurface.tsx' export { FloatLayer } from './components/FloatLayer.tsx' export type { FloatLayerProps } from './components/FloatLayer.tsx' +export { DockLayout } from './components/DockSurface.tsx' +export type { DockLayoutProps } from './components/DockSurface.tsx' From 96b2aca346bbef31ab7f7ab5835e5b79e3592cb5 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 00:54:24 +0800 Subject: [PATCH 242/284] feat(sidebar): retain session views across tab presentation changes --- ...9-20-sidebar-retained-tab-layout.i18n.yaml | 6 ++ .../2026-09-20-sidebar-retained-tab-layout.md | 83 ++++++++++++++++ ...26-09-20-sidebar-retained-tab-layout.zh.md | 83 ++++++++++++++++ ...t-sidebar-docking-infrastructure.i18n.yaml | 4 +- ...04-right-sidebar-docking-infrastructure.md | 6 +- ...right-sidebar-docking-infrastructure.zh.md | 6 +- .../src/client/contract/slots.ts | 4 +- .../ui-sidebar-right/src/client/index.ts | 20 +++- .../src/client/session-view.ts | 86 ++++++++++++++++ .../src/client/session-views.ts | 90 +++++++++++++++++ .../src/client/shell/RightbarRoot.tsx | 44 ++++++--- .../src/client/shell/SidebarRight.module.css | 78 +++++++-------- .../src/client/shell/SidebarRight.tsx | 99 ++++++++----------- .../ui-sidebar-right/src/client/tab-info.ts | 7 +- .../src/client/tab-registry.ts | 2 + 15 files changed, 493 insertions(+), 125 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md create mode 100644 .agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.zh.md create mode 100644 packages/client/ui-sidebar-right/src/client/session-view.ts create mode 100644 packages/client/ui-sidebar-right/src/client/session-views.ts diff --git a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.i18n.yaml new file mode 100644 index 0000000000..657f035be9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md +2026-09-20-sidebar-retained-tab-layout.md: ed5404009d6d8844c54aef224eac2dd238dc7a4c +2026-09-20-sidebar-retained-tab-layout.zh.md: 0539dee38c9b2b9ecc1f5e9b8d1608dd9b5ea518 diff --git a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md new file mode 100644 index 0000000000..ed5404009d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md @@ -0,0 +1,83 @@ +# Agent Note: Stable Sidebar tab mounting and Session retention + +Status: implemented + +English | [中文](2026-09-20-sidebar-retained-tab-layout.zh.md) + +## Problem + +Restoring Session-specific tab records and URLs does not preserve a live browsing context. The recursive docked renderer mounts only an active body, a separate floating portal creates another body, and changing a Session binding remounts Session-scoped slots. Electron 44's `WebViewElement.disconnectedCallback` detaches and resets its guest, so preserving a React key or caching an element reference cannot compensate for disconnected ancestors. + +The first [Desktop Browser implementation](../feature/2026-09-20-desktop-browser-webview.md) keeps its guest under a fixed body-level parent and follows a placeholder with RAF measurements. User trials report working float, collapse and Session switches with that implementation; this change replaces its geometry mechanism, not a demonstrated failure of those interactions. Real parent layout must own content size, clipping and overlap. + +## Decision + +Sidebar owns live-body retention, independently of Browser navigation and Workspace storage. A retained page's DOM ancestors remain connected and unchanged during Session switches, tab selection, pane moves, collapse, floating and docking back. Browser Presentation attaches directly inside its content container and performs no placeholder measurement or overlay positioning. + +### Layout ownership + +- `DockLayout` renders the Sidebar's one or two horizontal panes with stable tab siblings. The existing recursive `DockSurface` and separate `FloatLayer` remain available for general split-tree rendering; the layout engine and serialized format are unchanged. +- Every tab has a stable Grid cell, frame, Header container and Body container. Explicit Grid columns determine docking placement; Flex assigns Header and Body height. Floating changes that same frame to `position: fixed` without changing its parent or replacing Body. +- A floating frame's zero-sized Grid cell establishes a stacking context at the float layer. CSS `order` expresses float depth within that layer; DOM order stays sorted by tab identity. Raising many floats cannot increment their layer past menus. Selection and strip order never reorder the content-node list. +- Common ancestors have no transform, clipping or enclosing stacking context. Docked cells own collapse transforms and visibility; each frame clips its content. Fullscreen changes the same root's width and docked layer. Existing window-drag region pulses remain, but no RAF copies content positions. +- Split and float gestures retain their existing measurements and operation state. These update user-selected split fractions and float rectangles, not a second Browser rectangle. Browser resize and content sizing use normal CSS layout. + +### Session and tab lifetime + +`SidebarRightTabDefinition.keepMounted` enables lazy retention for a type. Desktop Browser enables it; other types keep visibility-based body mounting. A retained body is first created on display, then survives hiding until its occurrence or provider lifetime ends. The policy is not persisted and does not select a storage partition. + +`SidebarSessionView` acquires and releases one `SessionReference` using the independent `sidebarView` source, and owns its committed-mount count and retained-Body holds. `SidebarSessionViews` manages selection, retention policy and view indexes; it delegates lifetime operations to the view rather than mutating its counts or releasing its reference. `RightbarRoot` receives `SidebarSessionViewSnapshot` values and the existing injected callbacks, not resource-owner objects. It renders each through its own explicit `SessionProvider`, with a stable Session key; the renderer's general Session-remount semantics remain unchanged. + +Tabs contribute Body-retention holds rather than independent Session references. Those holds follow the existing occurrence signal, including a new signal after close-and-undo; the view does not create a second tab lifetime. A retired view stays in the collection's reference index until its own disposal removes it, so plugin shutdown can also reach views whose React mounts have not finished. + +Only the selected Session reports frame occupancy or binds public Sidebar navigation. Background tab actions still address their own adopted store. `tab.visible` includes Session and global-panel visibility; floating does not make a background Session visible. A Session change or global panel hides its whole content tree, while Sidebar collapse hides only docked cells and leaves foreground floats visible. Hidden content is inaccessible to pointer and keyboard input, and hidden body focus is blurred. + +A background Session with no initialized retained body leaves the view list. Its reference releases after its committed React root unmounts, avoiding scope disposal underneath a mounted subtree. Plugin disposal releases all references, including retired views awaiting unmount. The Sidebar plugin depends on `sessions` and `uiSession`, so Cordis unloads it when either dependency ends; `ui-session` owns binding invalidation. Views do not register disposal callbacks on the shared Session Context. Catalog absence alone is not treated as deletion. Unvisited Browser tabs and unrelated Sessions are not eagerly opened. + +### Provider responsibilities + +`BrowserFrame` remains navigation and observable page state. Presentation creates and attaches the carrier DOM, without pane, clipping or stacking knowledge. Electron's provider receives physical attach/detach callbacks: hiding retained content emits neither, while an actual unmount cancels pending attachment and releases the guest. A subsequent physical mount recreates it from its known address. Disposal waits for pending creation and guest releases; an acquisition completing after cancellation is released rather than attached. + +The [Desktop Browser decision](../feature/2026-09-20-desktop-browser-webview.md) retains guest security, shared IPC declarations and Workspace partition ownership. Separate tabs and Sessions never share one page instance merely because they share a storage partition. Web remains disabled by default and retains its iframe carrier when explicitly enabled. + +### Retention versus recovery + +| Operation | Content handling | +|---|---| +| Switch tabs | Hide the old retained cell; display the target's existing body. | +| Split, reorder or move to another pane | Change logical placement and Grid assignment, without moving Body. | +| Float, dock back or raise | Change the same frame's CSS mode or cell paint order. | +| Collapse Sidebar | Hide docked cells; foreground floats remain visible. | +| Session A → B → A | Hide and reshow A's original tree instead of loading its saved URL. | +| Close, provider unload or actual generation termination | Release the body and guest; undo creates a new occurrence. | +| Application restart, window reload or guest crash | Use provider recovery or retry; page memory and native history are not durable. | + +[Layout and provider recovery](2026-09-14-sidebar-layout-provider-recovery.md) remains independent: persisted tab identity, placement and address support cold starts, not DOM serialization. A hidden page may continue scripts, media and networking. + +## Alternatives considered + +**Hide panes only.** Collapse works, but moving between panes or into another floating portal still changes content ancestors. + +**Retain bodies only in the foreground Session.** Session-slot remounting still disconnects the page and fails to preserve the existing Session-switch experience. + +**Give each tab its own Session reference.** A view's tabs share one `SessionProvider`, whose reference must exist before their bodies render. Per-view ownership covers that parent and its children without an additional reference lifecycle for each tab. + +**Cache and reparent DOM.** Ordinary reparenting invokes Electron disconnection. Atomic-move dependencies, private attachment APIs and lifecycle monkey-patches are not used. + +**Keep the fixed Browser overlay with event-driven measurement or CSS anchor positioning.** Content remains separate from the layout it imitates; the content itself must participate in Grid/Flex. + +**Use native popovers.** Top-layer elements cannot be covered by ordinary Portal content through `z-index`. Migrating menus and dialogs into another stacking system expands the work beyond Sidebar; the implementation preserves the existing Portal and layer model. + +**Raise floats by increasing their root z-index.** An unbounded float count can cross menu and modal layers. Equal-layer Grid cells with CSS paint order retain both stacking and DOM identity. + +**Save the URL and rebuild when hidden.** This cannot preserve forms, script state, scroll position or native history. It is recovery after loss, not live retention. + +## Consequences + +Layout correctness belongs to Sidebar/DockKit rather than Browser. The cost is stable content containers and explicit Session-reference ownership: background retention holds Session scopes and subscriptions as well as page memory. There is no implicit LRU or idle timeout; future reclamation requires an explicit suspension/recovery policy. Other tab types do not automatically acquire these costs. + +The change affects Sidebar chrome, float stacking, focus and platform styles, not just the Browser package. The owning [docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) and Desktop Browser notes remain active for their independent engine, navigation, storage and security decisions. Their separated-content presentation is superseded here; layout persistence and tab-type navigation remain applicable. + +## Verification + +Focused TypeScript compilation covers Sidebar, Browser and Desktop source. DockKit, Sidebar, Browser and Desktop bundles and the Web shell build complete. No tests or recordings were added or run, and the running Electron was not restarted. The stable-tree implementation still requires real Electron verification of guest identity, form and history retention, multi-float paint order, resizing, clipping, drop hints, focus and platform window controls. Compilation and bundling are not evidence that these runtime observations pass. diff --git a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.zh.md b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.zh.md new file mode 100644 index 0000000000..0539dee38c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.zh.md @@ -0,0 +1,83 @@ +# Agent Note: Sidebar 的稳定 Tab 挂载与会话保活 + +Status: implemented + +[English](2026-09-20-sidebar-retained-tab-layout.md) | 中文 + +## Problem + +恢复各会话的 Tab 记录和 URL,并不能保留活的浏览上下文。递归停靠渲染只挂载活动 Body,独立浮动 Portal 会创建另一个 Body,切换会话绑定还会重挂载会话级 slot。Electron 44 的 `WebViewElement.disconnectedCallback` 会 detach 并重置 guest,因此保持 React key 或缓存元素引用不能抵消祖先断开。 + +首版 [Desktop Browser 实现](../feature/2026-09-20-desktop-browser-webview.zh.md)把 guest 留在 body 下的固定父节点中,通过 RAF 测量跟随占位元素。用户试用该实现时报告浮动、收起和切会话均可工作;本次替换的是其几何处理方式,并非这些交互已被证实存在故障。内容尺寸、裁剪与重叠必须由真实父节点布局负责。 + +## Decision + +Sidebar 负责活 Body 的保留,不依赖 Browser 导航和 Workspace 存储。保活页面的 DOM 祖先在切会话、选 Tab、跨 Pane 移动、收起、浮动和回停靠时保持连接与身份不变。Browser Presentation 直接挂载到内容容器内,不做占位元素测量或浮层定位。 + +### 布局所有权 + +- `DockLayout` 用稳定的 Tab 兄弟节点渲染 Sidebar 的一个或两个水平 Pane。现有递归 `DockSurface` 和独立 `FloatLayer` 仍可用于通用分割树渲染;布局引擎和序列化格式不变。 +- 每个 Tab 都有稳定的 Grid cell、frame、Header 容器和 Body 容器。显式 Grid 列决定停靠归属,Flex 分配 Header 与 Body 的高度。浮动只把同一个 frame 改为 `position: fixed`,不改父节点或替换 Body。 +- 浮动 frame 的零尺寸 Grid cell 在浮窗层建立层叠上下文。CSS `order` 表达该层内的浮窗深度,DOM 顺序始终按 Tab 身份排序。大量浮窗置顶不会把层级递增到菜单之上;选中项和 Tab 条排序均不重排内容节点列表。 +- 共同祖先不设置 transform、裁剪或包住全部内容的层叠上下文。停靠 cell 负责收起 transform 和显隐,各 frame 裁剪自身内容。Fullscreen 修改同一个根的宽度和停靠层级。窗口拖动区域的既有脉冲仍保留,但没有 RAF 复制内容位置。 +- 分栏与浮窗手势沿用原有测量和操作状态,更新用户选择的分栏比例与浮窗矩形,而非维护第二份 Browser 矩形。窗口缩放和内容尺寸变化由普通 CSS 布局处理。 + +### 会话与 Tab 生命周期 + +`SidebarRightTabDefinition.keepMounted` 为类型启用懒保活。Desktop Browser 启用它,其他类型继续按可见性挂载 Body。保活 Body 在首次显示时创建,随后隐藏不卸载,直到 occurrence 或提供方生命周期结束。该策略不持久化,也不选择存储分区。 + +`SidebarSessionView` 使用独立的 `sidebarView` 来源申请、释放一个 `SessionReference`,并管理自己的已提交挂载计数和 Body 保活持有。`SidebarSessionViews` 管理选中、保留策略和视图索引,将生命周期操作委托给视图,而不修改其计数或释放其引用。`RightbarRoot` 接收 `SidebarSessionViewSnapshot` 和已有注入回调,不接收资源所有者对象;它为各视图分别渲染显式 `SessionProvider`,保持会话 key 稳定,渲染器通用的会话重挂载语义不变。 + +Tab 提供 Body 保活持有,而不独立申请会话引用。这些持有跟随已有 occurrence signal,包括关闭后撤销恢复得到的新 signal;视图不创建第二套 Tab 生命周期。退出视图列表的视图仍留在集合的引用索引中,直到自行释放时移除索引,因此插件关闭时也能清理尚未结束 React 挂载的视图。 + +只有选中的会话向 frame 报告占位、绑定公共 Sidebar 导航。后台 Tab actions 仍指向自己的已接管存储。`tab.visible` 包含会话和全局面板的可见性,浮动不会让后台会话变成可见。切会话或打开全局面板隐藏整个内容树,收起 Sidebar 则只隐藏停靠 cell,前台浮窗仍可见。隐藏内容不可通过指针或键盘访问,隐藏 Body 内的焦点会被移除。 + +后台会话没有已初始化的保活 Body 时,从视图列表移除;等已提交的 React 根卸载后再释放引用,避免仍挂载的子树下方先销毁会话 scope。插件释放会清理全部引用,包括等待卸载的已移除视图。Sidebar 插件依赖 `sessions` 和 `uiSession`,任一依赖结束时由 Cordis 卸载它,绑定失效由 `ui-session` 负责。视图不向共享会话 Context 注册销毁回调。仅在目录列表中缺项不视为删除。未访问的 Browser Tab 和无关会话不会提前打开。 + +### 提供方职责 + +`BrowserFrame` 仍只负责导航与可观察页面状态。Presentation 创建、挂载载体 DOM,不了解 Pane、裁剪或层叠。Electron 提供方接收物理 attach/detach 回调:隐藏保活内容不触发这些回调,真正卸载则取消未完成的挂载并释放 guest;之后再次物理挂载时从已知地址重建。dispose(资源释放)会等待未完成的创建和 guest 释放,取消后才完成的申请只释放、不挂载。 + +[Desktop Browser 决策](../feature/2026-09-20-desktop-browser-webview.zh.md)继续负责 guest 安全、共享 IPC 声明和 Workspace 分区所有权。不同 Tab 和会话不能因为共享存储分区就共享一个页面实例。Web 默认仍关闭,显式启用时继续使用 iframe 载体。 + +### 保活与恢复 + +| 操作 | 内容处理 | +|---|---| +| 切换 Tab | 隐藏旧的保活 cell,显示目标已有 Body。 | +| 分栏、排序或移到另一个 Pane | 改逻辑归属和 Grid 分配,不搬移 Body。 | +| 浮动、回停靠或置顶 | 改同一个 frame 的 CSS 模式或 cell 绘制顺序。 | +| 收起 Sidebar | 隐藏停靠 cell,前台浮窗保持可见。 | +| 会话 A → B → A | 隐藏并重新显示 A 的原内容树,而非加载已保存 URL。 | +| 关闭、提供方卸载或真实 generation 结束 | 释放 Body 和 guest;撤销关闭创建新 occurrence。 | +| 应用重启、窗口刷新或 guest 崩溃 | 走提供方恢复或重试;页面内存和原生 history 不持久化。 | + +[布局与提供方恢复](2026-09-14-sidebar-layout-provider-recovery.zh.md)仍然独立:持久化的 Tab 身份、位置和地址支持冷启动,而不是序列化 DOM。隐藏页面可能继续执行脚本、播放媒体或联网。 + +## Alternatives considered + +**只隐藏 Pane。** 可以覆盖收起,但跨 Pane 移动或进入独立浮动 Portal 仍会改变内容祖先。 + +**只保留前台会话的 Body。** 会话 slot 重挂载仍会断开页面,无法保留现有的切会话体验。 + +**每个 Tab 独立持有会话引用。** 同一视图的 Tab 共用一个 `SessionProvider`,它的引用必须先于这些 Body 渲染而存在。按视图持有即可覆盖父级及其子节点,无须再为每个 Tab 管理一份引用生命周期。 + +**缓存并搬移 DOM。** 普通 reparent 会触发 Electron 断开处理;实现不依赖原子搬移、私有挂载 API 或生命周期补丁。 + +**保留固定 Browser 浮层,改为事件驱动测量或 CSS 锚点定位。** 内容仍然与它模拟的布局分离,而内容本身必须参与 Grid/Flex。 + +**使用原生 popover。** 普通 Portal 内容无法通过 `z-index` 覆盖 top layer 元素。把菜单、对话框迁移到另一套层叠体系会扩大 Sidebar 之外的改动;实现保留既有 Portal 和层级模型。 + +**通过递增根 z-index 置顶浮窗。** 不设上限的浮窗数量可能越过菜单和模态层。同层 Grid cell 的 CSS 绘制顺序同时保留层叠与 DOM 身份。 + +**隐藏时保存 URL 再重建。** 无法保留表单、脚本状态、滚动位置和原生 history。这是丢失实例后的恢复,不是活页面保留。 + +## Consequences + +布局正确性由 Sidebar/DockKit 负责,而不是 Browser。代价是稳定的内容容器与显式会话引用所有权:后台保活不仅持有页面内存,也持有会话 scope 和订阅。没有隐式 LRU 或空闲超时,未来回收需要明确的暂停与恢复策略;其他 Tab 类型不会自动承担这些成本。 + +变更涉及 Sidebar 控件、浮窗层级、焦点和平台样式,不仅是 Browser 包。[停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)和 Desktop Browser 记录的引擎、导航、存储与安全决策仍独立有效,因此保留为活动记录;其中的分离内容呈现由本文替代,布局持久化和 Tab 类型导航继续适用。 + +## Verification + +定向 TypeScript 编译覆盖 Sidebar、Browser 和 Desktop 源码,DockKit、Sidebar、Browser、Desktop 打包及 Web 外壳构建完成。未添加或运行测试、未录制演示,也未重启正在运行的 Electron。稳定树实现仍需真实 Electron 验证 guest 身份、表单与 history 保留、多浮窗绘制顺序、缩放、裁剪、放置提示、焦点和平台窗口控件。编译与打包不代表这些运行时观察已通过。 diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml index 5e501e5f46..137c5eba63 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md -2026-09-04-right-sidebar-docking-infrastructure.md: 3ebfe4b3a95ab5820682098be2a4c43f172d1a02 -2026-09-04-right-sidebar-docking-infrastructure.zh.md: dc6516324a368dd86b3e01afb4051a4b20697363 +2026-09-04-right-sidebar-docking-infrastructure.md: c5bfb75730f2037826723bc7fd0aa2bb01005d1e +2026-09-04-right-sidebar-docking-infrastructure.zh.md: 4959032b53f84654205a1973aee781acb356f999 diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md index 3ebfe4b3a9..c5bfb75730 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md @@ -16,6 +16,8 @@ The right column is a per-session docking surface — split panes, tabs, floatin ### Package topology +The [stable Sidebar mounting decision](../architecture/2026-09-20-sidebar-retained-tab-layout.md) owns one retained tab-content tree for docking, floating and Session switches. The engine, layout state and gesture ownership in this note remain applicable. + | Package | Kind | Owns | |---|---|---| | `packages/client/ui-dockkit` | static-linked library, zero DSH dependencies | the layout engine and the React components that render and drive it; consumers compile its sources, and it keeps exactly one stylesheet because a consumer de-duplicates injected sheets by file name | @@ -33,7 +35,7 @@ Components render a snapshot and report settled intents, one per gesture: a drag [Responsive Sidebar and tab information](../architecture/2026-09-07-sidebar-responsive-tab-info.md) supersedes this note's no-concession layout, overlay presentation and product pane limit. `ui-layout` still owns three-column geometry and pixel width preferences; the Sidebar occupant reports presentation through `ctx.layout.openRightbar(track, fullscreen)` and `closeRightbar()`, without the frame injecting the Sidebar package. Exact width rules belong to [ui-layout](../../../../packages/client/ui-layout/README.md). -The right Sidebar uses one mounted content tree in normal and fullscreen modes; hiding preserves tab state, and fullscreen covers the viewport while retaining underlying column reservation. Floats still use viewport coordinates through a portal and remain open when the Sidebar closes. The product limits docking to two horizontal panes and a 20–80% divider; the generic engine keeps its own defaults. +The right Sidebar uses one mounted content tree in normal and fullscreen modes; hiding preserves retained tab state, and fullscreen covers the viewport while retaining underlying column reservation. `DockLayout` renders stable Grid cells with fixed-position floating frames in the same tree; foreground floats remain open when the Sidebar closes. The product limits docking to two horizontal panes and a 20–80% divider; the generic engine keeps its own defaults. ### State @@ -86,7 +88,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind - The Detail panel and its duplicate card presentation are gone (a net removal of roughly 1,400 lines); cards are read in place, and `inspect` opens the trajectory view. - The frame has no centre floor: a viewport narrower than the two edge columns squeezes the conversation toward zero instead of closing a column. - The kit is compiled by its consumers, so a kit change requires a shell rebuild and a page reload; there is no HMR for it. -- The panel, the float host, and the portalled tab menu use hard-coded z-index values; the client still has no z-index token layer. +- Docked cells, floating cells and the portalled tab menu use fixed stacking levels; floating cells use CSS paint order within their level. The client still has no global z-index token layer. ## Testing diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md index dc6516324a..4959032b53 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md @@ -16,6 +16,8 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 ### 包拓扑 +[Sidebar 稳定挂载决策](../architecture/2026-09-20-sidebar-retained-tab-layout.zh.md)负责停靠、浮动与会话切换共用的一棵保活 Tab 内容树。本文的引擎、布局状态与手势所有权仍然适用。 + | 包 | 形态 | 所有物 | |---|---|---| | `packages/client/ui-dockkit` | 静态链接库,零 DSH 依赖 | 布局引擎与渲染/驱动它的 React 组件;消费方编译其源码,且它只保留一张样式表,因为消费方按文件名去重注入的样式表 | @@ -33,7 +35,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 [响应式 Sidebar 与标签信息](../architecture/2026-09-07-sidebar-responsive-tab-info.zh.md)取代本记录中的无让步布局、覆盖模式与产品窗格上限。`ui-layout` 仍拥有三列几何与像素宽度偏好,Sidebar 占位项通过 `ctx.layout.openRightbar(track, fullscreen)` 和 `closeRightbar()` 报告呈现方式,框架不注入 Sidebar 包。具体宽度规则见 [ui-layout](../../../../packages/client/ui-layout/README.zh.md)。 -右栏在普通与全屏模式下使用同一棵已挂载内容树;隐藏保留标签状态,全屏覆盖视口并保留底层列占位。浮窗仍经 portal 使用视口坐标,不随右栏关闭。产品限制为两个水平窗格与 20–80% 分割比例,通用引擎保留自己的默认值。 +右栏在普通与全屏模式下使用同一棵已挂载内容树;隐藏保留已保活的标签状态,全屏覆盖视口并保留底层列占位。`DockLayout` 在同一棵树中渲染稳定 Grid cell 与 fixed 定位的浮动 frame,前台浮窗不随右栏关闭。产品限制为两个水平窗格与 20–80% 分割比例,通用引擎保留自己的默认值。 ### 状态 @@ -86,7 +88,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 - Detail 面板及其重复的卡片展示消失(净删约 1,400 行);卡片就地阅读,`inspect` 打开 trajectory 视图。 - 框架没有中列下限:视口窄于两侧列之和时会话区被挤向零,而不是关掉某一列。 - 库由消费方编译,改库须重建壳并刷新页面;它没有 HMR。 -- 面板、浮层宿主与 portal 出去的 tab 菜单使用硬编码 z-index;客户端仍没有 z-index token 层。 +- 停靠 cell、浮动 cell 和 portal 菜单使用固定层级,浮动 cell 在所属层内通过 CSS 绘制顺序排序;客户端仍没有全局 z-index token 层。 ## Testing diff --git a/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/packages/client/ui-sidebar-right/src/client/contract/slots.ts index 6095752b27..5cb8c59e5c 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -39,7 +39,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** Session content selected by the root-scoped right Sidebar controller. */ - 'rightbar.session': { kind: 'single'; scope: 'session'; owner: RightbarOwnerProps } + 'rightbar.session': { kind: 'single'; scope: 'session'; owner: RightbarOwnerProps & { readonly active: boolean } } /** * One tab's body, dispatched with the `id` of the type in force for * `tab.kind`. A tab type registers here under its definition's `id` and @@ -148,7 +148,7 @@ export interface SidebarRightTabInfo { } readonly panel: { readonly id: PaneId } readonly tab: TabRecord & { - /** Docked bodies need an expanded sidebar and an active tab; expanded titles include inactive tabs. Floats stay visible. */ + /** Only the foreground Session is visible. Docked bodies also require expansion and selection; its floats survive collapse. */ readonly visible: boolean readonly navigation: SidebarRightTabNavigation /** Aborted only when the record disappears or this plugin unloads, not on hide or session switch. */ diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index c50c757f23..ddad14a2b6 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -33,7 +33,8 @@ import { GuideBody, type GuideInjected } from './tabs/guide/GuideBody.tsx' import { GuideTitle } from './tabs/guide/GuideTitle.tsx' import { ExpandButton } from './shell/ExpandButton.tsx' import { RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx' -import { RightbarRoot } from './shell/RightbarRoot.tsx' +import { RightbarRoot, type RightbarRootInjected } from './shell/RightbarRoot.tsx' +import { SidebarSessionViews } from './session-views.ts' import { createSidebarRightController, type SidebarRightController } from './service.ts' import { SidebarRightTabRegistry } from './tab-registry.ts' import { createSidebarRightStore } from './stores.ts' @@ -74,7 +75,7 @@ export type { SidebarRightOpenTab } from './tab-inventory.ts' const NS = 'sidebarRight' /** Required browser services: the slot registry, the frame's panel actions, copy, and the resource model. */ -export const inject = ['slots', 'layout', 'locale', 'resources'] +export const inject = ['slots', 'layout', 'locale', 'resources', 'sessions', 'uiSession'] declare module '@deepseek-ai/cordis' { interface Context { @@ -102,6 +103,14 @@ export function apply(ctx: ClientContext): void { // its own apply top level for the same reason. const t = ctx.locale.bind(NS) const tabs = new SidebarRightTabRegistry(ctx) + const views = new SidebarSessionViews(ctx.sessions) + ctx.effect(() => { + const current = ctx.uiSession.adapter.current + const sync = (): void => { views.select(current.getSnapshot().key as SessionId | undefined) } + const unsubscribe = current.subscribe(sync) + sync() + return () => { unsubscribe(); views.dispose() } + }, 'ui-sidebar-right: retained Session views') const { controller, adopt, forget } = createSidebarRightController( tabs, (address, signal) => { ctx.resources.pin(address, signal) }, @@ -141,7 +150,7 @@ export function apply(ctx: ClientContext): void { }, } const layout: ILayout = ctx.layout - const injected: Omit = { + const injected: Omit = { syncPresentation({ shown, track, fullscreen }) { if (shown) layout.openRightbar(track, fullscreen) else layout.closeRightbar() @@ -156,6 +165,10 @@ export function apply(ctx: ClientContext): void { yield ctx.slots.register({ name: 'rightbar', children: { 'rightbar.session': { kind: 'single', scope: 'session' } }, + inject: (): RightbarRootInjected => ({ + hooks: { views: views.source }, + mountView: reference => views.mount(reference), + }), }, RightbarRoot) yield ctx.slots.register({ name: 'rightbar.session', @@ -174,6 +187,7 @@ export function apply(ctx: ClientContext): void { }, keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation }, occurrence: tab => controller.tabDomain.occurrence(sessionId, tab), + retainTab: tabId => views.retainTab(sessionId, tabId, controller.tabDomain.occurrence(sessionId, { id: tabId }).signal), }), }, RightbarSeat) }) diff --git a/packages/client/ui-sidebar-right/src/client/session-view.ts b/packages/client/ui-sidebar-right/src/client/session-view.ts new file mode 100644 index 0000000000..62c6f51123 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/session-view.ts @@ -0,0 +1,86 @@ +/** One Sidebar view's Session reference, retained bodies and committed mount lifetime. */ +import type { ISessions, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' + +declare module '@deepseek-ai/dsh-api-session-controller/client' { + interface SessionReferenceSourceMap { + sidebarView: unknown + } +} + +/** Owns its reference until retirement finishes or the Sidebar plugin shuts down. */ +export class SidebarSessionView { + readonly reference: SessionReference + private readonly tabs = new Map() + private mounts = 0 + private retired = false + private disposed = false + + /** + * @param sessionId - Session displayed by this view. + * @param sessions - allocator for this view's independent reference. + * @param onDispose - removes this view from the collection's index before reference release. + */ + constructor( + readonly sessionId: SessionId, + sessions: ISessions, + private readonly onDispose: (view: SidebarSessionView) => void, + ) { + this.reference = sessions.retain(sessionId, { source: 'sidebarView' }) + void this.reference.ready.catch(error => { console.error('Sidebar Session opening failed:', error) }) + } + + /** Whether an initialized retained body still needs this view. */ + get hasRetainedTabs(): boolean { + return this.tabs.size > 0 + } + + /** @returns cleanup for one committed root; the final cleanup releases a retired view. */ + mount(): () => void { + this.mounts += 1 + return () => { + this.mounts -= 1 + if (this.retired && this.mounts === 0) this.dispose() + } + } + + /** + * Hold an initialized body until unmount or occurrence cancellation. + * @param tabId - initialized body identity. + * @param signal - occurrence lifetime; closing and undoing a tab creates a new lifetime. + * @param onRelease - lets the collection reconsider retaining this view. + * @returns idempotent release of this body's hold. + */ + retainTab(tabId: TabId, signal: AbortSignal, onRelease: () => void): () => void { + if (signal.aborted || this.disposed) return () => {} + this.tabs.set(tabId, (this.tabs.get(tabId) ?? 0) + 1) + let held = true + const release = (): void => { + if (!held) return + held = false + signal.removeEventListener('abort', release) + const count = this.tabs.get(tabId) as number + if (count === 1) this.tabs.delete(tabId) + else this.tabs.set(tabId, count - 1) + onRelease() + } + signal.addEventListener('abort', release, { once: true }) + return release + } + + /** End a withdrawn view after its existing committed mounts finish. */ + retire(): void { + this.retired = true + if (this.mounts === 0) this.dispose() + } + + /** Release once; plugin shutdown does not wait for remaining React mounts. */ + dispose(): void { + if (this.disposed) return + this.disposed = true + this.retired = true + this.onDispose(this) + this.reference.release() + } +} diff --git a/packages/client/ui-sidebar-right/src/client/session-views.ts b/packages/client/ui-sidebar-right/src/client/session-views.ts new file mode 100644 index 0000000000..badef0a31f --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/session-views.ts @@ -0,0 +1,90 @@ +/** Selection and retention policy for independently owned Sidebar Session views. */ +import type { ISessions, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { SidebarSessionView } from './session-view.ts' + +/** The reference is exclusively a framework SessionProvider target, not a business-component service. */ +export interface SidebarSessionViewSnapshot { + readonly sessionId: SessionId + readonly reference: SessionReference + readonly selected: boolean +} + +/** Selects and retires views; the Sidebar plugin's injected dependencies own global teardown. */ +export class SidebarSessionViews { + readonly source = createSnapshotStore([]) + private readonly views = new Map() + private readonly viewsByReference = new Map() + private selected: SessionId | undefined + private closed = false + + constructor(private readonly sessions: ISessions) {} + + /** @param sessionId - main selection, or absence; existing retained background views stay bound. */ + select(sessionId: SessionId | undefined): void { + if (this.closed || this.selected === sessionId) return + this.selected = sessionId + if (sessionId !== undefined && !this.views.has(sessionId)) { + const view = new SidebarSessionView(sessionId, this.sessions, disposed => { + this.viewsByReference.delete(disposed.reference) + }) + this.views.set(sessionId, view) + this.viewsByReference.set(view.reference, view) + } + for (const view of this.views.values()) this.prune(view) + this.publish() + } + + /** + * Bind a committed root; release retired references after that root unmounts. + * @param reference - framework target published by this owner. + * @returns releases this committed mount. + */ + mount(reference: SessionReference): () => void { + const view = this.viewsByReference.get(reference) + if (view === undefined) { + if (this.closed) return () => {} + throw new Error('Sidebar Session view reference is no longer owned') + } + return view.mount() + } + + /** + * Hold an initialized tab occurrence until unmount or occurrence cancellation. + * @param sessionId - mounted Session identity. + * @param tabId - initialized body identity. + * @param signal - occurrence lifetime; closing and undoing a tab creates a new lifetime. + * @returns idempotent release of this body's hold. + */ + retainTab(sessionId: SessionId, tabId: TabId, signal: AbortSignal): () => void { + if (signal.aborted || this.closed) return () => {} + const view = this.views.get(sessionId) + if (view === undefined) throw new Error(`Sidebar Session "${sessionId}" has no mounted view`) + return view.retainTab(tabId, signal, () => { this.prune(view) }) + } + + /** Plugin shutdown releases every view, including any awaiting a React unmount. */ + dispose(): void { + this.closed = true + this.views.clear() + this.source.set([]) + for (const view of this.viewsByReference.values()) view.dispose() + } + + private prune(view: SidebarSessionView): void { + if (view.sessionId === this.selected || view.hasRetainedTabs || this.views.get(view.sessionId) !== view) return + this.views.delete(view.sessionId) + // Render targets must be withdrawn before an unmounted view can release its reference. + this.publish() + view.retire() + } + + private publish(): void { + if (this.closed) return + this.source.set([...this.views.values()].sort((a, b) => a.sessionId.localeCompare(b.sessionId)).map(view => ({ + sessionId: view.sessionId, reference: view.reference, selected: view.sessionId === this.selected, + }))) + } +} diff --git a/packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx b/packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx index 46420e5970..79c5d7ecb1 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx @@ -1,20 +1,38 @@ /** Root-scoped controller for the right Sidebar's Session content. */ -import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { useLayoutEffect } from 'react' +import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SidebarSessionViewSnapshot } from '../session-views.ts' import type {} from '../contract/slots.ts' +import css from './SidebarRight.module.css' + +/** Root-only retained Session targets and their committed mount lifetimes. */ +export interface RightbarRootInjected { + readonly hooks: { readonly views: HostObservable } + readonly mountView: (reference: SessionReference) => () => void +} + +type RootProps = PropsRuntime<'rightbar'> & PropsRenderSlots<'rightbar.session'> & InjectFace + +function SessionView({ view, visible, SessionProvider, renderSlot, mountView, width, viewportWidth, canShow }: + Pick + & { readonly view: SidebarSessionViewSnapshot; readonly visible: boolean }) { + useLayoutEffect(() => mountView(view.reference), [mountView, view.reference]) + const active = visible && view.selected + return +} /** - * Render the Session-bound Sidebar only while the Conversation is selected. - * @param props - frame geometry, panel selection, and the authorized Session renderer. - * @returns the current Session's right Sidebar, or no content for a global panel. + * Keep independent Session subtrees and hide those outside the selected Conversation. + * @param props - frame geometry, view targets and the authorized Session renderer. + * @returns the foreground and retained background Sidebars. */ -export function RightbarRoot({ - usePanelInfo, SessionProvider, renderSlot, width, viewportWidth, canShow, -}: PropsRuntime<'rightbar'> & PropsRenderSlots<'rightbar.session'>) { +export function RightbarRoot({ usePanelInfo, useViews, ...props }: RootProps) { const visible = usePanelInfo(info => info.activePanelId === null) - if (!visible) return null - return ( - - {renderSlot('rightbar.session', { width, viewportWidth, canShow })} - - ) + const views = useViews(value => value) + return <>{views.map(view => )} } diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css index 890b897cc8..7c7833556f 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css @@ -9,7 +9,7 @@ * One content tree uses the frame's width normally and fills the viewport in * fullscreen. The frame retains a shown wide panel's track in both modes. * - * Hidden, the panel is translated off the frame's right edge rather than + * Hidden, docked Grid items translate off the frame's right edge rather than * unmounted, so showing and hiding are one slide in both presentations. The * transform rides the same variables as the frame's track transition (ui-theme * base.css); while squeezing, the panel's left edge and the conversation's right @@ -17,30 +17,45 @@ * is out of reach once off-edge, and flips back before the slide starts. * * The normal panel is below frame overlays (20); fullscreen (40) covers the - * frame and remains below independently floating panels (60). + * frame and remains below independently floating panels (60). The common + * content root owns no stacking context or transform. */ +.session { + display: contents; +} + +.session[hidden] { + display: none; +} + .panel { + --dsh-dockkit-dock-layer: 10; + --dsh-dockkit-float-layer: 60; position: absolute; top: 0; right: 0; bottom: 0; - z-index: 10; display: flex; flex-direction: column; min-width: 0; - /* The same ground as the conversation: this is a column of the page, not a - raised surface. The edge is the weight of the kit's split dividers inside - (ui-dockkit `.divider`), so the panel's outline and its inner rules match. */ - background: var(--dsw-alias-bg-base); - border-left: 0.5px solid var(--dsw-alias-border-l4); - transform: translateX(100%); + pointer-events: none; +} + +/* Only docked Grid items slide. Their common ancestor remains untransformed, + unclipped and without a stacking context, so fixed floating children escape. */ +.panel :global([data-dockkit-host='dock']), +.panel :global([data-dockkit-empty]), +.panel :global([data-dockkit-divider]) { + transform: translateX(var(--dsh-sidebar-width)); visibility: hidden; transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out), visibility 0s linear var(--ds-transition-duration-slow); } -.panel[data-sidebar-right-open] { +.panel[data-sidebar-right-open] :global([data-dockkit-host='dock']), +.panel[data-sidebar-right-open] :global([data-dockkit-empty]), +.panel[data-sidebar-right-open] :global([data-dockkit-divider]) { transform: none; visibility: visible; transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); @@ -60,15 +75,12 @@ } .panel[data-sidebar-right-panel='fullscreen'] { - position: fixed; - inset: 0; - z-index: 40; - border: none; + --dsh-dockkit-dock-layer: 40; } /* No whole-box subtraction here: Electron drag regions are window geometry, not stacking, so the frame's leading band does keep acting through this - fixed overlay — but the kit already subtracts every box that must stay + fullscreen content — but the kit already subtracts every box that must stay usable (tabs, strip-end chrome, pane bodies, dividers; ui-dockkit), and ui-web base.css subtracts interactive elements and portalled overlays. What remains of the band under the strips' blank runs is the window drag @@ -79,10 +91,10 @@ /* Windows keeps caption controls above fullscreen file and terminal panels. */ :global([data-windows-titlebar]) .panel[data-sidebar-right-panel='fullscreen'] { - overflow: hidden; - left: var(--dsh-windows-sidebar-width); - max-width: calc(100% - var(--dsh-windows-sidebar-width)); - top: var(--dsh-windows-titlebar-height); + max-width: calc(100vw - var(--dsh-windows-sidebar-width)); +} + +:global([data-windows-titlebar]) .panel[data-sidebar-right-panel='fullscreen'] :global([data-dockkit-column='0'][data-dockkit-pane]) { border-radius: var(--dsh-windows-content-radius) 0 0 0; corner-shape: round; } @@ -90,27 +102,27 @@ /* macOS fullscreen: the panel reaches the window's top-left corner, where the hiddenInset traffic lights (x 16..~68) float over the first pane's tab strip. Widen that strip's start inset (dockkit publishes the variable); - panes below or right of a divider sit clear of the lights, and every - non-first split cell resets the inherited value for its whole subtree. */ -:global([data-platform='darwin']) .panel[data-sidebar-right-panel='fullscreen'] .panelBody { + the second Grid column sits clear of the lights and uses the ordinary inset. */ +:global([data-platform='darwin']) .panel[data-sidebar-right-panel='fullscreen'] :global([data-dockkit-host='dock'][data-dockkit-column='0']) { /* Lights end near x 68; 88 leaves the same 20px of air the conversation header's leading controls keep after them (ui-sidebar HeaderLeadingControls). */ --dsh-dockkit-strip-inline-start: 88px; } -:global([data-platform='darwin']) .panel[data-sidebar-right-panel='fullscreen'] .panelBody :global([data-dockkit-cell]):not(:first-child) { +:global([data-platform='darwin']) .panel[data-sidebar-right-panel='fullscreen'] :global([data-dockkit-host='dock'][data-dockkit-column='1']) { --dsh-dockkit-strip-inline-start: 10px; } /* Window fullscreen hides the traffic lights (html[data-fullscreen], desktop preload); the first pane's strip keeps only the ordinary inset. */ -:global([data-platform='darwin'][data-fullscreen]) .panel[data-sidebar-right-panel='fullscreen'] .panelBody { +:global([data-platform='darwin'][data-fullscreen]) .panel[data-sidebar-right-panel='fullscreen'] :global([data-dockkit-host='dock'][data-dockkit-column='0']) { --dsh-dockkit-strip-inline-start: 10px; } @media (prefers-reduced-motion: reduce) { - .panel, - .panel[data-sidebar-right-open] { + .panel :global([data-dockkit-host='dock']), + .panel :global([data-dockkit-empty]), + .panel :global([data-dockkit-divider]) { transition: none; } } @@ -159,17 +171,3 @@ color: var(--dsw-alias-label-tertiary); font-size: var(--dsh-content-font-size-secondary, 13px); } - -/* - * Host for the portalled floating panels. Panels leave the column so they can - * cross it and the conversation, which puts them beside the app root rather than - * inside it — and a sibling of the root does not inherit its stacking, so the - * level is stated here. The value sits above the frame's own overlay layer; - * there is no z-index token layer to draw from yet. - */ -.floatHost { - position: fixed; - inset: 0; - z-index: 60; - pointer-events: none; -} diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 6b388b219f..87612be6af 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -2,11 +2,11 @@ * The Sidebar's seat in the frame, and the panel it draws. * * The frame owns the right column's geometry; this package owns one content - * tree at the column width or fixed across the viewport. A shown wide panel + * tree at the column width or spanning the viewport. A shown wide panel * retains its track in fullscreen, preserving the conversation width. Below * 768px fullscreen is derived from viewport width, without changing manual mode. * - * The panel stays mounted while collapsed, translated off the frame's right + * Docked content stays mounted while collapsed, translated off the frame's right * edge, so opening and closing are one gesture in both presentations: a slide * from and to that edge. Normal presentation moves the frame's tracks with * the panel. A fullscreen opening reserves its underlying track only after @@ -17,8 +17,8 @@ * pane's tab strip, so the strip is the panel's whole top edge. The way back in * while collapsed is not here either: it is one button in the conversation * header (`ExpandButton.tsx`), because it exists only while this panel is - * hidden. Floating panels portal out because they must cross the column and the - * conversation, and the kit already positions them in viewport coordinates. + * hidden. Floating panels remain in the same content tree; untransformed + * ancestors let their fixed-position frames cross the column and conversation. * * Tab bodies do not live here. Each one is a registration under its type's kind, * dispatched through the keyed `sidebar.right.pane.tab` seat (and a live chip @@ -28,15 +28,14 @@ * domain follows each session's store commits, including sessions off screen. */ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' -import type { ReactNode, RefObject } from 'react' -import { createPortal } from 'react-dom' +import type { CSSProperties, ReactNode, RefObject } from 'react' import { IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '../contract/slots.ts' import type { DockIntents, DockMode, FloatRect, TabId, TabRecord, TabRenderer } from '@deepseek-ai/dsh-client-ui-dockkit' -import { canSplit, dockPaneIds, DockSurface, findPaneContentTab, FloatLayer } from '@deepseek-ai/dsh-client-ui-dockkit' +import { canSplit, dockPaneIds, DockLayout, findPaneContentTab } from '@deepseek-ai/dsh-client-ui-dockkit' import type { HalvesFit, LayoutState, PaneId } from '@deepseek-ai/dsh-client-ui-dockkit' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { GUIDE_KIND, pageAddress } from '../contract/seed.ts' @@ -108,6 +107,8 @@ export interface SidebarRightInjected { } /** Read a committed record's lifetime; never creates an occurrence. */ readonly occurrence: (tab: Pick) => TabOccurrence + /** Keep this Session generation while an initialized body is retained. */ + readonly retainTab: (tabId: TabId) => () => void } /** The column seat's props: session scope, so the session arrives as a standard prop. */ @@ -133,6 +134,8 @@ interface PanelProps { readonly occurrence: SidebarRightInjected['occurrence'] readonly fullscreen: boolean readonly autoFullscreen: boolean + readonly active: boolean + readonly retainTab: SidebarRightInjected['retainTab'] /** Receives the kit's room-rule readings for the service's `split`. */ readonly reportRoom: (fits: ReadonlyMap) => void } @@ -172,7 +175,7 @@ export function intentsFor(sessionId: SessionId, actions: Store['actions'], open } /** One tab's slot dispatch: which seat, and what to render when no type registered. */ -interface TabSlotProps extends Pick { +interface TabSlotProps extends Pick { readonly tab: TabRecord readonly seat: 'sidebar.right.pane.tab' | 'sidebar.right.pane.tab.title' readonly fallback: ReactNode @@ -182,19 +185,22 @@ interface TabSlotProps extends Pick types.find(definition => definition.kind === tab.kind)) + const retained = seat === 'sidebar.right.pane.tab' && definition?.keepMounted === true + useLayoutEffect(() => retained ? retainTab(tab.id) : undefined, [retained, retainTab, tab.id, signal]) const hookContext = useMemo((): TabHookContext => ({ tabId: tab.id, title: seat === 'sidebar.right.pane.tab.title', fullscreen, + active, signal, actions: tabActions, useStore, useTabNavigation, - }), [tab.id, seat, fullscreen, signal, tabActions, useStore, useTabNavigation]) + }), [tab.id, seat, fullscreen, active, signal, tabActions, useStore, useTabNavigation]) return renderSlot(seat, {}, { entryKey: definition?.id ?? tab.kind, fallback, hookContext }) } @@ -207,9 +213,7 @@ function TabSlot({ */ function bodiesFor(panel: PanelProps): TabRenderer { const { t, ...rest } = panel - // Keyed by record: the kit draws one body per pane in one place, and the - // keyed slot below keys on the type, so two tabs of one kind would otherwise - // share a component instance and its local state (a scroll position, a ref). + // Slot dispatch selects a type; the record key separates bodies of that type. return tab => ( }): ReactNode { const { sessionId, surface, actions, t, renderSlot, openTab, width, reportRoom, fullscreen, autoFullscreen, panelRef } = panel const { expanded } = surface.layout + const types = panel.useTabTypes(value => value) return (