From ff5f94d3fb298968a6ba7acb77e376f73e833287 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 22:40:00 +0800 Subject: [PATCH 001/196] 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/196] 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/196] 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/196] 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/196] =?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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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/196] 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 068/196] 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 069/196] 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 073/196] 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 074/196] 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 075/196] 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 076/196] 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 077/196] 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 078/196] 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 079/196] 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 080/196] 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 081/196] 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 082/196] 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 083/196] 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 084/196] 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 085/196] 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 086/196] 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 087/196] 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 103/196] 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 104/196] 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 105/196] 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 106/196] 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 107/196] 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 108/196] 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 109/196] 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 110/196] 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 2f4b35425379637ec516d16e3faa5b65454918d1 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Sun, 20 Sep 2026 14:17:36 +0800 Subject: [PATCH 111/196] Fix Office packaging tests for static checks and Windows paths --- apps/desktop-host/tests/office-engine.spec.ts | 16 ++++++++-------- apps/desktop/tests/windows-asar-unpack.spec.ts | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/desktop-host/tests/office-engine.spec.ts b/apps/desktop-host/tests/office-engine.spec.ts index 6ceca3773e..716aef756d 100644 --- a/apps/desktop-host/tests/office-engine.spec.ts +++ b/apps/desktop-host/tests/office-engine.spec.ts @@ -25,7 +25,7 @@ function fixture(runtimeName = 'dsh') { const api = join(runtime, 'node_modules/@deepseek-ai/libreoffice-kit/package.json') mkdirSync(dirname(api), { recursive: true }) writeFileSync(api, '{"name":"@deepseek-ai/libreoffice-kit"}') - const require = createRequire(join(runtime, 'package.json')) + const require: (specifier: string) => unknown = createRequire(join(runtime, 'package.json')) const hook = installOfficeEngineResolution(runtime)! hooks.push(hook) return { root, runtime, manifest, require } @@ -34,7 +34,7 @@ function fixture(runtimeName = 'dsh') { it('resolves engine manifests to physical directories and leaves unrelated modules alone', () => { const f = fixture() // Node 24.13 require.resolve bypasses hooks; Electron's require.resolve is covered by packaged Office smoke. - expect(f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json') as unknown) + expect(f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) .toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'dsh', f.manifest))) }) expect((f.require('node:fs') as typeof import('node:fs')).realpathSync).toBe(realpathSync) expect(f.require('@deepseek-ai/libreoffice-kit/package.json')).toEqual({ name: '@deepseek-ai/libreoffice-kit' }) @@ -54,7 +54,7 @@ it('leaves a prepared runtime without an archive unchanged', () => { it('preserves a renamed runtime directory when locating the unpacked engine', () => { const f = fixture('alternate-runtime') - expect(f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json') as unknown) + expect(f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) .toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'alternate-runtime', f.manifest))) }) }) @@ -62,8 +62,8 @@ it('resolves an engine through a directory alias', () => { const f = fixture() const alias = join(f.root, 'alias') symlinkSync(join(f.root, 'app.asar'), alias, 'junction') - const require = createRequire(join(alias, 'dsh', 'package.json')) - expect(require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json') as unknown) + const require: (specifier: string) => unknown = createRequire(join(alias, 'dsh', 'package.json')) + expect(require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) .toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'dsh', f.manifest))) }) }) @@ -85,9 +85,9 @@ it('leaves external engines and the archived WASM engine at their own locations' mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, JSON.stringify({ path: realpathSync(dirname(path)) })) } - const require = createRequire(join(f.root, 'external', 'package.json')) - expect(require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json') as unknown) + const require: (specifier: string) => unknown = createRequire(join(f.root, 'external', 'package.json')) + expect(require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')) .toMatchObject({ path: realpathSync(dirname(external)) }) - expect(f.require('@deepseek-ai/libreoffice-kit-wasm/package.json') as unknown) + expect(f.require('@deepseek-ai/libreoffice-kit-wasm/package.json')) .toMatchObject({ path: realpathSync(dirname(wasm)) }) }) diff --git a/apps/desktop/tests/windows-asar-unpack.spec.ts b/apps/desktop/tests/windows-asar-unpack.spec.ts index faca10da56..a6587b132b 100644 --- a/apps/desktop/tests/windows-asar-unpack.spec.ts +++ b/apps/desktop/tests/windows-asar-unpack.spec.ts @@ -209,7 +209,7 @@ it.each([false, true])('keeps the complete Office engine outside ASAR with exter await config.beforePack(input.context) await packageFixture(input) const archive = await readAsar(join(input.resources, 'app.asar')) - expect(archive.getFile('dsh/node_modules/@deepseek-ai/libreoffice-kit-wasm/package.json').unpacked).not.toBe(true) + expect(archive.getFile(join('dsh', 'node_modules', '@deepseek-ai', 'libreoffice-kit-wasm', 'package.json')).unpacked).not.toBe(true) for (const file of files) { expect(archive.getFile(join('dsh', engine, file), false).unpacked).toBe(true) expect(await readFile(join(input.resources, 'app.asar.unpacked', 'dsh', engine, file), 'utf8')).toBe('{}') From c05771d82867f29182c4b01d5daaaff49cb15a6e Mon Sep 17 00:00:00 2001 From: winewill Date: Sun, 20 Sep 2026 14:17:51 +0800 Subject: [PATCH 112/196] 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 113/196] 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 90f0dc8f7d3a3b67e2c2620cc01c1978cd7fa288 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Sun, 20 Sep 2026 14:31:11 +0800 Subject: [PATCH 114/196] Check terminal descendant quiescence without requiring PID reaping --- packages/terminal/terminal-bash/tests/local.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 7ef28c6929..d539d56298 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -233,9 +233,10 @@ describe.skipIf(process.platform === 'win32')('terminal-bash real shell', () => const child = /CHILD=(\d+)/.exec(output)?.[1] expect(child).toBeDefined() const pid = Number(child) - expect(() => process.kill(pid, 0)).not.toThrow() + expect(processIsRunning(pid)).toBe(true) await ctx.terminals.kill(agent, created.sessionId) - expect(() => process.kill(pid, 0)).toThrow() + // Linux can retain an exited descendant's PID until its parent reaps it. + expect(processIsRunning(pid)).toBe(false) }, 10_000) it('quiesces a disowned same-session descendant after the shell exits naturally', async () => { From 8e5073ee617532d0e526118d94aa13cbbf4b8d15 Mon Sep 17 00:00:00 2001 From: winewill Date: Sun, 20 Sep 2026 14:44:36 +0800 Subject: [PATCH 115/196] 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 e71df56b1a2b6511bc7184a58c6a6142e934bed9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 14:59:15 +0800 Subject: [PATCH 116/196] 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 6fa56a0cf7c0d7d588a4f94549e82a4936e2cfa6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 15:57:45 +0800 Subject: [PATCH 117/196] 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 118/196] 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 119/196] 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 120/196] 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 a4148d687fa5f30f1c0998af81fdaf9869fe8f41 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 16:46:58 +0800 Subject: [PATCH 121/196] 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 + ?
    - - - - - )} - > - {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 f66ee7a96a..c57fe69936 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -1,5 +1,6 @@ -// Resident conversation shell keeps the composer mounted across local-draft, -// blank-Session, and active-conversation views. +// 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. 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 5852e4b5c4..6a4ea12740 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -1,12 +1,16 @@ /** The default composer body: the 'conversation.composer.bar' slot entry. - * 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. + * 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 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 contenteditable remains mounted when the first Session arrives. + * The no-session state renders the SAME div inert as the Workspace-picker + * trigger instead of a parallel tree. */ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -38,7 +42,7 @@ import css from './InputBar.module.css' export type InputBarProps = ComposerBarProps export const InputBar = memo(function InputBar({ - useSession, useComposerInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, + useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, retryFileUpload, toggleCommandMenu, stop, t, renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, @@ -46,7 +50,7 @@ export const InputBar = memo(function InputBar({ workspacePickerOpen = false, onRequestWorkspace, placeholder, accessory, }: InputBarProps) { - const input = useComposerInput(s => s) + const input = useInput(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 @@ -60,11 +64,14 @@ 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) - const draft = input.draft - const editor = keyboard.editor + // 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 attachments = useMemo( - () => resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds), - [resolveDraftAttachments, input.attachmentIds], + () => input === undefined || resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds), + [resolveDraftAttachments, input?.attachmentIds], ) const empty = draft.trim() === '' && attachments.length === 0 const uploads = useFileUploads(snapshot => snapshot) @@ -86,7 +93,8 @@ 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') - // The toast announces promptError, the draft stays in the machine, + // Prompt failures are ordinary failures (no create/attach transaction exists + // anymore): 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 @@ -114,30 +122,35 @@ 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 - // 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 + // 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 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 - 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 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 workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined const editorDisabled = removed || (locked && !workspaceTrigger) - const editable = !locked && !machineBusy + const editable = live && !locked && !machineBusy const steeringAvailable = subagent === null || subagent.address.mode === 'continuable' const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && steeringAvailable && input.queue.length > 0 useEffect(() => { - if (inputActions !== undefined && attachments.length !== input.attachmentIds.length) { + if (input === undefined || inputActions === undefined) return + if (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 @@ -154,7 +167,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) return + if (locked || editor === null) return focusDraftEditor(editor, revealSelection) }, [locked, sessionId, editor]) @@ -231,10 +244,12 @@ 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]) @@ -247,16 +262,17 @@ 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. - focusDraftEditor(editor, revealSelection) + if (editor !== null) focusDraftEditor(editor, revealSelection) toggleCommandMenu?.(keyboard.caretSpan()) } - // When a blank Session loses its Workspace, the resident editable div acts as the - // picker trigger for keyboard users. + // The no-session Workspace trigger: the resident editable div acts as the + // picker trigger for keyboard users (no editor is bound in this state). const onWorkspaceKeyDown = (e: KeyboardEvent): void => { if (!workspaceTrigger) return if (e.key === 'Enter' || e.key === ' ') { @@ -279,7 +295,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 @@ -290,6 +306,7 @@ 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) } @@ -297,7 +314,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() === '' @@ -305,7 +322,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. @@ -379,7 +396,7 @@ export const InputBar = memo(function InputBar({ scrollRef={scrollRef} editable={editable} editorDisabled={editorDisabled} - phase={input.phase} + phase={input?.phase ?? 'inert'} placeholderText={placeholderText} ariaLabel={workspaceTrigger ? t('hero.chooseWorkspace') : placeholderText} workspaceTrigger={workspaceTrigger} @@ -416,12 +433,12 @@ export const InputBar = memo(function InputBar({ {sessionId === undefined ? null : renderSlot('conversation.input.permission', { locked })} {sessionId === undefined ? null : renderSlot('conversation.input.plan', { locked })} - {sessionId === undefined + {input === undefined || sessionId === undefined ? null : renderSlot('conversation.input.left', {})}
    - {sessionId === undefined + {input === undefined || sessionId === undefined ? null : renderSlot('conversation.input.right', {})} {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })} @@ -465,7 +482,7 @@ export const InputBar = memo(function InputBar({
    - {variant === 'composer' && sessionId !== undefined + {variant === 'composer' && input !== undefined && 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 0339ef042d..e90a11a163 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -2,7 +2,6 @@ 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' @@ -74,14 +73,12 @@ 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({ @@ -139,113 +136,10 @@ 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, 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'], - ['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()) @@ -265,7 +159,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 }) @@ -514,8 +408,7 @@ describe('Conversation inject API', () => { expect(() => { injectBar('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/) const absent = injectBar(undefined) - expect(absent.keyboard).toBeDefined() - expect(absent.hooks.composerInput.getSnapshot().draft).toBe('') + expect(absent.keyboard).toBeUndefined() 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 51513cb28e..84e5ba28a1 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -100,10 +100,8 @@ async function bench(opts?: { blank?: boolean }) { } describe('resident composer', () => { - it('accepts a local draft before any Session exists and keeps the Workspace chip available', async () => { + it('renders the locked view state while no session exists at all', 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) @@ -115,21 +113,15 @@ 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')).toBe('true') - expect(textarea!.getAttribute('aria-haspopup')).toBeNull() + expect(textarea!.getAttribute('contenteditable')).not.toBe('true') + expect(textarea!.getAttribute('aria-haspopup')).toBe('menu') expect(view.getByTestId('workspace-probe').textContent).toBe('false:0') - fireEvent.click(view.getByRole('button', { name: '选择工作区' })) + fireEvent.click(textarea!) expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') - expect(textarea!.getAttribute('aria-expanded')).toBeNull() - fireEvent.click(view.getByRole('button', { name: '选择工作区' })) + 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(view.getByRole('button', { name: '选择工作区' })).toBeTruthy() await runtime.dispose() @@ -157,7 +149,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')).toBe('true') + expect(textarea.getAttribute('contenteditable')).not.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 deleted file mode 100644 index b23055dcf1..0000000000 --- a/packages/client/ui-conversation/tests/first-draft.client.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -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, isCurrent: () => true }) - 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 a4805db1ec..9e0366952c 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -185,7 +185,6 @@ 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 c8641afcb4..15d87a0ca7 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -67,7 +67,6 @@ 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 0c098d95fb..347b4d05c1 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -161,7 +161,6 @@ 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 619e3fddff..17aadfb056 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -137,8 +137,6 @@ 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[] } = {}, @@ -282,7 +280,6 @@ function mount( useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} - useComposerInput={useInput} keyboard={wiring} addFiles={() => null} useFileUploads={bindSnapshotSelector(createSnapshotStore({}))} @@ -339,8 +336,6 @@ 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, @@ -410,18 +405,6 @@ 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-label')).toBe('正在准备工作区…') - 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 b98485e434..4af8adc8d4 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: 03dc8a9ff45fbc1fc1ab8585db15c19437807f5e -README.zh.md: ac8000f78d3a9394ec2c83790aa3e0349cac2ab0 +README.md: 768c66503af70d0b587b25c09aa8412b0a647439 +README.zh.md: 25df6f8e6a2ec0a001d784c7bde18e454e9d706f diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 03dc8a9ff4..768c66503a 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -69,7 +69,9 @@ 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. +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. Preparation failure offers Cancel or Choose folder through the existing picker, and is not retried until the next startup. A successfully registered Workspace remains available when Session creation or later submission fails. + +The Client chooses the initial directory name and title from its language at startup: 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 successfully initialized Workspace keeps its directory and title across language changes. ## Understand the implementation diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index ac8000f78d..25df6f8e6a 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -69,7 +69,9 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** 新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求,目前只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 -`openDefaultWorkspace(request, beforeOpen, signal)` 在连接 Session 前,按请求中的目录名和标题初始化 Host 默认工作区。它在目录准备之前捕获导航取消信号;被取代的操作不能转移草稿或选中 Session。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。[Conversation](../ui-conversation/README.zh.md) 负责首次使用草稿和失败弹窗。 +Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `workspaces.initializeDefault`,创建或复用其空白 Session。选中该 Session 后输入框才可编辑,不会自动提交消息。后续导航或所属上下文销毁会阻止启动流程选中其结果。不符合首次使用条件时仍可选择文件夹,不显示错误。准备失败时通过现有选择器提供取消或选择文件夹操作,直到下次启动才重试。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。 + +Client 在启动时按其语言选择初始目录名和标题:中文使用 `默认工作区`,英文使用 `Default workspace`,其他语言使用目录 `default-workspace` 和标题 `Default workspace`。初始化过程中保留请求中的名称。成功初始化的工作区在切换语言后保留原目录和标题。 ## 理解实现 diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index b74e3689dc..42e177afce 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -231,21 +231,45 @@ export function WorkspacePicker({ onClose, createWorkspace, useDirectoryFlow, + useDefaultFailure, + dismissDefaultFailure, renderSlot, t, }: WorkspacePickerProps) { + const failed = useDefaultFailure(value => value) + const flowAvailable = useDirectoryFlow(value => value) + const [recovering, setRecovering] = useState(false) return ( - renderSlot('conversation.hero.workspace.directoryFlow', owner)} - selectedId={selectedId} - onPick={onPick} - onClose={onClose} - /> + <> + renderSlot('conversation.hero.workspace.directoryFlow', owner)} + selectedId={selectedId} + onPick={onPick} + onClose={() => { setRecovering(false); onClose() }} + /> + + + + + )} + > + {t('defaultWorkspace.retry')} + + ) } diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 6a54d79146..b878e24986 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -364,6 +364,12 @@ export type WorkspaceBrowserProps = * supplies the implicit index signature required by the registry. */ export type WorkspacePickerInjected = DirectoryPickingInjected & { + /** Dismiss the startup failure before choosing a directory manually. */ + dismissDefaultFailure: () => void + hooks: { + /** Whether first-use startup failed to prepare a Workspace and Session. */ + defaultFailure: HostObservable + } /** Adopt a picked host directory as a real Workspace before targeting a Session. */ createWorkspace: (input: { path: string }) => Promise } @@ -377,5 +383,5 @@ export type WorkspacePickerProps = PropsRuntime<'conversation.hero.workspace'> & PropsRenderSlots<'conversation.hero.workspace.directoryFlow'> & Omit - & DirectoryPickingHooks + & PropsHooks & PropsLocale<'workspace'> diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 0614dea6a4..8d96c65d77 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -219,7 +219,8 @@ export function apply(ctx: Context): void { }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => workspaces.create(input), - hooks: { directoryFlow: pickerFlowSource }, + dismissDefaultFailure: () => { uiWorkspace.defaultFailure.set(false) }, + hooks: { directoryFlow: pickerFlowSource, defaultFailure: uiWorkspace.defaultFailure }, }) // Each registration declares its owned children in the same call; slot // injection follows both the owner and declaration HMR lifetimes. diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index e721af231f..d926ce987f 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -6,6 +6,10 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'defaultWorkspace.failed': '无法创建默认工作区', + 'defaultWorkspace.title': '默认工作区', + 'defaultWorkspace.retry': '请选择一个文件夹后重试', + 'defaultWorkspace.choose': '选择文件夹', 'group.ungrouped': '未分组', 'session.new': '新会话', 'section.workspaces': '工作区', @@ -99,6 +103,10 @@ export type WorkspaceKey = 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', 'group.ungrouped': 'Ungrouped', 'session.new': 'New Session', 'section.workspaces': 'Workspaces', diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 29c4c8d3f7..b21cf06f15 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -12,10 +12,12 @@ import type { import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { - IWorkspaces, WorkspaceId, WorkspaceInitializeDefaultRequest, WorkspaceSnapshot, WorkspaceView, + IWorkspaces, WorkspaceId, 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' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { en, zh } from './locales.ts' import { pinOrderAccounts, pinOrderSource } from './pin-order.ts' import type { WorkspaceViewStoreActions } from './stores.ts' @@ -38,18 +40,6 @@ 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. @@ -130,6 +120,8 @@ export class DirectoryBrowseError extends Error { /** Implements Workspace archive and directory UI operations. */ class UiWorkspaceService extends Service implements UiWorkspace { + /** Startup failure awaiting dismissal or explicit directory selection. */ + readonly defaultFailure = createSnapshotStore(false) private readonly connecting = new Map>() private readonly lifetime = new AbortController() private readonly selection = createSnapshotStore( @@ -213,25 +205,6 @@ 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 }) } @@ -353,11 +326,30 @@ class UiWorkspaceService extends Service implements UiWorkspace { } const target = workspace?.workspaceId ?? recentWorkspace(workspaces.items, sessions.byId) if (sessionId === undefined && target !== undefined) sessionId = await this.connectWorkspace(target) + if (target === undefined && workspaces.items.length === 0 && sessions.ids.length === 0) { + sessionId = await this.initializeDefaultWorkspace(navigation) + } if (sessionId !== undefined && !navigation.aborted) { this.replaceMain(sessionId, navigation, 'preserve') } } + private async initializeDefaultWorkspace(signal: AbortSignal): Promise { + const language = this.ctx.locale.getSnapshot().active.toLowerCase().split('-')[0] + const title = (language === 'zh' ? zh : en)['defaultWorkspace.title'] + try { + const workspace = await this.workspaces.initializeDefault({ + directoryName: language === 'zh' || language === 'en' ? title : 'default-workspace', + title, + }, signal) + if (workspace === undefined || signal.aborted) return undefined + return await this.connectWorkspace(workspace.workspaceId) + } catch (_error: unknown) { + if (!signal.aborted) this.defaultFailure.set(true) + return undefined + } + } + /** @returns true when an archived current selection was cleared. */ private clearArchivedCurrent(): boolean { const current = this.mainReference?.sessionId 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 9ff0181207..e72d523668 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { @@ -88,14 +89,19 @@ function mount( items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn(), occupancy = occupancySource(), + options: { defaultFailure?: boolean; open?: boolean } = {}, ) { + const failure = createSnapshotStore(options.defaultFailure ?? false) + const useDefaultFailure = bindSnapshotSelector(failure) + const dismissDefaultFailure = vi.fn(() => { failure.set(false) }) const onPick = vi.fn() const onClose = vi.fn() const anchorRef = anchor() const { probe, renderSlot } = flowProbe() const renderPicker = (nextItems: readonly WorkspaceView[]) => ( { view.rerender(renderPicker(nextItems)) }, } } @@ -220,6 +226,7 @@ describe('WorkspacePicker', () => { const { renderSlot } = flowProbe() render( undefined} @@ -238,6 +245,7 @@ describe('WorkspacePicker', () => { const { renderSlot } = flowProbe() render( undefined} @@ -321,3 +329,26 @@ describe('WorkspacePicker', () => { expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull() }) }) + +describe('default Workspace startup recovery', () => { + it('dismisses the failure without opening the directory flow', () => { + const b = mount([], vi.fn(), occupancySource(), { defaultFailure: true, open: false }) + expect(screen.getByRole('dialog', { name: '无法创建默认工作区' })).toBeTruthy() + expect(b.probe.owner?.open).toBe(false) + fireEvent.click(screen.getByRole('button', { name: '取消' })) + expect(b.dismissDefaultFailure).toHaveBeenCalledOnce() + expect(screen.queryByRole('dialog', { name: '无法创建默认工作区' })).toBeNull() + expect(b.probe.owner?.open).toBe(false) + }) + + it('opens the composed directory flow from the startup failure', async () => { + const create = vi.fn(async () => workspace('chosen')) + const b = mount([], create, occupancySource(), { defaultFailure: true, open: false }) + fireEvent.click(screen.getByRole('button', { name: '选择文件夹' })) + expect(b.dismissDefaultFailure).toHaveBeenCalledOnce() + expect(b.probe.owner?.open).toBe(true) + await act(async () => { b.probe.owner!.onPicked('/chosen') }) + expect(create).toHaveBeenCalledExactlyOnceWith({ path: '/chosen' }) + expect(b.onPick).toHaveBeenCalledExactlyOnceWith(wid('chosen')) + }) +}) 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 422de2dc2d..4f9cd973b8 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -1,4 +1,6 @@ +import { setImmediate } from 'node:timers/promises' import { Context } from '@deepseek-ai/cordis' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ISessions, SessionListState, SessionReference, SessionSummary, @@ -169,11 +171,7 @@ 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 initializeDefault = vi.fn(async () => undefined) readonly list: MutableSource readonly archiveCalls: SessionId[] = [] readonly unarchiveCalls: SessionId[] = [] @@ -268,6 +266,8 @@ class FakeDirectoryPicker { } interface BenchOptions { + readonly language?: string + readonly configureWorkspaces?: (workspaces: FakeWorkspaces) => void readonly workspaces?: WorkspaceSnapshot readonly sessions?: SessionListState readonly configureSessions?: (sessions: FakeSessions) => void @@ -276,6 +276,10 @@ interface BenchOptions { function bench(options: BenchOptions = {}) { const ctx = new Context() contexts.push(ctx) + const locale = new LocaleRuntime(ctx) + if (options.language === 'fr') locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' }) + locale.setLocale(options.language ?? 'en') + ctx.provide('locale', locale) const layout = new LayoutController({ selectPanel: vi.fn(), retainMainPanels: vi.fn(), setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(), @@ -287,6 +291,7 @@ function bench(options: BenchOptions = {}) { const directoryPicker = new FakeDirectoryPicker() const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending')) const sessions = new FakeSessions(options.sessions ?? sessionState([], 'pending')) + options.configureWorkspaces?.(workspaces) options.configureSessions?.(sessions) const view = createWorkspaceViewStore().create() const uiWorkspace = new UiWorkspaceService( @@ -300,39 +305,95 @@ 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' }) + it.each([ + ['zh', '默认工作区', '默认工作区'], + ['en', 'Default workspace', 'Default workspace'], + ['fr', 'default-workspace', 'Default workspace'], + ])('prepares and selects the default Workspace after both startup baselines (%s)', async (language, directoryName, title) => { + const b = bench({ language, configureWorkspaces: (workspaces) => { + workspaces.initializeDefault.mockImplementation(async () => { + const item = workspace('default') + workspaces.list.set(workspaceState([item])) + return item + }) + } }) + expect(b.workspaces.initializeDefault).not.toHaveBeenCalled() + b.sessions.list.set(sessionState()) + expect(b.workspaces.initializeDefault).not.toHaveBeenCalled() + b.workspaces.list.set(workspaceState()) + await vi.waitFor(() => { + expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(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.workspaces.initializeDefault).toHaveBeenCalledExactlyOnceWith({ directoryName, title }, expect.any(AbortSignal)) expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('default') }) - expect(beforeOpen).toHaveBeenCalledWith(sid('created-default')) + expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false) }) - it('does not create a Session when default preparation fails or is superseded', async () => { + it('leaves an ineligible empty installation without a Session or failure dialog', 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') + await setImmediate() + b.workspaces.list.set(workspaceState()) + b.sessions.list.set(sessionState()) + await setImmediate() + expect(b.workspaces.initializeDefault).toHaveBeenCalledOnce() expect(b.sessions.create).not.toHaveBeenCalled() + expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false) + }) + + it('requires explicit selection when the startup Session list contains history', async () => { + const b = bench({ workspaces: workspaceState(), sessions: sessionState([summary('history')]) }) + await setImmediate() + expect(b.workspaces.initializeDefault).not.toHaveBeenCalled() + expect(b.sessions.create).not.toHaveBeenCalled() + }) + + it('publishes a startup directory failure once without creating a Session', async () => { + const b = bench({ workspaces: workspaceState(), sessions: sessionState(), configureWorkspaces: (workspaces) => { + workspaces.initializeDefault.mockRejectedValueOnce(new Error('denied')) + } }) + await vi.waitFor(() => { expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(true) }) + b.uiWorkspace.defaultFailure.set(false) + b.workspaces.list.set(workspaceState()) + await setImmediate() + expect(b.workspaces.initializeDefault).toHaveBeenCalledOnce() + expect(b.sessions.create).not.toHaveBeenCalled() + expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false) + }) + + it.each(['session', 'panel', 'disposal'] as const)('cancels startup directory preparation after %s navigation', async (kind) => { 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')) + const b = bench({ workspaces: workspaceState(), sessions: sessionState(), configureWorkspaces: (workspaces) => { + workspaces.initializeDefault.mockReturnValueOnce(pending.promise) + } }) + if (kind === 'session') b.uiWorkspace.openSession(sid('manual')) + else if (kind === 'panel') b.layout.selectPanel('other-panel' as MainPanelId) + else await b.ctx.fiber.dispose() + expect(b.workspaces.initializeDefault.mock.calls[0]![1]?.aborted).toBe(true) pending.resolve(workspace('default')) - await expect(opening).resolves.toBeUndefined() + await setImmediate() expect(b.sessions.create).not.toHaveBeenCalled() - expect(beforeOpen).not.toHaveBeenCalled() + expect(b.sessions.retain.mock.calls.map(args => args[0])).toEqual(kind === 'session' ? [sid('manual')] : []) + expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false) }) - 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 a prepared Workspace when its Session fails and suppresses a superseded failure', async () => { + for (const superseded of [false, true]) { + const created = Promise.withResolvers() + const b = bench({ workspaces: workspaceState(), sessions: sessionState(), configureWorkspaces: (workspaces) => { + workspaces.initializeDefault.mockImplementationOnce(async () => { + const item = workspace('default') + workspaces.list.set(workspaceState([item])) + return item + }) + }, configureSessions: (sessions) => { sessions.create.mockReturnValueOnce(created.promise) } }) + await vi.waitFor(() => { expect(b.sessions.create).toHaveBeenCalledOnce() }) + if (superseded) b.layout.selectPanel('other-panel' as MainPanelId) + created.reject(new Error('session failed')) + await setImmediate() + expect(b.workspaces.list.getSnapshot().items).toEqual([workspace('default')]) + expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(!superseded) + expect(b.sessions.retain).not.toHaveBeenCalled() + } }) it('retains an explicit main target before revealing its Conversation', () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index b89f2f3ada..37f5d7ec0e 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3251,10 +3251,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 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.', + signature: '@Remote(\'initializeDefault\') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise', + description: 'Initialize or reuse the default Workspace during first-use startup.', 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.', + returns: 'the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message.', }, { signature: '@Remote(\'rename\') rename(request: WorkspaceRenameRequest): Promise', diff --git a/packages/test-support/client-runtime/src/workspaces.ts b/packages/test-support/client-runtime/src/workspaces.ts index 2c6b788fa3..63c1ec78d9 100644 --- a/packages/test-support/client-runtime/src/workspaces.ts +++ b/packages/test-support/client-runtime/src/workspaces.ts @@ -81,16 +81,15 @@ export class TestWorkspaces implements IWorkspaces { } /** - * Initialize the default Workspace through an explicitly installed test stub. + * Initialize the default Workspace through a test stub; defaults to an ineligible first use. * @param request - initial directory name and title. * @param signal - caller lifetime. - * @returns the stubbed Workspace. + * @returns the stubbed Workspace, or undefined when initialization is ineligible. */ - async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise { + 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) + return await (stub?.(request, signal) as Promise | undefined) } /** 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 aeff2554d1..ca9523edbe 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -557,12 +557,12 @@ 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 () => { + it('skips default initialization without a fixture and forwards a configured 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') + await expect(runtime.workspaces.initializeDefault(request, signal)).resolves.toBeUndefined() const workspace = { workspaceId: 'default' as WorkspaceId, title: request.title, path: '/default', sessionIds: [], createdAt: '2026-09-20T00:00:00Z', updatedAt: '2026-09-20T00:00:00Z', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 1f19959e7e..adababb1a4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -246,7 +246,6 @@ 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', From 3a7f8f9657c1516aab510fbadecbc3e10152185d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 17:20:44 +0800 Subject: [PATCH 126/196] test(web): restore ordinary workspace picker readiness --- 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 | 23 ++++++++++++++++------- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 4797a6e4bf..bde54fe69e 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('button', { name: 'Choose workspace' }).count()).toBe(0) + expect(await page.getByRole('textbox', { 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('button', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) + await page.getByRole('textbox', { 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 1de5dfe14f..b2c1a85fb0 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('button', { name: 'Choose workspace', exact: true }).waitFor() + await page.getByRole('textbox', { 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 d8c89afcca..f6655ffbf3 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('button', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 }) + await page.getByRole('textbox', { 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 082f5019ae..fc29e7f913 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -105,16 +105,25 @@ export function probeFreePort(): Promise { } /** - * 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. + * 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. * @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('button', { name: 'Choose workspace', exact: true }).click() + await page.getByRole('textbox', { name: 'Choose workspace' }).click() const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Edit path' }).click() @@ -122,7 +131,8 @@ 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.locator('[data-slot="conversation.session.header"]').waitFor({ state: 'attached', timeout: 15_000 }) + // The pick connected the workspace: the blank session's live composer + // replaces the locked placeholder and enables. await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]') .waitFor({ timeout: 15_000 }) } @@ -138,7 +148,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('button', { name: '选择工作区', exact: true }).click() + await page.getByRole('textbox', { name: '选择工作区' }).click() const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: '编辑路径' }).click() @@ -146,7 +156,6 @@ 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.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 }) } From 2cd03c97582f84924ceafab9b0c8809d7606979d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 17:20:54 +0800 Subject: [PATCH 127/196] refactor(workspace): omit unchanged registry state fields --- packages/workspace/workspace/src/index.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index d0f3d3b8a5..e2f67d948a 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -459,8 +459,6 @@ export class WorkspaceRegistry extends Service { initialized: true, ...(firstUse ? { defaultWorkspaceId: id } : {}), workspaceIds: [id, ...state.workspaceIds], - archivedSessionIds: state.archivedSessionIds, - pinnedSessionIds: state.pinnedSessionIds, }) } catch (error) { this.entities.delete(id) @@ -494,8 +492,6 @@ export class WorkspaceRegistry extends Service { pendingMutation: undefined, initialized: true, workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id), - archivedSessionIds: state.archivedSessionIds, - pinnedSessionIds: state.pinnedSessionIds, } await this.setState({ ...nextState, @@ -549,14 +545,7 @@ 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, - pinnedSessionIds: state.pinnedSessionIds, - }) + await this.setState({ ...state, pendingMutation: undefined }) } private async bootstrap(headers: readonly SessionHeader[]): Promise { From 78d6f7bc1fafe4d60fd69fab03c043d4f4a90d66 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 17:48:46 +0800 Subject: [PATCH 128/196] test(client): mock default workspace initialization during boot --- .../client-runtime/src/assembly/remote-default-responses.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts index c54130bca5..485882f05f 100644 --- a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts +++ b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts @@ -13,6 +13,8 @@ export const remoteDefaultResponses: RemoteTable = { unary: { // api-session-controller `sessions.handleConnected()` on `connection/reset`. 'session/list': ok({ items: [] }), + // ui-workspace startup with no entries; first-use initialization is ineligible. + 'workspace/initializeDefault': ok(undefined), // ui-settings `mirror.ensure()` at apply and again on `connection/reset`. 'settings/describe': ok({ writable: true, hasDocument: false, namespaces: [] }), // ui-model-selection `ModelDirectoryResolver` constructor. From 5294b15b3102bfd4899b13fa3fb1a4ac69419c5a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 17:48:58 +0800 Subject: [PATCH 129/196] docs(workspace): separate constructor parameter tags --- packages/api/workspace-controller/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 7a63e5b699..928f52f883 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -61,7 +61,10 @@ export class WorkspaceController extends TypertRemoteService { private readonly commands: WorkspaceCommands private readonly feed: WorkspaceFeed - /** @param ctx - Host context containing the Workspace registry. @param config - first-use directory policy. */ + /** + * @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) From 112c9e574934fee75f958520f73e75997663e314 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 18:00:26 +0800 Subject: [PATCH 130/196] feat(web): cite dropped folders and files as @ references on Desktop The Desktop preload reports a picked file's real path through __DSH_HOST_PATHS__. Where that bridge exists, the composer inserts a dropped, picked, or pasted folder or non-image file as the same @path reference chip the user could have typed, so nothing uploads and the model reads the target with its file tools; images keep uploading. The drop handler names dropped directories through the entry API, and a browser without the bridge refuses a dropped folder with product copy instead of failing it as an upload. --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/preload-app.ts | 10 +++- apps/desktop/tests/preload-app.spec.ts | 18 +++++++ .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 2 +- packages/client/ui-attachment/README.zh.md | 2 +- .../ui-attachment/src/client/drop-events.ts | 18 ++++++- .../composer-attachments.client.spec.tsx | 37 +++++++++++-- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- packages/client/ui-conversation/package.json | 1 + .../ui-conversation/src/client/apply.ts | 52 +++++++++++++++++-- .../src/client/contract/slots.ts | 17 ++++-- .../src/client/input/editor/view-binding.ts | 2 +- .../ui-conversation/src/client/locales.ts | 4 ++ .../ui-conversation/src/client/service.ts | 8 ++- .../src/client/skeleton/InputBar.tsx | 4 +- .../tests/apply-inject.client.spec.tsx | 33 ++++++++++++ .../tests/input-bar.client.spec.tsx | 19 +++++-- packages/client/ui-conversation/tsconfig.json | 3 ++ pnpm-lock.yaml | 3 ++ 24 files changed, 219 insertions(+), 34 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 79e9bfbae3..d0ce71af3e 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: 94d09c628d6bb40933f95cd3874d412ab18521df -README.zh.md: bfb156bfaa0400799498eabb6034f32c2d50e7f3 +README.md: 68d158ad021fcb8e5958f31797dbef0fa91b74e7 +README.zh.md: ad53465dd9e5ff3ffef9a1ca36f7603b2e088e8e diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 94d09c628d..68d158ad02 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -46,7 +46,7 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. -The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The application preload exposes boot readiness, fatal startup reporting, native directory selection, and the host path of a picked file through `__DSH_HOST_PATHS__`, which the composer turns into `@path` references instead of uploads. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. The `dsh-app://shell/` origin serves packaged update documents, scripts, and styles without contacting the Host. Static requests retain GET/HEAD, path-containment, and MIME handling; each update document keeps its isolated preload and owned-window IPC checks. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index bfb156bfaa..ad53465dd9 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -46,7 +46,7 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。 +应用 preload 暴露启动就绪、致命启动失败上报、原生目录选择,以及通过 `__DSH_HOST_PATHS__` 报告所选文件的宿主机路径,composer 据此把文件和文件夹插成 `@路径` 引用而不上传。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。 `dsh-app://shell/` 无需联系 Host 即可提供打包的更新文档、脚本和样式。静态请求保留 GET/HEAD、路径范围和 MIME 处理;每个更新文档继续使用隔离 preload 和所属窗口的 IPC 校验。 diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 3baf8dd2b9..b2053a8216 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,6 +1,6 @@ -/** Origin-scoped boot, native directory selection, and update presentation with native confirmation actions. */ +/** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer, webUtils } from 'electron' import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts' import { markDocumentPlatform, syncWindowFullscreen } from './preload-platform.ts' import { syncNativeTheme } from './preload-theme.ts' @@ -26,6 +26,12 @@ if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', { pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise, }) + // The composer cites dropped, picked, and pasted files and folders that + // have a real path as `@path` references instead of uploading them; a + // File without one (pasted bytes) answers '' and uploads as before. + contextBridge.exposeInMainWorld('__DSH_HOST_PATHS__', { + pathFor: (file: File) => webUtils.getPathForFile(file), + }) contextBridge.exposeInMainWorld('dshDesktopBoot', { ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise, failed: (message: string) => ipcRenderer.invoke(DESKTOP_IPC.bootFailed, message) as Promise, diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index fb633e8a4a..203fe20348 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -6,6 +6,7 @@ import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts' const electron = vi.hoisted(() => ({ contextBridge: { exposeInMainWorld: vi.fn() }, ipcRenderer: { invoke: vi.fn(), on: vi.fn(), off: vi.fn(), send: vi.fn() }, + webUtils: { getPathForFile: vi.fn() }, })) vi.mock('electron', () => electron) vi.mock('../src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn(), syncWindowFullscreen: vi.fn() })) @@ -71,6 +72,23 @@ it('exposes a directory picker only to the local application document', async () } }) +it('reports host paths of picked files only to the local application document', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === '__DSH_HOST_PATHS__')?.[1] as { pathFor(file: File): string } + const picked = new File(['x'], 'notes.md') + electron.webUtils.getPathForFile.mockReturnValue('/Users/me/notes.md') + expect(api.pathFor(picked)).toBe('/Users/me/notes.md') + expect(electron.webUtils.getPathForFile).toHaveBeenCalledExactlyOnceWith(picked) + for (const url of ['dsh-app://shell/startup.html', 'https://example.com/']) { + vi.resetModules() + electron.contextBridge.exposeInMainWorld.mockClear() + vi.stubGlobal('location', new URL(url)) + await import('../src/preload-app.ts') + expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === '__DSH_HOST_PATHS__')).toBe(false) + } +}) + it.each(['dsh-app://app/', 'dsh-app://shell/plugin-manager.html', 'https://example.com/'])( 'installs Windows appearance only for the application document (%s)', async (url) => { vi.stubGlobal('location', new URL(url)) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 5ce716a2ae..0fad5492e0 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/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-attachment/README.md -README.md: 0765068c73c8ca3cf79b8b23a54629669625ca2e -README.zh.md: 5976acfda5b605dd85ba3d43e18a83af4d9d58e0 +README.md: 5a3e697189936a79df19ff9b976f06e0dd6191fb +README.zh.md: 4168650770f42e7b94146041f37ffc440a5ec52e diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 0765068c73..5a3e697189 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -39,7 +39,7 @@ Trajectory attachment rows request 48px square thumbnails that contain the compl ### Drop overlay -While a file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject. +While a file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject, and the drop handler reports which dropped members are directories through the entry API so the owner can cite or refuse them. ----- diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 5976acfda5..4168650770 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -39,7 +39,7 @@ Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪 ### 拖放遮罩 -文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态——是否接受由持有方的文档级监听器决定。 +文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态,是否接受由持有方的文档级监听器决定,拖放处理器通过 entry API 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。 ----- diff --git a/packages/client/ui-attachment/src/client/drop-events.ts b/packages/client/ui-attachment/src/client/drop-events.ts index e04cbd43d0..79a3c40439 100644 --- a/packages/client/ui-attachment/src/client/drop-events.ts +++ b/packages/client/ui-attachment/src/client/drop-events.ts @@ -1,6 +1,22 @@ /** Document drag-and-drop listeners owned by one mounted attachment view. */ import type { ComposerAttachmentsProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +/** + * Members of a drop that are directories. The `File` a directory drop yields + * is indistinguishable from an empty file, so the entry API is the only + * source of that fact; browsers without it report no directories. + */ +function droppedDirectories(dataTransfer: DataTransfer): ReadonlySet { + const directories = new Set() + for (const item of dataTransfer.items) { + if (item.kind !== 'file' || typeof item.webkitGetAsEntry !== 'function') continue + if (item.webkitGetAsEntry()?.isDirectory !== true) continue + const file = item.getAsFile() + if (file !== null) directories.add(file) + } + return directories +} + /** * Install one attachment view's file-drop listeners. * @param canAcceptDrop - whether this view accepts the dropped files. @@ -49,7 +65,7 @@ export function installDocumentDropEvents( if (dataTransfer === null) return event.preventDefault() reset() - if (canAcceptDrop) onAddFiles([...dataTransfer.files]) + if (canAcceptDrop) onAddFiles([...dataTransfer.files], droppedDirectories(dataTransfer)) } document.addEventListener('dragenter', onDragEnter) document.addEventListener('dragover', onDragOver) diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx index c0d0920c52..e1d5a6cf66 100644 --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx @@ -103,20 +103,49 @@ describe('ComposerAttachments', () => { expect(view.queryByRole('status')).toBeNull() const image = attachment('dropped').file - const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } + const dataTransfer = { types: ['Files'], files: [image], items: [], dropEffect: 'none' } expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false) expect(view.getByRole('status').textContent).toContain('文件或图片拖动到此处即可添加') expect(view.getByRole('status').textContent).toContain('图片限制:最多 20 张,每张 5MB') expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false) expect(dataTransfer.dropEffect).toBe('copy') expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false) - expect(onAddFiles).toHaveBeenCalledWith([image]) + expect(onAddFiles).toHaveBeenCalledWith([image], new Set()) expect(view.queryByRole('status')).toBeNull() }) + it('reports dropped directories from the entry API beside the dropped files', () => { + const onAddFiles = vi.fn() + render() + const folder = new File([], 'project') + const note = new File([Uint8Array.of(1)], 'notes.md', { type: 'text/markdown' }) + const entry = (isDirectory: boolean | null, file: File) => ({ + kind: 'file', getAsFile: () => file, webkitGetAsEntry: () => (isDirectory === null ? null : { isDirectory }), + }) + fireEvent.drop(document.body, { + dataTransfer: { + types: ['Files'], + files: [folder, note], + items: [entry(true, folder), entry(false, note), { kind: 'string', getAsFile: () => null }], + dropEffect: 'none', + }, + }) + expect(onAddFiles).toHaveBeenCalledWith([folder, note], new Set([folder])) + // An entry API that answers nothing, or a browser without it, reports no directories. + fireEvent.drop(document.body, { + dataTransfer: { + types: ['Files'], + files: [note], + items: [entry(null, note), { kind: 'file', getAsFile: () => note }], + dropEffect: 'none', + }, + }) + expect(onAddFiles).toHaveBeenLastCalledWith([note], new Set()) + }) + it('tracks nested file drags and clears an aborted drag', () => { const view = render() - const dataTransfer = { types: ['Files'], files: [], dropEffect: 'none' } + const dataTransfer = { types: ['Files'], files: [], items: [], dropEffect: 'none' } fireEvent.dragLeave(document.body, { dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' }, }) @@ -144,7 +173,7 @@ describe('ComposerAttachments', () => { const onAddFiles = vi.fn() const view = render() const image = attachment('blocked').file - const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' } + const dataTransfer = { types: ['Files'], files: [image], items: [], dropEffect: 'copy' } fireEvent.dragEnter(document.body, { dataTransfer }) expect(view.getByRole('status').textContent).toBe('当前无法添加文件或图片') fireEvent.dragOver(document.body, { dataTransfer }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index b871029e8d..e1005d8ba2 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: 16e821ed4caabae491d552a823bd69a2e976e2bd -README.zh.md: 7316b00c69ca74284b81678082d93e870dee6bbe +README.md: 87f76ef1f1aeeed0bf502044a84fb93c6118c47d +README.zh.md: ec61f9acabfa52e81d4160db511ed25c3070cd38 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 16e821ed4c..87f76ef1f1 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -62,7 +62,7 @@ Active transcripts in the main occurrence expose content-width drag handles in t 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. -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. +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. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), a dropped, picked, or pasted folder or non-image file with a real path is inserted as the same `@path` reference chip the user could have typed, so nothing uploads and the model reads it with its file tools; images keep uploading, and a browser without the bridge refuses a dropped folder with product copy. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 7316b00c69..ec61f9acab 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -62,7 +62,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 常驻 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,因此光标回到草稿原来的位置而不是开头。 -默认发送采用乐观提交: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。 +默认发送采用乐观提交: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 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入、选择或粘贴的文件夹和带真实路径的非图片文件会插成和用户手动输入相同的 `@路径` 引用 chip,因此不上传,模型用文件工具读取;图片仍然上传,没有该桥的浏览器会用产品文案拒绝拖入的文件夹。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 8a92b63714..e5b6bcdf2b 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -80,6 +80,7 @@ "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 07d754d873..ea52eb6ec9 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -19,7 +19,9 @@ import type { } from './contract/slots.ts' import type { InputNotice } from './contract/input.ts' import { createConversationStore, readConversationViewPreference } from './stores.ts' -import { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' +import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' +import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' +import { ConversationController, UnsupportedImageMediaTypeError, isImageMediaType } from './service.ts' import type { IConversation } from './service.ts' import { ComposerBlockRegistry } from './input/blocks.ts' import type { ComposerBlock } from './contract/composer-blocks.ts' @@ -87,6 +89,21 @@ const ABSENT_FILE_UPLOADS = { subscribe: () => () => {}, } +/** + * Browser-shell bridge reporting the harness-host path of a picked file. The + * Desktop preload exposes it on the application document; a served Web page + * has none, so every non-image file uploads there. + */ +interface HostPathBridge { + /** Absolute harness-host path of one picked file, or empty when the shell has none for it. */ + pathFor(file: File): string +} + +/** The shell-installed bridge, when this document runs inside the Desktop application. */ +function hostPathBridge(): HostPathBridge | undefined { + return (globalThis as { __DSH_HOST_PATHS__?: HostPathBridge }).__DSH_HOST_PATHS__ +} + interface WorkspaceNavigation { openSession(sessionId: SessionId): void openWorkspace( @@ -392,12 +409,41 @@ export function apply(ctx: Context, config: Config = Config({})): void { const conversation = concreteConversation(ctx) const shell = inputHub.shell(sessionId) const inputTriggers = inputHub.inputTriggers(sessionId) + const bridge = hostPathBridge() + // A folder or non-image file the shell can name is cited as the same + // `@path` reference the user could have typed: the model reads it with + // its file tools, nothing uploads, and the draft keeps the chip. A + // directory the shell cannot name has nothing to upload or cite. + const cite = (file: File, directory: boolean): boolean | string => { + if (bridge === undefined) return directory ? t('attachment.directoryDesktopOnly') : false + const path = bridge.pathFor(file) + if (path === '') return directory ? t('attachment.directoryDesktopOnly') : false + if (!directory && isImageMediaType(file.type)) return false + const mention = formatFileMention({ path, kind: directory ? 'directory' : 'file' }, false) + if (mention === undefined) return t('attachment.pathUnsupported') + const label = workspaceTitleOf(path) || file.name + const selection = shell.caretSpan() + shell.insertReference({ + source: 'reference', + ref: mention, + label: directory ? `${label}/` : label, + appearance: directory ? 'folder' : 'file', + clipboardText: mention, + }, { ...selection, draftRev: shell.snapshot.draftRev }) + return true + } return { keyboard: shell, - addFiles: (files) => { + addFiles: (files, directories = new Set()) => { if (sessions.binding(sessionId) === undefined) return t('file.sessionUnavailable') + const uploads: File[] = [] + for (const file of files) { + const cited = cite(file, directories.has(file)) + if (typeof cited === 'string') return cited + if (!cited) uploads.push(file) + } try { - const drafts = conversation.createDrafts(sessionId, files) + const drafts = conversation.createDrafts(sessionId, uploads) if (!shell.addAttachments(drafts.map(draft => draft.id))) { conversation.releaseDraftAttachments(drafts) } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 023babf26e..89e7a04a49 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -60,8 +60,12 @@ export interface ComposerAttachmentsOwnerProps { attachments: readonly ComposerAttachment[] /** Whether a document-level file drop may add attachments now. */ canAcceptDrop: boolean - /** Add one dropped batch through the composer's validation path. */ - onAddFiles: (files: readonly File[]) => void + /** + * Add one dropped batch through the composer's validation path. + * @param files - dropped, pasted, or picked browser files in source order. + * @param directories - members of `files` the drop source identified as directories. + */ + onAddFiles: (files: readonly File[], directories?: ReadonlySet) => void /** Remove one draft attachment through the Conversation service. */ onRemoveAttachment: (id: DraftAttachmentId) => void /** Current per-draft upload states for file-kind attachments. */ @@ -366,7 +370,14 @@ export interface ComposerBarOwnerProps { /** Package-private operations injected into the resident composer bar. */ export interface ComposerBarInjected { keyboard: ComposerKeyboard | undefined - addFiles: ((files: readonly File[]) => string | null) | undefined + /** + * Register one picked batch; resolves to the rejection copy or null. Where + * the browser shell reports host paths (the Desktop application), files + * and folders with a real path become `@path` references in the draft + * instead of uploads; `directories` names the members the drop source + * identified as directories. + */ + addFiles: ((files: readonly File[], directories?: ReadonlySet) => string | null) | undefined removeAttachment: ((id: DraftAttachmentId) => void) | undefined resolveDraftAttachments: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined /** Restart one failed file upload; absent without a session. */ diff --git a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts index 8dcd3d8c63..098bd88f47 100644 --- a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts +++ b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts @@ -14,7 +14,7 @@ interface DraftViewGate { running: boolean steeringAvailable: boolean busyEnter: BusyEnterBehavior - intakeFiles: (files: readonly File[]) => void + intakeFiles: (files: readonly File[], directories?: ReadonlySet) => void uploadsPending: boolean showToast: (text: string) => void t: ComposerBarProps['t'] diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 464ff55e17..d23e903717 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -31,6 +31,8 @@ export const zh = { 'attachment.dropTitle': '文件或图片拖动到此处即可添加', 'attachment.dropDesc': '图片限制:最多 {count} 张,每张 {size}', 'attachment.dropBlocked': '当前无法添加文件或图片', + 'attachment.directoryDesktopOnly': '只有桌面端支持添加文件夹,浏览器里请添加单个文件', + 'attachment.pathUnsupported': '路径含有无法引用的字符,请改名后再试', 'image.pending': '待发送图片', 'image.openOriginal': '查看原图', 'image.openOriginalLabel': '{label},点击查看原图', @@ -382,6 +384,8 @@ export const en = { 'attachment.dropTitle': 'Drag files or images here to add them', 'attachment.dropDesc': 'Image limit: up to {count} images, {size} each', 'attachment.dropBlocked': 'Files and images cannot be added right now', + 'attachment.directoryDesktopOnly': 'Folders can only be added in the desktop app; add individual files in the browser', + 'attachment.pathUnsupported': 'The path contains characters a reference cannot carry; rename it and try again', 'image.pending': 'Pending images', 'image.openOriginal': 'View original', 'image.openOriginalLabel': '{label}, click to view original', diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 367647658e..2ef2309ccf 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -594,8 +594,12 @@ function imageMediaType(value: string): ImageMediaType { } } -/** Whether a browser-declared MIME selects the image draft path (all other files upload verbatim). */ -function isImageMediaType(value: string): boolean { +/** + * Whether a browser-declared MIME selects the image draft path (all other files upload verbatim). + * @param value - the browser's declared MIME type. + * @returns whether the file is an accepted raster image. + */ +export function isImageMediaType(value: string): boolean { return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif' } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 6a4ea12740..d85d2ba396 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -198,7 +198,7 @@ export const InputBar = memo(function InputBar({ // client-side size or count limit and upload as soon as they are picked. // The host enforces the same image limits at submit for callers that bypass // this composer. - const intakeFiles = useCallback((files: readonly File[]): void => { + const intakeFiles = useCallback((files: readonly File[], directories?: ReadonlySet): void => { if (subagent !== null || addFiles === undefined || files.length === 0) return const rejected = ((): string | null => { if (imageLimits !== undefined) { @@ -217,7 +217,7 @@ export const InputBar = memo(function InputBar({ return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) }) } } - return addFiles(files) + return addFiles(files, directories) })() if (rejected !== null) showToast(rejected) }, [subagent, addFiles, attachments, imageLimits, showToast, t]) 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 e90a11a163..f7e5e0b899 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -399,6 +399,39 @@ describe('Conversation inject API', () => { await b.runtime.dispose() }) + it('cites shell-named files and folders as @ references and refuses folders without the shell bridge', async () => { + const browser = await bench() + const folder = new File([], 'project') + expect(browser.composerApi(ROOT).addFiles?.([folder], new Set([folder]))) + .toBe('只有桌面端支持添加文件夹,浏览器里请添加单个文件') + expect(browser.inputApi(ROOT).state.getSnapshot().attachmentIds).toEqual([]) + await browser.runtime.dispose() + + const paths = new Map([['project', '/Users/me/my project'], ['notes.md', '/Users/me/notes.md'], ['shot.png', '/Users/me/shot.png']]) + vi.stubGlobal('__DSH_HOST_PATHS__', { pathFor: (file: File) => paths.get(file.name) ?? '' }) + try { + const desktop = await bench() + const composer = desktop.composerApi(ROOT) + const { state } = desktop.inputApi(ROOT) + const note = new File([Uint8Array.of(1)], 'notes.md', { type: 'text/markdown' }) + const shot = new File([Uint8Array.of(2)], 'shot.png', { type: 'image/png' }) + const pasted = new File([Uint8Array.of(3)], 'pasted.bin', { type: 'application/octet-stream' }) + expect(composer.addFiles?.([folder, note, shot, pasted], new Set([folder]))).toBeNull() + // The folder and the file became references in the draft; the image and the pathless bytes stayed drafts. + expect(state.getSnapshot().draft).toBe('@"/Users/me/my project/ @/Users/me/notes.md ') + const drafts = composer.resolveDraftAttachments?.(state.getSnapshot().attachmentIds) ?? [] + expect(drafts.map(draft => draft.kind)).toEqual(['image', 'file']) + await vi.waitFor(() => { expect(desktop.rootUpload).toHaveBeenCalledOnce() }) + // A directory the shell cannot name is refused even with the bridge present. + const nameless = new File([], 'nameless') + expect(composer.addFiles?.([nameless], new Set([nameless]))) + .toBe('只有桌面端支持添加文件夹,浏览器里请添加单个文件') + await desktop.runtime.dispose() + } finally { + vi.unstubAllGlobals() + } + }) + it('fails loud for an unknown binding or an unloaded scoped service', async () => { const b = await bench() const entry = b.entryOf('conversation.composer.bar') 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..fc02732780 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -96,7 +96,7 @@ interface BenchOptions { attachments?: readonly ComposerAttachment[] /** Upload states served for file-kind drafts (absent = every file is ready). */ fileUploads?: DraftFileUploads - addFiles?: (files: readonly File[]) => string | null + addFiles?: (files: readonly File[], directories?: ReadonlySet) => string | null commandMenuOpen?: boolean busyEnter?: 'queue' | 'steer' toggleCommandMenu?: (selection: { start: number; end: number }) => void @@ -344,7 +344,7 @@ describe('image draft rail', () => { getData: () => '同时粘贴的文字', }, }) - expect(addFiles).toHaveBeenCalledWith([image]) + expect(addFiles).toHaveBeenCalledWith([image], undefined) // The paste lands inside the PASTE_COMMAND update; its commit is a microtask away. await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('同时粘贴的文字') }) }) @@ -386,7 +386,7 @@ describe('image draft rail', () => { const within = bench({ addFiles: vi.fn(() => null), imageLimits: limits }) const fits = png(16, 'fits.png') intake(within, [fits]) - expect(within.props.addFiles).toHaveBeenCalledWith([fits]) + expect(within.props.addFiles).toHaveBeenCalledWith([fits], undefined) expect(within.view.queryByRole('alert')).toBeNull() }) @@ -409,7 +409,7 @@ describe('image draft rail', () => { new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }), ] act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) }) - expect(addFiles).toHaveBeenCalledWith(files) + expect(addFiles).toHaveBeenCalledWith(files, undefined) expect(result.view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片') }) @@ -428,6 +428,17 @@ describe('image draft rail', () => { expect(attachmentOwner(result.slotCalls).dropLimits).toEqual({ count: 20, size: '5MB' }) }) + it('forwards dropped directories to addFiles and announces its refusal', () => { + const addFiles = vi.fn((_files: readonly File[], directories?: ReadonlySet) => + directories !== undefined && directories.size > 0 ? '只有桌面端支持添加文件夹,浏览器里请添加单个文件' : null) + const result = bench({ addFiles }) + const folder = new File([], 'project') + const note = new File([Uint8Array.of(1)], 'notes.md', { type: 'text/markdown' }) + act(() => { attachmentOwner(result.slotCalls).onAddFiles([folder, note], new Set([folder])) }) + expect(addFiles).toHaveBeenCalledWith([folder, note], new Set([folder])) + expect(result.view.getByRole('alert').textContent).toContain('只有桌面端支持添加文件夹') + }) + it('announces server attachment rejections as product copy, other codes as developer text', () => { const attachmentError = (reason: string): SessionSnapshot['promptError'] => ({ op: 'send', diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 1dbe8b1ce5..a4df996a41 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -92,6 +92,9 @@ { "path": "../../util/values" }, + { + "path": "../../context/file-reference" + }, { "path": "../../util/workspace-path" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae17bcec90..30883704cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2983,6 +2983,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal From d72f85a0adf4010f22f48d50c32784d513b7ae32 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sun, 20 Sep 2026 20:41:48 +0800 Subject: [PATCH 131/196] fix(desktop): paint an opaque base while minimized to cover the vibrancy reattach gap --- apps/desktop/src/main.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7531a7cfa9..75fe3ab47c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -154,6 +154,26 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi // Reloads and navigations re-register the preload listener; resend the // current state so a fullscreen reload does not fall back to windowed CSS. window.webContents.on('did-finish-load', sendFullscreen) + // Deminiaturize reattaches the NSVisualEffectView material late + // (electron/electron#25368), so a transparent window shows the desktop + // through the sidebar until then. Paint an opaque base while minimized or + // hidden so the deminiaturize animation and the reattachment gap show a + // solid fill; restoring flips back, and the null -> 'sidebar' transition + // forces the material to reattach. + const applyBackdrop = (): void => { + if (window.isDestroyed()) return + if (window.isMinimized() || !window.isVisible()) { + window.setVibrancy(null) + window.setBackgroundColor(nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb') + } else { + window.setVibrancy('sidebar') + window.setBackgroundColor('#00000000') + } + } + window.on('minimize', applyBackdrop) + window.on('hide', applyBackdrop) + window.on('restore', applyBackdrop) + window.on('show', applyBackdrop) } window.webContents.on('context-menu', (_event, { isEditable, selectionText, editFlags }) => { const items: MenuItemConstructorOptions[] = [] From 5ec3353af2be73c6773ae3ce917d547f0ea5cd3a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 20:42:50 +0800 Subject: [PATCH 132/196] refactor(workspace): reuse toast for default initialization failures --- apps/web/tests/default-workspace.e2e.ts | 14 ++-- .../default-workspace/failure.expected.md | 7 +- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../src/client/WorkspacePicker.tsx | 48 ++++--------- .../ui-workspace/src/client/contract/slots.ts | 9 +-- .../client/ui-workspace/src/client/index.ts | 13 ++-- .../client/ui-workspace/src/client/locales.ts | 8 +-- .../ui-workspace/src/client/navigation.ts | 20 +++--- .../client/session-actions/RowActionToast.tsx | 13 ++-- .../ui-workspace/tests/apply.client.spec.ts | 20 +++++- .../tests/session-actions.client.spec.tsx | 1 + .../tests/workspace-picker.client.spec.tsx | 35 +--------- .../tests/workspaces-service.client.spec.ts | 69 ++++++++++++------- 15 files changed, 114 insertions(+), 151 deletions(-) diff --git a/apps/web/tests/default-workspace.e2e.ts b/apps/web/tests/default-workspace.e2e.ts index d3b6db624d..d27f430fd3 100644 --- a/apps/web/tests/default-workspace.e2e.ts +++ b/apps/web/tests/default-workspace.e2e.ts @@ -81,16 +81,14 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { try { await page.goto(scaffold.authenticatedUrl) const input = page.locator('[data-composer-input][contenteditable="true"]').first() - const modal = page.getByRole('dialog', { name: '无法创建默认工作区' }) - await modal.waitFor() + const notice = page.getByRole('alert').filter({ hasText: '无法创建默认工作区' }) + await notice.waitFor() 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() + await compareOrRefreshGolden(FAILURE_EXPECTED, await notice.ariaSnapshot(), MODE) + expect(await page.getByRole('dialog').count()).toBe(0) expect(await input.count()).toBe(0) - await page.reload() - await modal.waitFor() - await modal.getByRole('button', { name: '选择文件夹', exact: true }).click() + await page.getByRole('button', { name: '选择工作区', exact: true }).click() const picker = page.getByRole('dialog', { name: '选择工作区目录' }) await picker.waitFor() await picker.getByRole('button', { name: '编辑路径', exact: true }).click() @@ -126,7 +124,7 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { await page.goto(scaffold.authenticatedUrl) expect((await attempted).ok()).toBe(true) await page.getByRole('button', { name: 'Choose workspace', exact: true }).waitFor() - expect(await page.getByRole('dialog', { name: 'Unable to create default workspace' }).count()).toBe(0) + expect(await page.getByRole('alert').filter({ hasText: 'Unable to create default workspace' }).count()).toBe(0) expect(await page.locator('[data-composer-input][contenteditable="true"]').count()).toBe(0) expect(scaffold.ctx.workspaceRegistry.list()).toEqual([]) expect(scaffold.ctx.sessions.list()).toEqual([]) diff --git a/apps/web/tests/expected/default-workspace/failure.expected.md b/apps/web/tests/expected/default-workspace/failure.expected.md index 6944d1cf14..c9624749f0 100644 --- a/apps/web/tests/expected/default-workspace/failure.expected.md +++ b/apps/web/tests/expected/default-workspace/failure.expected.md @@ -1,6 +1 @@ -- dialog "无法创建默认工作区": - - heading "无法创建默认工作区" [level=2] - - button "关闭" - - text: 请选择一个文件夹后重试 - - button "取消" - - button "选择文件夹" +- alert: 无法创建默认工作区,请通过“选择工作区”选择文件夹 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 4af8adc8d4..2f176f2329 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: 768c66503af70d0b587b25c09aa8412b0a647439 -README.zh.md: 25df6f8e6a2ec0a001d784c7bde18e454e9d706f +README.md: bf67f12f15f634a29dc05744c079255723ae2a62 +README.zh.md: e57700d983b31872383fc4b26f9b5b080d66ba6c diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 768c66503a..bf67f12f15 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 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. -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. Preparation failure offers Cancel or Choose folder through the existing picker, and is not retried until the next startup. A successfully registered Workspace remains available when Session creation or later submission fails. +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. The Client chooses the initial directory name and title from its language at startup: 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 successfully initialized Workspace keeps its directory and title across language changes. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 25df6f8e6a..e57700d983 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -69,7 +69,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** 新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求,目前只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 -Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `workspaces.initializeDefault`,创建或复用其空白 Session。选中该 Session 后输入框才可编辑,不会自动提交消息。后续导航或所属上下文销毁会阻止启动流程选中其结果。不符合首次使用条件时仍可选择文件夹,不显示错误。准备失败时通过现有选择器提供取消或选择文件夹操作,直到下次启动才重试。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。 +Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `workspaces.initializeDefault`,创建或复用其空白 Session。选中该 Session 后输入框才可编辑,不会自动提交消息。后续导航或所属上下文销毁会阻止启动流程选中其结果。不符合首次使用条件时仍可选择文件夹,不显示错误。默认工作区创建失败时显示短暂提示,引导用户通过“选择工作区”选择文件夹,直到下次启动才重试。Session 创建失败沿用普通的恢复错误处理。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。 Client 在启动时按其语言选择初始目录名和标题:中文使用 `默认工作区`,英文使用 `Default workspace`,其他语言使用目录 `default-workspace` 和标题 `Default workspace`。初始化过程中保留请求中的名称。成功初始化的工作区在切换语言后保留原目录和标题。 diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 42e177afce..b74e3689dc 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -231,45 +231,21 @@ export function WorkspacePicker({ onClose, createWorkspace, useDirectoryFlow, - useDefaultFailure, - dismissDefaultFailure, renderSlot, t, }: WorkspacePickerProps) { - const failed = useDefaultFailure(value => value) - const flowAvailable = useDirectoryFlow(value => value) - const [recovering, setRecovering] = useState(false) return ( - <> - renderSlot('conversation.hero.workspace.directoryFlow', owner)} - selectedId={selectedId} - onPick={onPick} - onClose={() => { setRecovering(false); onClose() }} - /> - - - - - )} - > - {t('defaultWorkspace.retry')} - - + renderSlot('conversation.hero.workspace.directoryFlow', owner)} + selectedId={selectedId} + onPick={onPick} + onClose={onClose} + /> ) } diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index b878e24986..36ab1850d4 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -238,12 +238,13 @@ export type SessionRowActionProps = & PropsLocale<'workspace'> & InjectFace -/** One transient notice a row action raises; the overlay toast entry renders it. */ +/** One transient Workspace notice rendered by the overlay toast entry. */ export type RowToast = | { kind: 'archived'; sessionId: SessionId } | { kind: 'pinFailed' } | { kind: 'unpinFailed' } | { kind: 'archivedNotOpenable' } + | { kind: 'defaultWorkspaceFailed' } /** The notice on display; `seq` keys remounts so a repeated notice restarts its hold. */ export type RowToastState = RowToast & { seq: number } @@ -364,12 +365,6 @@ export type WorkspaceBrowserProps = * supplies the implicit index signature required by the registry. */ export type WorkspacePickerInjected = DirectoryPickingInjected & { - /** Dismiss the startup failure before choosing a directory manually. */ - dismissDefaultFailure: () => void - hooks: { - /** Whether first-use startup failed to prepare a Workspace and Session. */ - defaultFailure: HostObservable - } /** Adopt a picked host directory as a real Workspace before targeting a Session. */ createWorkspace: (input: { path: string }) => Promise } diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 8d96c65d77..7c821033d6 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -103,8 +103,13 @@ export function apply(ctx: Context): void { const viewHandle = createWorkspaceViewStore() const viewInstance = viewHandle.create() const viewStore: typeof viewHandle = { ...viewHandle, create: () => viewInstance } + const rowToast = createSnapshotStore(null) + let toastSeq = 0 + const notify = (toast: RowToast): void => { rowToast.set({ ...toast, seq: ++toastSeq }) } const uiWorkspace = new UiWorkspaceService( - ctx, ctx.remote.directoryPicker, workspaces, sessions, viewInstance.actions) + ctx, ctx.remote.directoryPicker, workspaces, sessions, viewInstance.actions, + () => { notify({ kind: 'defaultWorkspaceFailed' }) }, + ) ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } }) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') @@ -137,9 +142,6 @@ export function apply(ctx: Context): void { // writes through its own injected callback and the surface reads through // its bound hook. const renameRequest = createSnapshotStore(null) - const rowToast = createSnapshotStore(null) - let toastSeq = 0 - const notify = (toast: RowToast): void => { rowToast.set({ ...toast, seq: ++toastSeq }) } const requestSessionRename = (sessionId: SessionId, currentTitle: string): void => { renameRequest.set({ sessionId, currentTitle }) } @@ -219,8 +221,7 @@ export function apply(ctx: Context): void { }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => workspaces.create(input), - dismissDefaultFailure: () => { uiWorkspace.defaultFailure.set(false) }, - hooks: { directoryFlow: pickerFlowSource, defaultFailure: uiWorkspace.defaultFailure }, + hooks: { directoryFlow: pickerFlowSource }, }) // Each registration declares its owned children in the same call; slot // injection follows both the owner and declaration HMR lifetimes. diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index d926ce987f..3ecc46741f 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -6,10 +6,8 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'defaultWorkspace.failed': '无法创建默认工作区', + 'defaultWorkspace.failed': '无法创建默认工作区,请通过“选择工作区”选择文件夹', 'defaultWorkspace.title': '默认工作区', - 'defaultWorkspace.retry': '请选择一个文件夹后重试', - 'defaultWorkspace.choose': '选择文件夹', 'group.ungrouped': '未分组', 'session.new': '新会话', 'section.workspaces': '工作区', @@ -103,10 +101,8 @@ export type WorkspaceKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { - 'defaultWorkspace.failed': 'Unable to create default workspace', + 'defaultWorkspace.failed': 'Unable to create default workspace. Use Choose workspace to select a folder.', 'defaultWorkspace.title': 'Default workspace', - 'defaultWorkspace.retry': 'Please choose a folder and try again', - 'defaultWorkspace.choose': 'Choose folder', 'group.ungrouped': 'Ungrouped', 'session.new': 'New Session', 'section.workspaces': 'Workspaces', diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index b21cf06f15..13c12651fd 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -120,8 +120,6 @@ export class DirectoryBrowseError extends Error { /** Implements Workspace archive and directory UI operations. */ class UiWorkspaceService extends Service implements UiWorkspace { - /** Startup failure awaiting dismissal or explicit directory selection. */ - readonly defaultFailure = createSnapshotStore(false) private readonly connecting = new Map>() private readonly lifetime = new AbortController() private readonly selection = createSnapshotStore( @@ -135,6 +133,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { * @param workspaces - pure Workspace Controller. * @param sessions - pure Session Controller. * @param view - the browser's viewing-store write set (one instance shared with its registration). + * @param notifyDefaultFailure - show the startup creation failure through the Workspace notice channel. */ constructor( ctx: Context, @@ -142,6 +141,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { private readonly workspaces: IWorkspaces, private readonly sessions: ISessions, private readonly view: Pick, + private readonly notifyDefaultFailure: () => void, ) { super(ctx, 'uiWorkspace') ctx.effect(() => { @@ -324,28 +324,28 @@ class UiWorkspaceService extends Service implements UiWorkspace { && !workspaces.archivedSessionIds.includes(summary.id)) { sessionId = await this.reuseBlank(workspace.workspaceId, summary.id) } - const target = workspace?.workspaceId ?? recentWorkspace(workspaces.items, sessions.byId) - if (sessionId === undefined && target !== undefined) sessionId = await this.connectWorkspace(target) + let target = workspace?.workspaceId ?? recentWorkspace(workspaces.items, sessions.byId) if (target === undefined && workspaces.items.length === 0 && sessions.ids.length === 0) { - sessionId = await this.initializeDefaultWorkspace(navigation) + const prepared = await this.initializeDefaultWorkspace(navigation) + if (navigation.aborted) return + target = prepared?.workspaceId } + if (sessionId === undefined && target !== undefined) sessionId = await this.connectWorkspace(target) if (sessionId !== undefined && !navigation.aborted) { this.replaceMain(sessionId, navigation, 'preserve') } } - private async initializeDefaultWorkspace(signal: AbortSignal): Promise { + private async initializeDefaultWorkspace(signal: AbortSignal): Promise { const language = this.ctx.locale.getSnapshot().active.toLowerCase().split('-')[0] const title = (language === 'zh' ? zh : en)['defaultWorkspace.title'] try { - const workspace = await this.workspaces.initializeDefault({ + return await this.workspaces.initializeDefault({ directoryName: language === 'zh' || language === 'en' ? title : 'default-workspace', title, }, signal) - if (workspace === undefined || signal.aborted) return undefined - return await this.connectWorkspace(workspace.workspaceId) } catch (_error: unknown) { - if (!signal.aborted) this.defaultFailure.set(true) + if (!signal.aborted) this.notifyDefaultFailure() return 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 f000fc4b4a..c5a3caccc6 100644 --- a/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx +++ b/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx @@ -1,8 +1,6 @@ /** - * The `shell.overlay` entry that shows the row actions' notices: the archived - * notice with its undo and "show archived" actions, pin failures, and the - * browser's "archived rows cannot be opened" notice. One notice at a time; - * a parent rerender does not extend its hold. + * The `shell.overlay` entry for Workspace and Session notices. + * 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' @@ -11,9 +9,9 @@ import type { RowToastProps } from '../contract/slots.ts' const ARCHIVED_TOAST_HOLD_MS = 6000 /** - * Render the row actions' current notice: the archived notice with its undo + * Render the current notice: the archived notice with its undo * and show-archived actions on a 6 s hold, or a plain warning for a failed - * pin or an archived row that was clicked. + * pin, an archived row that was clicked, or default Workspace creation. * @param props - the notice hook, its dismissal, the two archived-notice actions, and the locale seat. * @returns the notice on display, or null. */ @@ -38,7 +36,8 @@ export function RowActionToast({ useToast, dismissToast, undoArchive, showArchiv } const text = toast.kind === 'pinFailed' ? t('toast.pinFailed') - : toast.kind === 'unpinFailed' ? t('toast.unpinFailed') : t('toast.archivedNotOpenable') + : toast.kind === 'unpinFailed' ? t('toast.unpinFailed') + : toast.kind === 'defaultWorkspaceFailed' ? t('defaultWorkspace.failed') : t('toast.archivedNotOpenable') return ( () => {} const workspacesSubscribe = vi.fn(subscribe) + const initializeDefault = vi.fn(async (): Promise => undefined) ctx.provide('workspaces', { list: { getSnapshot: () => workspaceSnapshot, subscribe: workspacesSubscribe }, create, + initializeDefault, rename, delete: vi.fn(async () => undefined), insertBefore: vi.fn(async () => undefined), @@ -128,7 +130,7 @@ async function bench() { return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, retain, using, selectPanel, search, renameSession, binding, fork, pickDirectory, pinSession, unpinSession, - workspacesSubscribe, + workspacesSubscribe, initializeDefault, setWorkspaces: (snapshot: WorkspaceSnapshot): void => { workspaceSnapshot = snapshot }, setSessions: (snapshot: SessionListState): void => { sessionSnapshot = snapshot }, } @@ -182,6 +184,20 @@ describe('ui-workspace apply', () => { ]) }) + it('reports a default Workspace creation failure through the shared notice overlay', async () => { + const b = await bench() + onTestFinished(() => b.ctx.fiber.dispose()) + b.initializeDefault.mockRejectedValueOnce(new Error('denied')) + declare(b.slots, 'shell.overlay') + await b.ctx.plugin({ inject: [...inject], apply }).await() + const face = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected + await vi.waitFor(() => { + expect(face.hooks.toast.getSnapshot()).toMatchObject({ kind: 'defaultWorkspaceFailed' }) + }) + face.dismissToast() + expect(face.hooks.toast.getSnapshot()).toBeNull() + }) + it('registers browser and pickers for declarations arriving before or after apply', async () => { const before = await bench() declare(before.slots, 'sidebar.workspaces') diff --git a/packages/client/ui-workspace/tests/session-actions.client.spec.tsx b/packages/client/ui-workspace/tests/session-actions.client.spec.tsx index ce5cb47dda..c7e3b1150d 100644 --- a/packages/client/ui-workspace/tests/session-actions.client.spec.tsx +++ b/packages/client/ui-workspace/tests/session-actions.client.spec.tsx @@ -387,6 +387,7 @@ describe('RowActionToast', () => { ['pinFailed', '置顶失败,请稍后重试'], ['unpinFailed', '取消置顶失败,请稍后重试'], ['archivedNotOpenable', '已归档对话暂时无法查看,请取消归档后查看'], + ['defaultWorkspaceFailed', '无法创建默认工作区,请通过“选择工作区”选择文件夹'], ] as const)('shows the %s warning and takes it down when its hold ends', (kind, text) => { vi.useFakeTimers() try { 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 e72d523668..9ff0181207 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -1,7 +1,6 @@ // @vitest-environment jsdom import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { @@ -89,19 +88,14 @@ function mount( items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn(), occupancy = occupancySource(), - options: { defaultFailure?: boolean; open?: boolean } = {}, ) { - const failure = createSnapshotStore(options.defaultFailure ?? false) - const useDefaultFailure = bindSnapshotSelector(failure) - const dismissDefaultFailure = vi.fn(() => { failure.set(false) }) const onPick = vi.fn() const onClose = vi.fn() const anchorRef = anchor() const { probe, renderSlot } = flowProbe() const renderPicker = (nextItems: readonly WorkspaceView[]) => ( { view.rerender(renderPicker(nextItems)) }, } } @@ -226,7 +220,6 @@ describe('WorkspacePicker', () => { const { renderSlot } = flowProbe() render( undefined} @@ -245,7 +238,6 @@ describe('WorkspacePicker', () => { const { renderSlot } = flowProbe() render( undefined} @@ -329,26 +321,3 @@ describe('WorkspacePicker', () => { expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull() }) }) - -describe('default Workspace startup recovery', () => { - it('dismisses the failure without opening the directory flow', () => { - const b = mount([], vi.fn(), occupancySource(), { defaultFailure: true, open: false }) - expect(screen.getByRole('dialog', { name: '无法创建默认工作区' })).toBeTruthy() - expect(b.probe.owner?.open).toBe(false) - fireEvent.click(screen.getByRole('button', { name: '取消' })) - expect(b.dismissDefaultFailure).toHaveBeenCalledOnce() - expect(screen.queryByRole('dialog', { name: '无法创建默认工作区' })).toBeNull() - expect(b.probe.owner?.open).toBe(false) - }) - - it('opens the composed directory flow from the startup failure', async () => { - const create = vi.fn(async () => workspace('chosen')) - const b = mount([], create, occupancySource(), { defaultFailure: true, open: false }) - fireEvent.click(screen.getByRole('button', { name: '选择文件夹' })) - expect(b.dismissDefaultFailure).toHaveBeenCalledOnce() - expect(b.probe.owner?.open).toBe(true) - await act(async () => { b.probe.owner!.onPicked('/chosen') }) - expect(create).toHaveBeenCalledExactlyOnceWith({ path: '/chosen' }) - expect(b.onPick).toHaveBeenCalledExactlyOnceWith(wid('chosen')) - }) -}) 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 4f9cd973b8..8efdb0af95 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -294,14 +294,16 @@ function bench(options: BenchOptions = {}) { options.configureWorkspaces?.(workspaces) options.configureSessions?.(sessions) const view = createWorkspaceViewStore().create() + const notifyDefaultFailure = vi.fn() const uiWorkspace = new UiWorkspaceService( ctx, directoryPicker.remote, workspaces, sessions, view.actions, + notifyDefaultFailure, ) - return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, layout, selectPanel, view } + return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, layout, selectPanel, view, notifyDefaultFailure } } describe('UiWorkspaceService', () => { @@ -326,10 +328,10 @@ describe('UiWorkspaceService', () => { }) expect(b.workspaces.initializeDefault).toHaveBeenCalledExactlyOnceWith({ directoryName, title }, expect.any(AbortSignal)) expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('default') }) - expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false) + expect(b.notifyDefaultFailure).not.toHaveBeenCalled() }) - it('leaves an ineligible empty installation without a Session or failure dialog', async () => { + it('leaves an ineligible empty installation without a Session or failure notice', async () => { const b = bench({ workspaces: workspaceState(), sessions: sessionState() }) await setImmediate() b.workspaces.list.set(workspaceState()) @@ -337,7 +339,7 @@ describe('UiWorkspaceService', () => { await setImmediate() expect(b.workspaces.initializeDefault).toHaveBeenCalledOnce() expect(b.sessions.create).not.toHaveBeenCalled() - expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false) + expect(b.notifyDefaultFailure).not.toHaveBeenCalled() }) it('requires explicit selection when the startup Session list contains history', async () => { @@ -351,13 +353,12 @@ describe('UiWorkspaceService', () => { const b = bench({ workspaces: workspaceState(), sessions: sessionState(), configureWorkspaces: (workspaces) => { workspaces.initializeDefault.mockRejectedValueOnce(new Error('denied')) } }) - await vi.waitFor(() => { expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(true) }) - b.uiWorkspace.defaultFailure.set(false) + await vi.waitFor(() => { expect(b.notifyDefaultFailure).toHaveBeenCalledOnce() }) b.workspaces.list.set(workspaceState()) await setImmediate() expect(b.workspaces.initializeDefault).toHaveBeenCalledOnce() expect(b.sessions.create).not.toHaveBeenCalled() - expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false) + expect(b.notifyDefaultFailure).toHaveBeenCalledOnce() }) it.each(['session', 'panel', 'disposal'] as const)('cancels startup directory preparation after %s navigation', async (kind) => { @@ -373,27 +374,43 @@ describe('UiWorkspaceService', () => { await setImmediate() expect(b.sessions.create).not.toHaveBeenCalled() expect(b.sessions.retain.mock.calls.map(args => args[0])).toEqual(kind === 'session' ? [sid('manual')] : []) - expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false) + expect(b.notifyDefaultFailure).not.toHaveBeenCalled() }) - it('retains a prepared Workspace when its Session fails and suppresses a superseded failure', async () => { - for (const superseded of [false, true]) { - const created = Promise.withResolvers() - const b = bench({ workspaces: workspaceState(), sessions: sessionState(), configureWorkspaces: (workspaces) => { - workspaces.initializeDefault.mockImplementationOnce(async () => { - const item = workspace('default') - workspaces.list.set(workspaceState([item])) - return item - }) - }, configureSessions: (sessions) => { sessions.create.mockReturnValueOnce(created.promise) } }) - await vi.waitFor(() => { expect(b.sessions.create).toHaveBeenCalledOnce() }) - if (superseded) b.layout.selectPanel('other-panel' as MainPanelId) - created.reject(new Error('session failed')) - await setImmediate() - expect(b.workspaces.list.getSnapshot().items).toEqual([workspace('default')]) - expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(!superseded) - expect(b.sessions.retain).not.toHaveBeenCalled() - } + it.each(['session', 'panel', 'disposal'] as const)('suppresses a startup directory failure after %s navigation', async (kind) => { + const pending = Promise.withResolvers() + const b = bench({ workspaces: workspaceState(), sessions: sessionState(), configureWorkspaces: (workspaces) => { + workspaces.initializeDefault.mockReturnValueOnce(pending.promise) + } }) + if (kind === 'session') b.uiWorkspace.openSession(sid('manual')) + else if (kind === 'panel') b.layout.selectPanel('other-panel' as MainPanelId) + else await b.ctx.fiber.dispose() + pending.reject(new Error('late failure')) + await setImmediate() + expect(b.sessions.create).not.toHaveBeenCalled() + expect(b.notifyDefaultFailure).not.toHaveBeenCalled() + }) + + it.each([false, true])('reports Session failure without a directory error and retains the Workspace (superseded: %s)', async (superseded) => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const created = Promise.withResolvers() + const b = bench({ workspaces: workspaceState(), sessions: sessionState(), configureWorkspaces: (workspaces) => { + workspaces.initializeDefault.mockImplementationOnce(async () => { + const item = workspace('default') + workspaces.list.set(workspaceState([item])) + return item + }) + }, configureSessions: (sessions) => { sessions.create.mockReturnValueOnce(created.promise) } }) + await vi.waitFor(() => { expect(b.sessions.create).toHaveBeenCalledOnce() }) + if (superseded) b.layout.selectPanel('other-panel' as MainPanelId) + const failure = new Error('session failed') + created.reject(failure) + await vi.waitFor(() => { + expect(warning).toHaveBeenCalledExactlyOnceWith('initial Session restoration failed:', failure) + }) + expect(b.workspaces.list.getSnapshot().items).toEqual([workspace('default')]) + expect(b.notifyDefaultFailure).not.toHaveBeenCalled() + expect(b.sessions.retain).not.toHaveBeenCalled() }) it('retains an explicit main target before revealing its Conversation', () => { From 31e6b3f15061b64cc2827492055b2ce8914945bb Mon Sep 17 00:00:00 2001 From: Turtle Date: Sun, 20 Sep 2026 20:45:23 +0800 Subject: [PATCH 133/196] fix(client): rotate every ongoing StateDot loader in phase (#4737) CSS animations start when their element is inserted, so loaders mounted at different moments rotated out of step in the sidebar session list. The ongoing loader now pins its spin and dash animations to document time zero on mount so every visible loader shares one phase. --- .../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/StateDot.module.css | 3 ++- .../client/ui-primitives/src/StateDot.tsx | 15 ++++++++++++++ .../tests/state-dot.client.spec.tsx | 20 ++++++++++++++++++- 6 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 52a8d99d49..dde0b3eec5 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: 09950210eaef57a290362037f3d603366fe00168 -README.zh.md: aa95cbf1ce7fa79c3d1d5466ca8ed4d6942ad03e +README.md: 12fe2648e383189198ff972709819e6f1eb4c02f +README.zh.md: 3c6f62fee22c5ad7e750c860724f60ca6dd9fbcf diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 09950210ea..12fe2648e3 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -44,7 +44,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | -| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader. `aria-hidden`, so the render site owns the name. | +| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. | | `Modal` | Centered dialog over a page mask. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index aa95cbf1ce..3c6f62fee2 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -44,7 +44,7 @@ kind: "package-library" | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | -| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading。它是 `aria-hidden` 的,名称由渲染点提供。 | +| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。 | | `Modal` | 页面遮罩之上的居中对话框。 | diff --git a/packages/client/ui-primitives/src/StateDot.module.css b/packages/client/ui-primitives/src/StateDot.module.css index 6a77a4e127..186e54eb57 100644 --- a/packages/client/ui-primitives/src/StateDot.module.css +++ b/packages/client/ui-primitives/src/StateDot.module.css @@ -32,7 +32,8 @@ } /* Ongoing is the sole non-dot state: a quiet full ring sits behind a centered - * breathing arc while the complete glyph rotates continuously. */ + * breathing arc while the complete glyph rotates continuously. Both animations + * share one 1.5s period so pinning their start times aligns them together. */ .spinner { flex: none; color: var(--dsw-alias-label-tertiary); diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx index 05d869acfd..b0d84b89ec 100644 --- a/packages/client/ui-primitives/src/StateDot.tsx +++ b/packages/client/ui-primitives/src/StateDot.tsx @@ -7,6 +7,20 @@ import css from './StateDot.module.css' */ export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' | 'idle' +/** + * Pin the loader's CSS animations to document time zero. A CSS animation starts + * when its element is inserted, so loaders mounted at different moments rotate + * out of phase; one shared start time keeps every visible loader in step. + * @param element - the mounted loader, or null on unmount. + */ +function syncSpinner(element: SVGSVGElement | null): void { + if (element === null) return + // jsdom (the unit lane) implements no Web Animations despite lib.dom's + // non-optional typing; the optional call leaves that lane unsynced. + const spinner = element as { getAnimations?: SVGSVGElement['getAnimations'] } + for (const animation of spinner.getAnimations?.({ subtree: true }) ?? []) animation.startTime = 0 +} + /** * Render a state dot. * @param props.state - which of `done`, `warning`, `ongoing`, `error`, or `idle` to show. @@ -23,6 +37,7 @@ export function StateDot({ state, size, className }: { if (state === 'ongoing') { return ( { expect(bad('paused')).toBe('paused') }) }) + +describe('StateDot ongoing phase', () => { + it('pins every loader animation to document time zero on mount', () => { + const animations = [{ startTime: 42 }, { startTime: 7 }] + const getAnimations = vi.fn(() => animations) + const proto = SVGElement.prototype as { getAnimations?: () => { startTime: number }[] } + proto.getAnimations = getAnimations + try { + const { unmount } = render() + expect(getAnimations).toHaveBeenCalledWith({ subtree: true }) + expect(animations.map(animation => animation.startTime)).toEqual([0, 0]) + unmount() + expect(getAnimations).toHaveBeenCalledTimes(1) + } finally { + delete proto.getAnimations + } + }) +}) From 942e496ce7e4e72f5fe094c1428a46d07a4fafa7 Mon Sep 17 00:00:00 2001 From: liyao Date: Sun, 20 Sep 2026 20:45:29 +0800 Subject: [PATCH 134/196] fix(desktop): declare macOS bundle localizations --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/scripts/electron-builder-config.mjs | 2 ++ 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 79e9bfbae3..067c49e554 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: 94d09c628d6bb40933f95cd3874d412ab18521df -README.zh.md: bfb156bfaa0400799498eabb6034f32c2d50e7f3 +README.md: 8f8c4f87c2b254d123336a0ba723e66bf01fe11d +README.zh.md: 6537a2b2897d223fcd3a54725312c0bf78c4d412 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 94d09c628d..8f8c4f87c2 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -52,7 +52,7 @@ The `dsh-app://shell/` origin serves packaged update documents, scripts, and sty The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles. -Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources. +Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources. Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index bfb156bfaa..6537a2b289 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -52,7 +52,7 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。 -Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 +Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离预加载和壳 frame 之间的私有 MessageChannel 传递。 diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index cf2f76768f..dd4b00d24c 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -126,6 +126,8 @@ export function createElectronBuilderConfig( mac: { icon: fileURLToPath(new URL('../resources/icon-macos.png', import.meta.url)), category: 'public.app-category.developer-tools', + // macOS matches the application locale against this bundle, not Electron Framework resources. + extendInfo: { CFBundleLocalizations: ['en', 'zh_CN'] }, identity: macOSSigning?.signingIdentity, forceCodeSigning: true, hardenedRuntime: true, From 52a4a5bdb3562c7c272f61cfce9f5c7a4aa15862 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 21:12:06 +0800 Subject: [PATCH 135/196] fix(composer): preserve batches and quoted directory references --- .../ui-attachment/src/client/drop-events.ts | 15 +++-- .../composer-attachments.client.spec.tsx | 4 +- .../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 | 53 +++++++++-------- .../src/client/input/editor/runtime.ts | 19 ++++++ .../src/client/input/editor/view-binding.ts | 2 +- .../src/client/input/facade.ts | 14 +++++ .../ui-conversation/src/client/locales.ts | 2 + .../tests/apply-inject.client.spec.tsx | 58 ++++++++++++++++++- .../tests/input-matrix.client.spec.tsx | 5 ++ .../file-reference-local/README.i18n.yaml | 4 +- .../context/file-reference-local/README.md | 2 +- .../context/file-reference-local/README.zh.md | 2 +- .../context/file-reference/README.i18n.yaml | 4 +- packages/context/file-reference/README.md | 2 +- packages/context/file-reference/README.zh.md | 2 +- packages/context/file-reference/src/index.ts | 2 +- .../system-prompt.expected.md | 2 +- .../system-prompt.expected.md | 2 +- .../web/ptc-round/system-prompt.expected.md | 2 +- .../system-prompt.expected.md | 2 +- 23 files changed, 152 insertions(+), 54 deletions(-) diff --git a/packages/client/ui-attachment/src/client/drop-events.ts b/packages/client/ui-attachment/src/client/drop-events.ts index 79a3c40439..2f3da69372 100644 --- a/packages/client/ui-attachment/src/client/drop-events.ts +++ b/packages/client/ui-attachment/src/client/drop-events.ts @@ -6,13 +6,15 @@ import type { ComposerAttachmentsProps } from '@deepseek-ai/dsh-client-ui-conver * is indistinguishable from an empty file, so the entry API is the only * source of that fact; browsers without it report no directories. */ -function droppedDirectories(dataTransfer: DataTransfer): ReadonlySet { +function droppedDirectories(dataTransfer: DataTransfer, files: readonly File[]): ReadonlySet { const directories = new Set() + let fileIndex = 0 for (const item of dataTransfer.items) { - if (item.kind !== 'file' || typeof item.webkitGetAsEntry !== 'function') continue + if (item.kind !== 'file') continue + const file = files[fileIndex++] + if (typeof item.webkitGetAsEntry !== 'function') continue if (item.webkitGetAsEntry()?.isDirectory !== true) continue - const file = item.getAsFile() - if (file !== null) directories.add(file) + if (file !== undefined) directories.add(file) } return directories } @@ -65,7 +67,10 @@ export function installDocumentDropEvents( if (dataTransfer === null) return event.preventDefault() reset() - if (canAcceptDrop) onAddFiles([...dataTransfer.files], droppedDirectories(dataTransfer)) + if (canAcceptDrop) { + const files = [...dataTransfer.files] + onAddFiles(files, droppedDirectories(dataTransfer, files)) + } } document.addEventListener('dragenter', onDragEnter) document.addEventListener('dragover', onDragOver) diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx index e1d5a6cf66..057e29dc9c 100644 --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx @@ -120,7 +120,7 @@ describe('ComposerAttachments', () => { const folder = new File([], 'project') const note = new File([Uint8Array.of(1)], 'notes.md', { type: 'text/markdown' }) const entry = (isDirectory: boolean | null, file: File) => ({ - kind: 'file', getAsFile: () => file, webkitGetAsEntry: () => (isDirectory === null ? null : { isDirectory }), + kind: 'file', getAsFile: () => new File([], file.name), webkitGetAsEntry: () => (isDirectory === null ? null : { isDirectory }), }) fireEvent.drop(document.body, { dataTransfer: { @@ -136,7 +136,7 @@ describe('ComposerAttachments', () => { dataTransfer: { types: ['Files'], files: [note], - items: [entry(null, note), { kind: 'file', getAsFile: () => note }], + items: [entry(null, note), { kind: 'file', getAsFile: () => note }, entry(true, folder)], dropEffect: 'none', }, }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index e1005d8ba2..9d85f538e3 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: 87f76ef1f1aeeed0bf502044a84fb93c6118c47d -README.zh.md: ec61f9acabfa52e81d4160db511ed25c3070cd38 +README.md: 2154a7231e25123070b41f240f46cb71c754e3d2 +README.zh.md: 60943a47c63f92d8d1c8de70ce262da689cfccd4 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 87f76ef1f1..2154a7231e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -62,7 +62,7 @@ Active transcripts in the main occurrence expose content-width drag handles in t 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. -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. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), a dropped, picked, or pasted folder or non-image file with a real path is inserted as the same `@path` reference chip the user could have typed, so nothing uploads and the model reads it with its file tools; images keep uploading, and a browser without the bridge refuses a dropped folder with product copy. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. +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. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Only drag-and-drop identifies directories; the file picker cannot select them, and clipboard files carry no directory classification. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index ec61f9acab..60943a47c6 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -62,7 +62,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 常驻 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,因此光标回到草稿原来的位置而不是开头。 -默认发送采用乐观提交: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 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入、选择或粘贴的文件夹和带真实路径的非图片文件会插成和用户手动输入相同的 `@路径` 引用 chip,因此不上传,模型用文件工具读取;图片仍然上传,没有该桥的浏览器会用产品文案拒绝拖入的文件夹。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 +默认发送采用乐观提交: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 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。只有拖放会识别目录,文件选择器不能选择目录,剪贴板文件也不携带目录分类。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index ea52eb6ec9..40daee8fa9 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -18,9 +18,10 @@ import type { ConversationSessionInjected, DraftFileUploads, } from './contract/slots.ts' import type { InputNotice } from './contract/input.ts' +import type { ReferenceInsert } from './contract/draft-editor.ts' import { createConversationStore, readConversationViewPreference } from './stores.ts' import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' -import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' +import { relativizeToCwd, workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import { ConversationController, UnsupportedImageMediaTypeError, isImageMediaType } from './service.ts' import type { IConversation } from './service.ts' import { ComposerBlockRegistry } from './input/blocks.ts' @@ -410,42 +411,40 @@ export function apply(ctx: Context, config: Config = Config({})): void { const shell = inputHub.shell(sessionId) const inputTriggers = inputHub.inputTriggers(sessionId) const bridge = hostPathBridge() - // A folder or non-image file the shell can name is cited as the same - // `@path` reference the user could have typed: the model reads it with - // its file tools, nothing uploads, and the draft keeps the chip. A - // directory the shell cannot name has nothing to upload or cite. - const cite = (file: File, directory: boolean): boolean | string => { - if (bridge === undefined) return directory ? t('attachment.directoryDesktopOnly') : false - const path = bridge.pathFor(file) - if (path === '') return directory ? t('attachment.directoryDesktopOnly') : false - if (!directory && isImageMediaType(file.type)) return false - const mention = formatFileMention({ path, kind: directory ? 'directory' : 'file' }, false) - if (mention === undefined) return t('attachment.pathUnsupported') - const label = workspaceTitleOf(path) || file.name - const selection = shell.caretSpan() - shell.insertReference({ - source: 'reference', - ref: mention, - label: directory ? `${label}/` : label, - appearance: directory ? 'folder' : 'file', - clipboardText: mention, - }, { ...selection, draftRev: shell.snapshot.draftRev }) - return true - } return { keyboard: shell, addFiles: (files, directories = new Set()) => { if (sessions.binding(sessionId) === undefined) return t('file.sessionUnavailable') + if (shell.snapshot.phase === 'adjudicating' || shell.snapshot.phase === 'submitting') { + return t('attachment.dropBlocked') + } const uploads: File[] = [] + const references: ReferenceInsert[] = [] + const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd for (const file of files) { - const cited = cite(file, directories.has(file)) - if (typeof cited === 'string') return cited - if (!cited) uploads.push(file) + const directory = directories.has(file) + if (bridge === undefined && directory) return t('attachment.directoryDesktopOnly') + const path = bridge?.pathFor(file) ?? '' + if (directory && path === '') return t('attachment.pathUnavailable') + if (path === '' || (!directory && isImageMediaType(file.type))) { + uploads.push(file) + continue + } + const relative = relativizeToCwd(path, cwd) + // A completed directory chip needs closed quotes; the directory grammar keeps them open for drill. + const mention = formatFileMention({ path: directory ? `${relative}/` : relative, kind: 'file' }, false) + if (mention === undefined) return t('attachment.pathUnsupported') + const label = workspaceTitleOf(path) || file.name + references.push({ + source: 'reference', ref: mention, label: directory ? `${label}/` : label, + appearance: directory ? 'folder' : 'file', clipboardText: mention, + }) } try { const drafts = conversation.createDrafts(sessionId, uploads) - if (!shell.addAttachments(drafts.map(draft => draft.id))) { + if (!shell.addFiles(references, drafts.map(draft => draft.id))) { conversation.releaseDraftAttachments(drafts) + return t('attachment.dropBlocked') } return null } catch (error: unknown) { diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts index 2054624342..a5bdbe33e1 100644 --- a/packages/client/ui-conversation/src/client/input/editor/runtime.ts +++ b/packages/client/ui-conversation/src/client/input/editor/runtime.ts @@ -228,6 +228,25 @@ export class DraftEditorRuntime { return applied } + /** + * Insert an ordered file-reference batch after the live selection without deleting it. + * @param references - validated references in source order. + * @returns whether the live insertion position accepted the batch. + */ + insertFileReferences(references: readonly ReferenceInsert[]): boolean { + if (references.length === 0) return true + let applied = false + this.applyEdit(() => { + const projection = $projectComposer(key => this.occurrenceIdOf(key)) + const at = projection.selection?.end ?? projection.detectText.length + const before = projection.detectText.slice(0, at) + const nodes = references.flatMap(ref => [$createReferenceChipNode(ref), $createTextNode(' ')]) + if (before !== '' && !/\s$/u.test(before)) nodes.unshift($createTextNode(' ')) + applied = $replaceDetectSpanWithNodes({ start: at, end: at }, nodes) + }, PASTE_TAG) + return applied + } + /** Refresh claim-token decoration after the model's claim changes. */ refreshClaimDecoration(): void { refreshClaimDecoration(this.editor) diff --git a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts index 098bd88f47..8dcd3d8c63 100644 --- a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts +++ b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts @@ -14,7 +14,7 @@ interface DraftViewGate { running: boolean steeringAvailable: boolean busyEnter: BusyEnterBehavior - intakeFiles: (files: readonly File[], directories?: ReadonlySet) => void + intakeFiles: (files: readonly File[]) => void uploadsPending: boolean showToast: (text: string) => void t: ComposerBarProps['t'] diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 4f4d0aa300..1ec22a50bc 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -215,6 +215,20 @@ export class SessionInputShell implements SessionInput { return true } + /** + * Add validated file references and attachment ids while admission is editable. + * @param references - reference chips in source order. + * @param ids - newly allocated attachment ids. + * @returns false when admission is locked or the editor refuses the insertion. + */ + addFiles(references: readonly ReferenceInsert[], ids: readonly DraftAttachmentId[]): boolean { + if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false + if (!this.draftEditor.insertFileReferences(references)) return false + this.attachmentIds = [...this.attachmentIds, ...ids] + this.publish() + return true + } + /** * Remove one attachment id from this draft. Busy admission phases refuse, like * {@link addAttachments}: a removal landing while a command submit serializes diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index d23e903717..cb317d87eb 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -32,6 +32,7 @@ export const zh = { 'attachment.dropDesc': '图片限制:最多 {count} 张,每张 {size}', 'attachment.dropBlocked': '当前无法添加文件或图片', 'attachment.directoryDesktopOnly': '只有桌面端支持添加文件夹,浏览器里请添加单个文件', + 'attachment.pathUnavailable': '无法获取文件夹路径,请重新拖入', 'attachment.pathUnsupported': '路径含有无法引用的字符,请改名后再试', 'image.pending': '待发送图片', 'image.openOriginal': '查看原图', @@ -385,6 +386,7 @@ export const en = { 'attachment.dropDesc': 'Image limit: up to {count} images, {size} each', 'attachment.dropBlocked': 'Files and images cannot be added right now', 'attachment.directoryDesktopOnly': 'Folders can only be added in the desktop app; add individual files in the browser', + 'attachment.pathUnavailable': 'Could not obtain the folder path; drag it in again', 'attachment.pathUnsupported': 'The path contains characters a reference cannot carry; rename it and try again', 'image.pending': 'Pending images', 'image.openOriginal': 'View original', 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 f7e5e0b899..26114f66ac 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -1,4 +1,8 @@ // @vitest-environment jsdom +import { render, cleanup } from '@testing-library/react' +import { $getRoot, $isTextNode, PASTE_COMMAND } from 'lexical' +import { projectUserText } from '@deepseek-ai/dsh-client-ui-primitives' +import { registerComposerKeymap } from '../src/client/input/editor/keymap.ts' 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' @@ -418,20 +422,70 @@ describe('Conversation inject API', () => { const pasted = new File([Uint8Array.of(3)], 'pasted.bin', { type: 'application/octet-stream' }) expect(composer.addFiles?.([folder, note, shot, pasted], new Set([folder]))).toBeNull() // The folder and the file became references in the draft; the image and the pathless bytes stayed drafts. - expect(state.getSnapshot().draft).toBe('@"/Users/me/my project/ @/Users/me/notes.md ') + expect(state.getSnapshot().draft).toBe('@"/Users/me/my project/" @/Users/me/notes.md ') const drafts = composer.resolveDraftAttachments?.(state.getSnapshot().attachmentIds) ?? [] expect(drafts.map(draft => draft.kind)).toEqual(['image', 'file']) await vi.waitFor(() => { expect(desktop.rootUpload).toHaveBeenCalledOnce() }) // A directory the shell cannot name is refused even with the bridge present. const nameless = new File([], 'nameless') expect(composer.addFiles?.([nameless], new Set([nameless]))) - .toBe('只有桌面端支持添加文件夹,浏览器里请添加单个文件') + .toBe('无法获取文件夹路径,请重新拖入') await desktop.runtime.dispose() } finally { vi.unstubAllGlobals() } }) + it('preserves selected text and source order when files arrive inside a paste transaction', async () => { + vi.stubGlobal('__DSH_HOST_PATHS__', { pathFor: (file: File) => `/proj/${file.name}` }) + onTestFinished(() => { vi.unstubAllGlobals(); cleanup() }) + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const composer = b.composerApi(ROOT) + const editor = composer.keyboard!.editor + const { state, actions } = b.inputApi(ROOT) + actions.setDraft('读取') + editor.update(() => { + const text = $getRoot().getAllTextNodes()[0] + if (text === undefined || !$isTextNode(text)) throw new Error('expected text selection') + text.select(0, 2) + }, { discrete: true }) + const off = registerComposerKeymap(editor, { + arbitrate: () => 'pass', space: () => false, dismissPopup: () => {}, + canSubmit: () => true, submit: () => {}, pasteText: text => composer.keyboard!.paste(text), + intakeFiles: (files) => { expect(composer.addFiles?.(files)).toBeNull() }, + }) + onTestFinished(off) + const files = [new File([], 'a.txt'), new File([], 'b.txt')] + const event = new Event('paste', { cancelable: true }) + Object.defineProperty(event, 'clipboardData', { value: { + items: files.map(file => ({ kind: 'file', getAsFile: () => file })), getData: () => '', + } }) + editor.update(() => { editor.dispatchCommand(PASTE_COMMAND, event) }, { discrete: true }) + expect(state.getSnapshot().draft).toBe('读取 @a.txt @b.txt ') + const folder = new File([], 'my project') + expect(composer.addFiles?.([folder], new Set([folder]))).toBeNull() + const view = render(
    {projectUserText(state.getSnapshot().draft, [])}
    ) + expect(view.container.querySelector('[data-ref-chip="folder"]')?.textContent).toBe('my project') + expect(view.container.querySelector('[data-ref-chip="folder"]')?.getAttribute('title')).toBe('@"my project/"') + }) + + it('rejects the whole file batch before changing the draft when a later path is unrepresentable', async () => { + vi.stubGlobal('__DSH_HOST_PATHS__', { pathFor: (file: File) => `/proj/${file.name}` }) + onTestFinished(() => { vi.unstubAllGlobals() }) + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const { state, actions } = b.inputApi(ROOT) + actions.setDraft('keep this text') + for (const name of ['bad"name', 'bad\nname']) { + const files = [new File([], 'good.txt'), new File([], name)] + expect(b.composerApi(ROOT).addFiles?.(files)).toBe('路径含有无法引用的字符,请改名后再试') + expect(state.getSnapshot().draft).toBe('keep this text') + expect(state.getSnapshot().attachmentIds).toEqual([]) + expect(b.rootUpload).not.toHaveBeenCalled() + } + }) + it('fails loud for an unknown binding or an unloaded scoped service', async () => { const b = await bench() const entry = b.entryOf('conversation.composer.bar') 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..fe62abfb0e 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -283,6 +283,11 @@ describe('matrix row: claimed with attachments', () => { act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(shell.snapshot.phase).toBe('submitting') + const draft = shell.snapshot.draft + expect(shell.addFiles([{ + source: 'reference', ref: '@note.txt', label: 'note.txt', clipboardText: '@note.txt', + }], ['new-file' as DraftAttachmentId])).toBe(false) + expect(shell.snapshot.draft).toBe(draft) let removed = true act(() => { removed = shell.removeAttachment(img) }) expect(removed).toBe(false) diff --git a/packages/context/file-reference-local/README.i18n.yaml b/packages/context/file-reference-local/README.i18n.yaml index 1395353c93..8bd8e62df0 100644 --- a/packages/context/file-reference-local/README.i18n.yaml +++ b/packages/context/file-reference-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/context/file-reference-local/README.md -README.md: efeebdbd3e113597cc93dfe4d6c379e16b324d93 -README.zh.md: f286f92d6e6ec76afc9919db9f81c73e4ace8d04 +README.md: b80f5863e2cae166277d7672085bd1a9742e6cf3 +README.zh.md: 343d0950986df8e071c307314ce2e860168c4308 diff --git a/packages/context/file-reference-local/README.md b/packages/context/file-reference-local/README.md index efeebdbd3e..b80f5863e2 100644 --- a/packages/context/file-reference-local/README.md +++ b/packages/context/file-reference-local/README.md @@ -105,7 +105,7 @@ When the addressed agent has an effective `read` tool, the provider contributes ##### File-reference instruction ```markdown -Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. +Tokens prefixed with @ are paths the user explicitly referenced. Relative paths resolve from the workspace root; absolute paths identify files or directories on the host. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. ``` #### Token effect diff --git a/packages/context/file-reference-local/README.zh.md b/packages/context/file-reference-local/README.zh.md index f286f92d6e..343d095098 100644 --- a/packages/context/file-reference-local/README.zh.md +++ b/packages/context/file-reference-local/README.zh.md @@ -105,7 +105,7 @@ agent(智能体)及宿主 UI 可以用各 agent 本地工作区中经过排 ##### 文件引用指令 ```markdown -Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. +Tokens prefixed with @ are paths the user explicitly referenced. Relative paths resolve from the workspace root; absolute paths identify files or directories on the host. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. ``` #### Token 影响 diff --git a/packages/context/file-reference/README.i18n.yaml b/packages/context/file-reference/README.i18n.yaml index 404975fb63..6d1e7a991a 100644 --- a/packages/context/file-reference/README.i18n.yaml +++ b/packages/context/file-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 packages/context/file-reference/README.md -README.md: ce84a91e96988fb662ee665707d1ce2ad1651327 -README.zh.md: ffdce668dd1b4a5a39a076d6abc998ec6db2ce92 +README.md: 3b759a88bd9ebb59fbf3d039756e66ad6901a893 +README.zh.md: d7dedc46365d70740480167cdfa0432f494d4563 diff --git a/packages/context/file-reference/README.md b/packages/context/file-reference/README.md index ce84a91e96..3b759a88bd 100644 --- a/packages/context/file-reference/README.md +++ b/packages/context/file-reference/README.md @@ -29,7 +29,7 @@ Choose this package when a host-backed UI (web or terminal) should offer `@file` ### Mention grammar -An `@path` token at the start of input or after whitespace triggers completion; an `@` inside another token, such as an email address, does not. `@"path with spaces"` opens a quoted mention, and a directory candidate keeps that quote open after its trailing slash so completion can descend another level. The formatter rejects paths with control characters or embedded quotes that the grammar cannot represent safely. +An `@path` token at the start of input or after whitespace triggers completion; an `@` inside another token, such as an email address, does not. `@"path with spaces"` opens a quoted mention, and a directory candidate keeps that quote open after its trailing slash so completion can descend another level. Completed directory chips close the quote after the trailing slash. Relative mentions resolve from the workspace root; absolute mentions retain the host path. The formatter rejects paths with control characters or embedded quotes that the grammar cannot represent safely. ### Getting candidates diff --git a/packages/context/file-reference/README.zh.md b/packages/context/file-reference/README.zh.md index ffdce668dd..d7dedc4636 100644 --- a/packages/context/file-reference/README.zh.md +++ b/packages/context/file-reference/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### mention 语法 -输入开头或空白后的 `@path` token 会触发补全;其他 token 内部的 `@`(如电子邮件地址)不会。`@"path with spaces"` 打开带引号的 mention,目录候选在其尾斜杠后保持引号打开,使补全可以继续深入下一层。格式化器会拒绝含有语法无法安全表示的控制字符或内嵌引号的路径。 +输入开头或空白后的 `@path` token 会触发补全;其他 token 内部的 `@`(如电子邮件地址)不会。`@"path with spaces"` 打开带引号的 mention,目录候选在其尾斜杠后保持引号打开,使补全可以继续深入下一层。格式化器会拒绝含有语法无法安全表示的控制字符或内嵌引号的路径。 已完成的目录 chip 在末尾斜杠之后闭合引号。相对引用从工作区根目录解析,绝对引用保留主机路径。 ### 获取候选 diff --git a/packages/context/file-reference/src/index.ts b/packages/context/file-reference/src/index.ts index bf1025714f..d6b30e783e 100644 --- a/packages/context/file-reference/src/index.ts +++ b/packages/context/file-reference/src/index.ts @@ -14,7 +14,7 @@ export type { ActiveAtToken } from './grammar.ts' export type { FileReferenceCandidate } from './types.ts' /** Model guidance for path-only references selected by a user interface. */ -export const FILE_REFERENCE_PROMPT = 'Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces.' +export const FILE_REFERENCE_PROMPT = 'Tokens prefixed with @ are paths the user explicitly referenced. Relative paths resolve from the workspace root; absolute paths identify files or directories on the host. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces.' declare module '@deepseek-ai/cordis' { interface Context { diff --git a/snapshots/web/cordis-tool-round/system-prompt.expected.md b/snapshots/web/cordis-tool-round/system-prompt.expected.md index a6bd84f32d..187a1abeef 100644 --- a/snapshots/web/cordis-tool-round/system-prompt.expected.md +++ b/snapshots/web/cordis-tool-round/system-prompt.expected.md @@ -2,7 +2,7 @@ You are an AI agent powered by DeepSeek Harness. You are a coding agent powered by the deepseek-v4-flash model. -Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. +Tokens prefixed with @ are paths the user explicitly referenced. Relative paths resolve from the workspace root; absolute paths identify files or directories on the host. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. Check the [exit code: N] marker on every bash result; investigate failures before moving on. diff --git a/snapshots/web/fresh-round-trip/system-prompt.expected.md b/snapshots/web/fresh-round-trip/system-prompt.expected.md index cc5a4013c4..e05a41da44 100644 --- a/snapshots/web/fresh-round-trip/system-prompt.expected.md +++ b/snapshots/web/fresh-round-trip/system-prompt.expected.md @@ -2,7 +2,7 @@ You are an AI agent powered by DeepSeek Harness. You are a coding agent powered by the deepseek-v4-flash model. -Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. +Tokens prefixed with @ are paths the user explicitly referenced. Relative paths resolve from the workspace root; absolute paths identify files or directories on the host. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. Check the [exit code: N] marker on every bash result; investigate failures before moving on. diff --git a/snapshots/web/ptc-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md index 8787372c8f..107c581adc 100644 --- a/snapshots/web/ptc-round/system-prompt.expected.md +++ b/snapshots/web/ptc-round/system-prompt.expected.md @@ -4,7 +4,7 @@ You are a coding agent powered by the deepseek-v4-flash model. `run_code` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program. -Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. +Tokens prefixed with @ are paths the user explicitly referenced. Relative paths resolve from the workspace root; absolute paths identify files or directories on the host. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. Check the [exit code: N] marker on every bash result; investigate failures before moving on. diff --git a/snapshots/web/schedule-catalog/system-prompt.expected.md b/snapshots/web/schedule-catalog/system-prompt.expected.md index 0c5f3c77cc..01b43eca97 100644 --- a/snapshots/web/schedule-catalog/system-prompt.expected.md +++ b/snapshots/web/schedule-catalog/system-prompt.expected.md @@ -6,7 +6,7 @@ You are interacting with the user through the DeepSeek Harness Web GUI at {{webU You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. -Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. +Tokens prefixed with @ are paths the user explicitly referenced. Relative paths resolve from the workspace root; absolute paths identify files or directories on the host. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. Check the [exit code: N] marker on every bash result; investigate failures before moving on. From 444ce0444bfece66d7f614cc34b7027d20d4893c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 21:14:44 +0800 Subject: [PATCH 136/196] fix(composer): type paste regression and refresh slot catalog --- .../tests/apply-inject.client.spec.tsx | 2 +- .../src/client/slot-catalog.ts | 50 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) 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 26114f66ac..1e45de8882 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -457,7 +457,7 @@ describe('Conversation inject API', () => { }) onTestFinished(off) const files = [new File([], 'a.txt'), new File([], 'b.txt')] - const event = new Event('paste', { cancelable: true }) + const event = new KeyboardEvent('paste', { cancelable: true }) Object.defineProperty(event, 'clipboardData', { value: { items: files.map(file => ({ kind: 'file', getAsFile: () => file })), getData: () => '', } }) 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 b540e06b33..96a1e2c0fc 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -406,7 +406,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187', }, { key: 'conversation.composer.bar', @@ -445,7 +445,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.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:201', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:205', }, { key: 'conversation.composer.dock', @@ -501,7 +501,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', 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:195', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:199', }, { key: 'conversation.header', @@ -536,7 +536,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.header\', () => ctx.slots.register(\n { name: \'conversation.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:135', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139', }, { key: 'conversation.header.leading', @@ -565,7 +565,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header.leading\', () => ctx.slots.register(\n { name: \'conversation.header.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:164', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:168', }, { key: 'conversation.hero.agentPreset', @@ -602,7 +602,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.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:189', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:193', }, { key: 'conversation.hero.brand.mark', @@ -631,7 +631,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:191', }, { key: 'conversation.hero.workspace', @@ -664,7 +664,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.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:189', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -706,7 +706,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Optional draft-attachment rail and drop target.', registerOptions: [], ownerProps: [ - '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft attachments in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add attachments now. */\n canAcceptDrop: boolean\n /** Add one dropped batch through the composer\'s validation path. */\n onAddFiles: (files: readonly File[]) => void\n /** Remove one draft attachment through the Conversation service. */\n onRemoveAttachment: (id: DraftAttachmentId) => void\n /** Current per-draft upload states for file-kind attachments. */\n uploads: DraftFileUploads\n /** Restart one failed file upload. */\n onRetryFile: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', + '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft attachments in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add attachments now. */\n canAcceptDrop: boolean\n /**\n * Add one dropped batch through the composer\'s validation path.\n * @param files - dropped, pasted, or picked browser files in source order.\n * @param directories - members of `files` the drop source identified as directories.\n */\n onAddFiles: (files: readonly File[], directories?: ReadonlySet) => void\n /** Remove one draft attachment through the Conversation service. */\n onRemoveAttachment: (id: DraftAttachmentId) => void\n /** Current per-draft upload states for file-kind attachments. */\n uploads: DraftFileUploads\n /** Restart one failed file upload. */\n onRetryFile: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', ], ownerPropsReferences: [ 'ComposerAttachment', @@ -737,7 +737,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.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:203', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:207', }, { key: 'conversation.input.dock', @@ -800,7 +800,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', 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:191', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:195', }, { key: 'conversation.input.left', @@ -854,7 +854,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', 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:197', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:201', }, { key: 'conversation.input.model', @@ -893,7 +893,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.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:213', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:217', }, { key: 'conversation.input.overlay', @@ -951,7 +951,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', 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:193', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:197', }, { key: 'conversation.input.permission', @@ -990,7 +990,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.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:211', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:215', }, { key: 'conversation.input.plan', @@ -1029,7 +1029,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.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:209', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:213', }, { key: 'conversation.input.right', @@ -1083,7 +1083,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', 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:199', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:203', }, { key: 'conversation.message.images', @@ -1221,7 +1221,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.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:129', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133', }, { key: 'conversation.session.header', @@ -1258,7 +1258,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.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:141', }, { key: 'conversation.session.header.actions', @@ -1319,7 +1319,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], 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}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:152', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:156', }, { key: 'conversation.session.header.corner', @@ -1358,7 +1358,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.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:179', }, { key: 'conversation.session.header.lineage', @@ -1399,7 +1399,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.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:146', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:150', }, { key: 'conversation.session.header.utilities', @@ -1458,7 +1458,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', 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:158', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:162', }, { key: 'conversation.trajectory.images', @@ -1562,7 +1562,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', 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', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', }, { key: 'main', @@ -1634,7 +1634,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', }, { key: 'plugins.bundle.config', From 5953ab29131eed4425e6927b8332712db138538f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 21:24:49 +0800 Subject: [PATCH 137/196] test(composer): use explicit void paste handler --- .../client/ui-conversation/tests/apply-inject.client.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1e45de8882..e62f51b58b 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -452,7 +452,7 @@ describe('Conversation inject API', () => { }, { discrete: true }) const off = registerComposerKeymap(editor, { arbitrate: () => 'pass', space: () => false, dismissPopup: () => {}, - canSubmit: () => true, submit: () => {}, pasteText: text => composer.keyboard!.paste(text), + canSubmit: () => true, submit: () => {}, pasteText: (text) => { composer.keyboard!.paste(text) }, intakeFiles: (files) => { expect(composer.addFiles?.(files)).toBeNull() }, }) onTestFinished(off) From db10002cdd3fc806415f7c01a445190c2aff7701 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 21:34:31 +0800 Subject: [PATCH 138/196] test(web): include file items in image drop fixture --- apps/web/tests/image-display.expected.e2e.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index adbb742d0b..e3a98dc8f5 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -171,7 +171,10 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov // desc line carries the projected limits — copy that can only render after // the imageLimits projection crossed the RemoteMock transport. const image = new File([new Uint8Array([137, 80, 78, 71])], 'dropped.png', { type: 'image/png' }) - const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } + const dataTransfer = { + types: ['Files'], files: [image], dropEffect: 'none', + items: [{ kind: 'file', getAsFile: () => image, webkitGetAsEntry: () => ({ isDirectory: false }) }], + } fireEvent.dragEnter(document.body, { dataTransfer }) const overlay = await screen.findByRole('status') expect(overlay.textContent).toContain('Drag files or images here to add them') From 93ad804bf1189e552e6d089df15e3c8e809d2ffe Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 20 Sep 2026 22:36:33 +0800 Subject: [PATCH 139/196] fix(web): report a refused new session through the Workspace notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An explicit New Session (sidebar and header buttons, the workspace row button, and the hero workspace pick) whose `session.create` the Host refuses — for example a default preset whose composition names a plugin that no longer resolves (`agent-preset/invalid`) — was only logged with `console.warn`, so the click looked like it did nothing. `openWorkspace` now shows the failure through the shared Workspace notice as a `createFailed` toast quoting the Host's stable code and message on the long hold, then rethrows as before. Startup restoration does not go through `openWorkspace` and stays console-only. --- 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 | 2 + .../client/ui-workspace/src/client/index.ts | 3 +- .../client/ui-workspace/src/client/locales.ts | 2 + .../ui-workspace/src/client/navigation.ts | 43 ++++++++++++--- .../client/session-actions/RowActionToast.tsx | 24 ++++++-- .../tests/session-actions.client.spec.tsx | 18 ++++++ .../tests/workspaces-service.client.spec.ts | 55 +++++++++++++++---- .../src/client/api-catalog.ts | 3 +- 11 files changed, 129 insertions(+), 29 deletions(-) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 81d24fe35b..6c1b132770 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: 92e2f231ec2e25aa1716f03338a980f2c2269d16 -README.zh.md: e490c4ebe63a0e11e1892d8bc02693ab63d12277 +README.md: d4396fd07a05791285f44a01a6cfb17aeac1ebe9 +README.zh.md: 31dca87e21c620271f5fe7307f07f9f437247742 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 92e2f231ec..d4396fd07a 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 and are currently reported 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), while 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 e490c4ebe6..31dca87e21 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,不再尝试其他空白会话。其他获取错误会中止请求,目前只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 +新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求:显式新建会话或在 hero 中选择工作区时,失败以短暂提示展示,引用 Host 的错误码和消息(例如 preset 挂载失败);启动恢复仍只报告到控制台。已释放的空白会话被复用时保留 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 36ab1850d4..0bd651602a 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -245,6 +245,8 @@ 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. */ + | { kind: 'createFailed'; message: string } /** The notice on display; `seq` keys remounts so a repeated notice restarts its hold. */ export type RowToastState = RowToast & { seq: number } diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 7c821033d6..2339aa77f0 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -107,8 +107,7 @@ export function apply(ctx: Context): void { let toastSeq = 0 const notify = (toast: RowToast): void => { rowToast.set({ ...toast, seq: ++toastSeq }) } const uiWorkspace = new UiWorkspaceService( - ctx, ctx.remote.directoryPicker, workspaces, sessions, viewInstance.actions, - () => { notify({ kind: 'defaultWorkspaceFailed' }) }, + ctx, ctx.remote.directoryPicker, workspaces, sessions, viewInstance.actions, notify, ) ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } }) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 3ecc46741f..203811d38c 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -62,6 +62,7 @@ export const zh = { 'toast.archivedFilter': '筛选已归档会话', 'toast.pinFailed': '置顶失败,请稍后重试', 'toast.unpinFailed': '取消置顶失败,请稍后重试', + 'toast.createFailed': '新建会话失败:{message}', 'sessions.count.one': '{n} 个会话', 'sessions.count.other': '{n} 个会话', 'actions.workspace.aria': '工作区“{name}”的操作', @@ -157,6 +158,7 @@ export const en = { 'toast.archivedFilter': 'filter archived sessions', 'toast.pinFailed': 'Pin failed. Try again later.', 'toast.unpinFailed': 'Unpin failed. Try again later.', + 'toast.createFailed': 'New session failed: {message}', 'sessions.count.one': '{n} session', 'sessions.count.other': '{n} sessions', 'actions.workspace.aria': 'Workspace actions for {name}', diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 139e35076a..4a05f3fc62 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -17,6 +17,7 @@ import type { import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { RowToast } from './contract/slots.ts' import { en, zh } from './locales.ts' import { pinOrderAccounts, pinOrderSource } from './pin-order.ts' import type { WorkspaceViewStoreActions } from './stores.ts' @@ -38,6 +39,7 @@ 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. */ openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise /** @@ -53,7 +55,8 @@ export interface UiWorkspace { */ connectWorkspace(workspaceId: WorkspaceId): Promise /** - * Start a New Session flow and navigate to its Session. + * Start a New Session flow and navigate to its Session; a creation the Host + * refuses is shown through the Workspace notice and leaves the selection as it was. * @param workspaceId - explicit target; absent inherits the current or most recent Workspace. */ startSession(workspaceId?: WorkspaceId): void @@ -133,7 +136,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { * @param workspaces - pure Workspace Controller. * @param sessions - pure Session Controller. * @param view - the browser's viewing-store write set (one instance shared with its registration). - * @param notifyDefaultFailure - show the startup creation failure through the Workspace notice channel. + * @param notify - show one notice through the Workspace notice channel. */ constructor( ctx: Context, @@ -141,7 +144,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { private readonly workspaces: IWorkspaces, private readonly sessions: ISessions, private readonly view: Pick, - private readonly notifyDefaultFailure: () => void, + private readonly notify: (toast: RowToast) => void, ) { super(ctx, 'uiWorkspace') ctx.effect(() => { @@ -187,9 +190,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { try { return await this.sessions.create({ workspaceId, sessionId }) } catch (error: unknown) { - // Client plugin bundles do not share error-class identity. - if (!(error instanceof Error) || error.name !== 'SessionCreateError' - || (error as SessionCreateError).rpcError.code !== 'session/writer-held') throw error + if (sessionCreateErrorOf(error)?.rpcError.code !== 'session/writer-held') throw error return this.sessions.create({ workspaceId }) } } @@ -200,7 +201,15 @@ class UiWorkspaceService extends Service implements UiWorkspace { async openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise { const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal]) - const sessionId = await this.connectWorkspace(workspaceId) + let sessionId: SessionId + 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) }) + throw error + } if (navigation.aborted) return this.replaceMain(sessionId, navigation, 'reveal', beforeOpen) } @@ -344,7 +353,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { title, }, signal) } catch (_error: unknown) { - if (!signal.aborted) this.notifyDefaultFailure() + if (!signal.aborted) this.notify({ kind: 'defaultWorkspaceFailed' }) return undefined } } @@ -400,6 +409,24 @@ 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. + */ +function sessionCreateErrorOf(error: unknown): SessionCreateError | undefined { + return error instanceof Error && error.name === 'SessionCreateError' ? error as SessionCreateError : undefined +} + +/** + * The words a failed Session creation is reported in: a Host refusal keeps its + * stable code and message; any other failure keeps its own message. + */ +function creationFailureMessage(error: unknown): string { + const refused = sessionCreateErrorOf(error) + if (refused !== undefined) return `${refused.rpcError.code}: ${refused.rpcError.message}` + return error instanceof Error ? error.message : String(error) +} + /** Stable tie-breaking follows Host Workspace order. */ function recentWorkspace( workspaces: readonly WorkspaceView[], 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 c5a3caccc6..7833409ba8 100644 --- a/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx +++ b/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx @@ -5,12 +5,17 @@ import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' import type { RowToastProps } from '../contract/slots.ts' -/** Hold for the actionable archived notice: two buttons need a longer read-and-react window than a plain notice. */ -const ARCHIVED_TOAST_HOLD_MS = 6000 +/** + * Hold for the notices that take longer to read than a one-line warning: the + * actionable archived notice (two buttons to react to) and a refused Session + * creation, which quotes the Host's reason. + */ +const LONG_TOAST_HOLD_MS = 6000 /** * Render the current notice: the archived notice with its undo - * and show-archived actions on a 6 s hold, or a plain warning for a failed + * and show-archived actions on a 6 s hold, a refused Session creation with + * the Host's reason on the same hold, or a plain warning for a failed * pin, an archived row that was clicked, or default Workspace creation. * @param props - the notice hook, its dismissal, the two archived-notice actions, and the locale seat. * @returns the notice on display, or null. @@ -25,7 +30,7 @@ export function RowActionToast({ useToast, dismissToast, undoArchive, showArchiv key={`toast-${String(toast.seq)}`} text={t('toast.archived')} tone="success" - holdMs={ARCHIVED_TOAST_HOLD_MS} + holdMs={LONG_TOAST_HOLD_MS} actions={[ { label: t('toast.archivedUndo'), onClick: () => { dismissToast(); undoArchive(sessionId) } }, { prefix: t('toast.archivedOr'), label: t('toast.archivedFilter'), onClick: () => { dismissToast(); showArchived() } }, @@ -34,6 +39,17 @@ export function RowActionToast({ useToast, dismissToast, undoArchive, showArchiv /> ) } + if (toast.kind === 'createFailed') { + return ( + } + holdMs={LONG_TOAST_HOLD_MS} + onDone={dismissToast} + /> + ) + } const text = toast.kind === 'pinFailed' ? t('toast.pinFailed') : toast.kind === 'unpinFailed' ? t('toast.unpinFailed') diff --git a/packages/client/ui-workspace/tests/session-actions.client.spec.tsx b/packages/client/ui-workspace/tests/session-actions.client.spec.tsx index c7e3b1150d..bce8cdf2e6 100644 --- a/packages/client/ui-workspace/tests/session-actions.client.spec.tsx +++ b/packages/client/ui-workspace/tests/session-actions.client.spec.tsx @@ -404,6 +404,24 @@ describe('RowActionToast', () => { } }) + it('shows a refused creation with the Host reason and holds it as long as the archived notice', () => { + vi.useFakeTimers() + try { + const { dismissToast, notify } = toastSurface() + notify({ kind: 'createFailed', message: 'agent-preset/invalid: agent-presets: preset "broken" failed to mount' }) + const alert = screen.getByRole('alert') + expect(alert.textContent).toBe('新建会话失败:agent-preset/invalid: agent-presets: preset "broken" failed to mount') + expect(alert.querySelector('button')).toBeNull() + act(() => { vi.advanceTimersByTime(4000) }) + expect(dismissToast).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(3000) }) + expect(dismissToast).toHaveBeenCalledOnce() + expect(screen.queryByRole('alert')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + it('the archived notice holds longer than a plain notice, and a repeat restarts its hold', () => { vi.useFakeTimers() try { 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 75cfe81fcf..10fe3fdb65 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -16,6 +16,7 @@ import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import { SessionId } from '@deepseek-ai/dsh-session/types' import { LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client' import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' +import type { RowToast } from '../src/client/contract/slots.ts' import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts' import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts' import { UNGROUPED_KEY } from '../src/client/tree.ts' @@ -294,16 +295,16 @@ function bench(options: BenchOptions = {}) { options.configureWorkspaces?.(workspaces) options.configureSessions?.(sessions) const view = createWorkspaceViewStore().create() - const notifyDefaultFailure = vi.fn() + const notify = vi.fn<(toast: RowToast) => void>() const uiWorkspace = new UiWorkspaceService( ctx, directoryPicker.remote, workspaces, sessions, view.actions, - notifyDefaultFailure, + notify, ) - return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, layout, selectPanel, view, notifyDefaultFailure } + return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, layout, selectPanel, view, notify } } describe('UiWorkspaceService', () => { @@ -328,7 +329,7 @@ describe('UiWorkspaceService', () => { }) expect(b.workspaces.initializeDefault).toHaveBeenCalledExactlyOnceWith({ directoryName, title }, expect.any(AbortSignal)) expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('default') }) - expect(b.notifyDefaultFailure).not.toHaveBeenCalled() + expect(b.notify).not.toHaveBeenCalled() }) it('leaves an ineligible empty installation without a Session or failure notice', async () => { @@ -339,7 +340,7 @@ describe('UiWorkspaceService', () => { await setImmediate() expect(b.workspaces.initializeDefault).toHaveBeenCalledOnce() expect(b.sessions.create).not.toHaveBeenCalled() - expect(b.notifyDefaultFailure).not.toHaveBeenCalled() + expect(b.notify).not.toHaveBeenCalled() }) it('requires explicit selection when the startup Session list contains history', async () => { @@ -353,12 +354,12 @@ describe('UiWorkspaceService', () => { const b = bench({ workspaces: workspaceState(), sessions: sessionState(), configureWorkspaces: (workspaces) => { workspaces.initializeDefault.mockRejectedValueOnce(new Error('denied')) } }) - await vi.waitFor(() => { expect(b.notifyDefaultFailure).toHaveBeenCalledOnce() }) + await vi.waitFor(() => { expect(b.notify).toHaveBeenCalledExactlyOnceWith({ kind: 'defaultWorkspaceFailed' }) }) b.workspaces.list.set(workspaceState()) await setImmediate() expect(b.workspaces.initializeDefault).toHaveBeenCalledOnce() expect(b.sessions.create).not.toHaveBeenCalled() - expect(b.notifyDefaultFailure).toHaveBeenCalledOnce() + expect(b.notify).toHaveBeenCalledExactlyOnceWith({ kind: 'defaultWorkspaceFailed' }) }) it.each(['session', 'panel', 'disposal'] as const)('cancels startup directory preparation after %s navigation', async (kind) => { @@ -374,7 +375,7 @@ describe('UiWorkspaceService', () => { await setImmediate() expect(b.sessions.create).not.toHaveBeenCalled() expect(b.sessions.retain.mock.calls.map(args => args[0])).toEqual(kind === 'session' ? [sid('manual')] : []) - expect(b.notifyDefaultFailure).not.toHaveBeenCalled() + expect(b.notify).not.toHaveBeenCalled() }) it.each(['session', 'panel', 'disposal'] as const)('suppresses a startup directory failure after %s navigation', async (kind) => { @@ -388,7 +389,7 @@ describe('UiWorkspaceService', () => { pending.reject(new Error('late failure')) await setImmediate() expect(b.sessions.create).not.toHaveBeenCalled() - expect(b.notifyDefaultFailure).not.toHaveBeenCalled() + expect(b.notify).not.toHaveBeenCalled() }) it.each([false, true])('reports Session failure without a directory error and retains the Workspace (superseded: %s)', async (superseded) => { @@ -409,7 +410,7 @@ describe('UiWorkspaceService', () => { expect(warning).toHaveBeenCalledExactlyOnceWith('initial Session restoration failed:', failure) }) expect(b.workspaces.list.getSnapshot().items).toEqual([workspace('default')]) - expect(b.notifyDefaultFailure).not.toHaveBeenCalled() + expect(b.notify).not.toHaveBeenCalled() expect(b.sessions.retain).not.toHaveBeenCalled() }) @@ -641,6 +642,40 @@ describe('UiWorkspaceService', () => { expect(b.sessions.retain).not.toHaveBeenCalled() }) + it('reports a refused explicit Session creation through the Workspace notice', async () => { + const b = bench({ workspaces: workspaceState([workspace('a')]), sessions: sessionState() }) + // Startup restoration creates its own Session first and stays quiet on failure (pinned above). + await vi.waitFor(() => { expect(b.sessions.retain).toHaveBeenCalledOnce() }) + b.sessions.retain.mockClear() + expect(b.notify).not.toHaveBeenCalled() + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const refused = new SessionCreateError(new RemoteError( + 'agent-preset/invalid', + 'agent-presets: preset "broken" failed to mount: row "workflow-ptc" names a plugin that cannot be resolved', + { agentPreset: 'broken', reason: 'row "workflow-ptc" names a plugin that cannot be resolved' }, + ), undefined) + b.sessions.create.mockRejectedValueOnce(refused) + b.uiWorkspace.startSession(wid('a')) + await vi.waitFor(() => { + expect(b.notify).toHaveBeenCalledExactlyOnceWith({ + kind: 'createFailed', + message: 'agent-preset/invalid: agent-presets: preset "broken" failed to mount: row "workflow-ptc" names a plugin that cannot be resolved', + }) + }) + expect(warning).toHaveBeenCalledExactlyOnceWith('new session failed:', refused) + expect(b.sessions.retain).not.toHaveBeenCalled() + + // The hero picker reaches the same creation through openWorkspace; a + // failure that is not a Host refusal keeps its own message. + b.sessions.create.mockRejectedValueOnce(new Error('create failed')) + await expect(b.uiWorkspace.openWorkspace(wid('a'))).rejects.toThrow('create failed') + expect(b.notify).toHaveBeenLastCalledWith({ kind: 'createFailed', message: 'create failed' }) + b.sessions.create.mockRejectedValueOnce('rejected without an Error') + await expect(b.uiWorkspace.openWorkspace(wid('a'))).rejects.toBe('rejected without an Error') + expect(b.notify).toHaveBeenLastCalledWith({ kind: 'createFailed', message: 'rejected without an Error' }) + expect(b.notify).toHaveBeenCalledTimes(3) + }) + it('uses only an explicit Workspace or the recent-Workspace policy for new Sessions', async () => { const current = summary('current', { cwd: '/w/current-home', updatedAt: 1 }) const recent = summary('recent', { cwd: '/w/recent-home', updatedAt: 2 }) 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 d92592d81a..fe5358eef9 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -345,6 +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.'], }, { signature: 'forkSession(sessionId: SessionId): Promise', @@ -360,7 +361,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'startSession(workspaceId?: WorkspaceId): void', - description: 'Start a New Session flow and navigate to its Session.', + description: 'Start a New Session flow and navigate to its Session; a creation the Host refuses is shown through the Workspace notice and leaves the selection as it was.', parameters: [{ name: 'workspaceId', description: 'explicit target; absent inherits the current or most recent Workspace.' }], }, { From 508329fe5799287c2e69dca15c27505dc911ba3d Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 20:56:49 +0800 Subject: [PATCH 140/196] feat(ui-chat): add presentation policy channel --- ...-presentation-policy-and-step-groups.zh.md | 276 ++++++++++++++++++ ...26-09-20-chat-work-details-migration.zh.md | 120 ++++++++ packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 + packages/client/ui-chat/README.zh.md | 2 + packages/client/ui-chat/src/chat-settings.ts | 23 +- packages/client/ui-chat/src/client/apply.ts | 6 +- .../src/client/chat/AssistantMarkdown.tsx | 12 +- .../src/client/chat/AssistantNodeView.tsx | 10 +- .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 19 +- .../ui-chat/src/client/chat/ChatView.tsx | 6 +- .../src/client/chat/ReasoningRow.module.css | 5 + .../ui-chat/src/client/chat/ReasoningRow.tsx | 54 ++-- .../client/chat/register-node-renderers.ts | 18 +- .../ui-chat/src/client/contract/slots.ts | 24 +- .../ui-chat/src/client/contract/snapshot.ts | 2 + .../turn-process-presentation.ts | 2 + packages/client/ui-chat/src/client/index.ts | 6 +- packages/client/ui-chat/src/client/locale.ts | 16 +- .../ui-chat/src/client/presentation-policy.ts | 70 +++++ .../src/client/settings/TranscriptViewRow.tsx | 28 +- .../ui-chat/src/client/transcript-view.ts | 13 +- packages/client/ui-chat/src/index.ts | 2 +- ...stant-markdown-path-images.client.spec.tsx | 3 + .../ui-chat/tests/chat-apply.client.spec.tsx | 6 +- .../tests/chat-branch-tails.client.spec.tsx | 2 + .../ui-chat/tests/chat-view.client.spec.tsx | 7 +- .../tests/coverage-tails.client.spec.tsx | 8 +- .../tests/gate-branch-tails.client.spec.tsx | 3 + .../tests/image-labels.client.spec.tsx | 4 + .../tests/presentation-fixture.client.ts | 10 + .../tests/reasoning-row.client.spec.tsx | 23 +- .../transcript-view-policy.client.spec.ts | 18 +- .../tests/transcript-view-row.client.spec.tsx | 8 +- .../ui-conversation/src/client/locales.ts | 8 +- .../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/DisclosureRow.tsx | 7 +- .../client/ui-skill/src/client/locales.ts | 2 +- 40 files changed, 716 insertions(+), 121 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md create mode 100644 .agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md create mode 100644 packages/client/ui-chat/src/client/presentation-policy.ts create mode 100644 packages/client/ui-chat/tests/presentation-fixture.client.ts 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 new file mode 100644 index 0000000000..88ee6a4986 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md @@ -0,0 +1,276 @@ +# Agent Note: Chat 展示策略通道与过程分组的基础架构 + +Status: proposed + +## 问题 + +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 重渲染」这条它新增的热路径。 + +审阅那次实现,问题不在业务需求,在选型: + +- 模式被当成列表结构的输入。`ChatNodeList` 订阅整表 `layout`,按 range 生成 wrapper 组件;完全展开模式下 wrapper 消失。模式切换等于整棵列表重挂载,任何一次重新分组都重建 wrapper 的 key。 +- 模式进了 `ChatNodeOwnerProps`。每个 seat 的 owner 对象随模式变,所有业务渲染器跟着重渲染,包括与模式无关的用户消息、工具行、压缩标记。 +- 同一个 Assistant 节点在 seat 里被拆成 reasoning 与 response 两份派生对象,只为了让 wrapper 在 DOM 上包住前者不包住后者。master 的 `AssistantMarkdown` 早有 `reasoningHidden` 与 `revealProcess`,靠隐藏状态就能只藏 reasoning。 +- 基础设施行的 Chat 可见性由消费方过滤函数在四处各判一遍,而节点本身有 `visibility` 字段由 Definition 决定。 + +再往下一层,是一个信息流上的两难:分组的行要出现在成员之前,从组打开那一刻就该存在;但它要显示的内容,只有看到整轮所有事件的人才知道。事件驱动的 Context 模型里每个 Context 只收自己的事件,能看到整轮的只有 turn 级 Context,而它只能产出一个节点。 + +本记录给出一套基础架构,让三档模式与分组落地时满足下面的约束,并让后续产品变化只落在少数节点上。 + +## 目标与约束 + +下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。 + +| 编号 | 裁决 | 含义 | +|---|---|---| +| 1 | 分组结构在 Definition 层定义 | 组的开合、成员范围、摘要由一个多重匹配本轮事件的 Definition 折叠出来,不由渲染层扫描节点得到 | +| 2 | 有限刷 seat | 切模式只重渲染 presentation 真正变化的 seat;流式 chunk 只重渲染所属 seat 和它所在组的组头;用户消息、回复、turn-tail 在这两种变化下零重渲染 | +| 3 | 一个 Context 一个节点 | 不引入「一个 Context 产出多个节点」,理由是绝大多数 Context 只会有一个元素,数组化让内存更零散 | +| 4 | 模式不开关 Definition | 注册变化会触发 registry 重建与全量重放,所有 seat 必刷;Definition 必须与模式无关 | +| 5 | 折叠按轮判定 | 一轮只要自己的 `turn/start` 已加载就可以折叠;被分页切开的最老一轮保持展开,本轮不优化 | +| 6 | 允许改 ui-conversation | 引擎可以改,但只做让方案正确成立所必需的小改动 | +| 7 | 本轮范围 | 只写代码与本记录;不写测试,不改其他文档;测试与文档在下一轮补 | + +有一条前置澄清:接受「切模式全屏重排版」不等于接受「所有 seat 重渲染」。浏览器重排是几何结果,seat 重渲染是 React 组件函数重跑,后者才是这里要限制的量。 + +## 可行的路线 + +围绕「组头这一行的身份从哪来、内容从哪来」,可行的路线有五条。 + +**路线 A,头成员的 seat 画组头。** 列表不变,builder 算出分组后告诉第一个成员的 seat「你是组头」,该 seat 在自己上方画标题,其余成员 seat 用现有 `hidden` 机制藏起来。零新概念。缺点是 seat 里出现「自身可见但只显示标题」的第三种状态,且标题寄生在一个业务节点的 seat 里。 + +**路线 α,每步一个 Context 当组头。** 增加一个 id 为 `turn:step` 的 Definition,在构建时读上一步的 `assistant-step` 数据判断自己是否新组开头,是则产出组头节点。组的内容由另一个 turn 级 Definition 发布成 Turn 数据供渲染器读取。 + +**路线 β,一个 Context 产出多个节点。** turn 级 Definition 一个人产出整轮控件加 N 个组头。 + +**路线 γ,父 Context 派生子 Context。** turn 级 fold 在开组时生成一个 `step-group` 子 Context 并把后续事件转发给它,子 Context 有自己的单个节点。引擎需要新增转发原语与重放级联。 + +**路线 δ,Definition 拥有结构,builder 物化行。** turn 级 Definition 折叠出组列表并发布为 Turn 数据;Chat builder 据此物化组头节点并给成员写归属;seat 用组合 selector 决定隐藏;引擎给 `ViewBuilder.apply` 的入参加一份「本次 flush 哪些 Location 数据换了引用」。 + +## 砍掉的路线与理由 + +**每组一个 Context 无法成立。** 引擎要求 `match(event)` 只看单个事件算出 id,「第几组」取决于前面有没有回复,单个事件算不出。这不是实现难度,是契约上的不可能。 + +**路线 α 与裁决 2 冲突。** 组头身份能算出,但内容要读整轮一份 Turn 数据,任何一组变化全轮组头都重渲染。改成去读 `turn.steps` 里后面几步的数据,又碰上组头节点不会因为后面的步到来而重建,读到的是陈旧引用。 + +**路线 β 被裁决 3 否决。** 见上表。 + +**路线 γ 现在不值。** 它在所有权上最纯,但要给 assembler 加转发与重放级联,是几百行的引擎核心改动。本记录把它保留为备选,条件写在末尾。 + +**路线 A 作为退路保留。** 如果 builder 物化节点在实践中被否决,退到 A;两者的结构来源相同,只差组头这一行的承载。 + +选择路线 δ。 + +## 方案 + +### 数据流 + +``` +事件流 + ──▶ 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,选出的是自己的结论 +``` + +引擎改动只有一处:assembler 在 flush 时把「哪些 Location 数据换了引用」随 `apply` 入参交给 builder。 + +### 各层职责 + +| 层 | 拥有 | 明确不拥有 | +|---|---|---| +| `step-groups` Definition | 组的开合、`headStep`、`closeStep`、工具计数与名称、运行中的工具;作为 Turn 数据发布 | 任何视图节点;成员是哪些节点 | +| Chat builder | 组头节点的身份与生命周期;成员归属;`turnStarted`;per-key presentation 的身份比较发布 | 读事件;自己算业务结构 | +| 展示策略对象 | 枚举到能力字段的映射 | 任何节点或 seat 状态 | +| seat | 用策略字段、角色、store 状态合成 hidden;把组的开合能力交给组头渲染器 | 判断组的边界 | +| 组头渲染器 | 标题、箭头、点击开合 | 组的内容来源 | +| assistant-step 渲染器 | 是否显示已完成推理的首行预览;作为组的关闭者时隐藏推理部分 | 组的边界 | + +### 组头节点的身份与生命周期 + +组头节点的 key 是 `step-group:${turn}:${headStep}`。`headStep` 在组打开那一刻就确定,之后任何事件都改不了「谁是第一个」,所以组头节点从打开起就存在并被复用,运行中不会换 key。组关闭只是它 data 里的 `closed` 从 false 变 true。 + +组头节点的锚点是该组头成员节点锚点减去一个合成偏移,位置紧贴在第一个成员之前。头成员的 Assistant 节点在流式期间锚在第一个可见 chunk,落盘后锚在消息事件,锚点会移动一次;组头随之移动一次,这是一次结构性更新,与 Assistant 节点自己的结构性更新同时发生,不额外增加次数。 + +组头节点属于整轮过程的一部分:整轮折叠时它随成员一起隐藏。它不在 `TURN_PROCESS_INDEPENDENT_KINDS` 里。 + +组头节点的 data 就是 fold 发布的组对象。已关闭的组对象引用永不改变,因此已关闭组头的 source 永不通知;正在运行的组每来一个改变它的事件换一个对象,只有这一组的组头重渲染。 + +### fold 状态机 + +`step-groups` Definition 的 id 是 turn 号,在 `turn/start` 上启动,匹配与 `turn-process` 同一批事件。State 是 `{ turn, groups, open }`,`groups` 是组对象数组,`open` 是当前打开组的下标或空。 + +| 事件 | 转移 | +|---|---| +| `tool/call` | 没有打开的组则以该事件的 step 开新组;计数加一;记录 distinct 工具名;把 callId 加入运行集合 | +| `tool/result` 追加 | 从运行集合移除 callId | +| `assistant/live-chunk` 推理 delta 含可见字符 | 没有打开的组则以该 chunk 的 step 开新组 | +| `assistant/live-chunk` 文本 delta 或文本块结束含可见字符 | 有打开的组则以该 chunk 的 step 关闭它 | +| `assistant/message` 追加 | 消息含可见推理则按推理规则处理,含可见回复则按文本规则处理;顺序是先推理后文本 | +| `turn/end` | 有打开的组则标记为被轮次结束关闭 | +| `step/start`、`step/end`、`llm/retry` | 不改变状态;重试行不属于任何组 | + +三条不变量: + +- 幂等。开组时已有打开的组则无操作;关组时组已关闭则无操作。live chunk 与落盘消息重复描述同一内容不会开两次或关两次,历史重放只有落盘消息也能得到同样的组列表。 +- 引用稳定。State 没变时 `update` 返回原对象;`groups` 数组只在组列表变化时换引用;已关闭的组对象永不替换。 +- 只向前折叠。不读邻居 Context,不读节点,不向后看。 + +Turn 数据发布:`buildLocationData` 只在 turn 阶段返回值,`groups` 引用不变时返回上一次发布的值,引擎据此不重新发布。没有 `turn/start` 的历史窗口用与 `turn-process` 相同的兜底:从已有匹配折叠出 State。 + +发布节奏:live chunk 走动画帧合并,其余立即。 + +### 成员归属规则 + +归属由 builder 按 step 判定,不用 seq 区间,因为 live chunk 的 seq 与落盘事件的 seq 不在一个尺度上。 + +| 节点 | 规则 | +|---|---| +| `tool-call`,step 为 s | 属于 `headStep ≤ s` 的最后一组。文本在同一 step 关闭了上一组之后的工具调用,会由 fold 以该 step 开出新组,所以「最后一组」总是正确的那一组 | +| `assistant-step`,step 为 s,且某组 `closeStep === s` | 角色为关闭者。节点保持可见;组折叠时只隐藏它的推理部分,用现有 `reasoningHidden` 机制 | +| `assistant-step`,step 为 s,其余情况 | 属于 `headStep ≤ s` 且 `s` 未越过 `closeStep` 的组,角色为成员 | +| `model-retry`、`turn-error`、`turn-max-tokens`、`turn-tail`、`user`、`steering` | 不属于任何组 | + +归属只在组列表变化时重算,成本是该轮节点数。流式 chunk 不改变组列表时,成员的 presentation 不发布。 + +组头行锚在最早的成员之前。一个组如果只有关闭者的推理而没有任何成员,比如一步里先推理后回复,组头行就锚在关闭者之前,这样被隐藏的推理仍然有一行可以点开。 + +### 分组变化怎样到达 builder + +fold 只发布 Turn 数据,没有视图节点。engine 的 `flush` 在 Location 数据有变化时会把 `timelineDirty` 置真并对所有活跃目标调用 `apply`,但 builder 不知道是哪一轮的哪个 key 变了。依赖「同一轮总有别的节点也在这次 flush 里变了」去推断,是巧合不是契约。 + +所以给 `ConversationViewBuilder.apply` 的入参增加可选的 `changedLocationData`:本次 flush 里换了引用的 Location 数据清单,每项含 `kind`、`turn`、可选 `step`、`key`。assembler 在 `applyDirtyLocationData` 里本来就逐条比较了前后引用,只是把比较结果收集起来交给 builder。Chat builder 只取 `key === 'step-groups'` 的轮次做重投影。入参可选,不传时 builder 退化为按本次 upsert 涉及的轮次重投影。 + +### seat 的 hidden 合成 + +seat 不再接收 `compactTranscript` 与 `historyIncomplete` 两个 prop。它通过 `usePresentation` 读策略,但选出的不是模式,而是自己的结论: + +```ts +const grouping = usePresentation(p => group === undefined ? 'none' : p.stepGrouping) +const foldCompleted = usePresentation(p => p.foldCompletedTurns) +``` + +不在任何组里的 seat,`grouping` 在三档下都是 `'none'`,值没变就不重渲染。`foldCompletedTurns` 在三档下都是 true,同理。selector hook 的底层是 `useSyncExternalStoreWithSelector`,只有选出的值变化才重渲染。 + +三个隐藏来源合成一个 `hidden`: + +| 来源 | 条件 | 揭示动作 | +|---|---|---| +| 整轮折叠 | 现有规则,`turnClosed && turnStarted && 有回复` 且未手动展开 | 打开整轮 | +| 组折叠 | `grouping === 'collapsed'`、角色为成员、组未手动展开 | 打开该组 | +| 组头在完全展开档 | 角色为组头且 `grouping === 'none'` | 无 | + +关闭者节点的推理部分单独隐藏:seat 通过 owner props 里的 `stepGroup.reasoningHidden` 告诉 assistant-step 渲染器。 + +组的展开状态进 chat store,key 是 `turn` 加 `headStep`,与整轮的手动展开状态并列。 + +### 刷新范围 + +下表是这套结构给出的保证,也是下一轮渲染次数测试的断言表。每一行断言的是精确集合,不是上限。 + +| 外部变化 | 允许重渲染的 | +|---|---| +| 运行中的组来一个推理 chunk | 该 assistant seat | +| 运行中的组来一个 `tool/call` | 该 tool seat,加这一组的组头 | +| 回复出现,组关闭 | 该 assistant seat,加这一组的组头;成员归属重算只发生在开关组时 | +| 新组打开 | 新组头进 `order`,一次结构重排;其他 seat 不刷 | +| 模式在简洁与详细之间切换 | 仅有已完成推理的 assistant-step 渲染器 | +| 模式切到完全展开或切回 | 仅成员与组头 seat | +| `performanceUsage` 切换 | 仅 turn-tail | +| 加载更早一页 | 新增 seat,加 presentation 变了的历史 seat | +| 语言切换 | 全部,作为对照 | + +### 展示策略对象 + +持久化的枚举与运行时词汇分开。枚举 `TranscriptViewMode` 是 `compact`、`detailed`、`expanded`,存档里的旧值 `normal` 读取时映射为 `detailed`,不回写。运行时词汇是派生的 `ChatPresentationPolicy`: + +| 字段 | compact | detailed | expanded | +|---|---|---|---| +| `foldCompletedTurns` | true | true | true | +| `stepGrouping` | collapsed | collapsed | none | +| `settledReasoningPreview` | false | true | true | + +映射只存在于一处。每档对应一个常量对象,同一档永远同一引用,所以派生观察量不需要自己的订阅簿记:`getSnapshot` 是查表,`subscribe` 直接转给模式观察量。 + +渲染器不比较枚举,只 select 自己需要的字段。策略观察量按 kind 注入:assistant-step 的注册通过 `inject.hooks.presentation` 拿到它,与 turn-tail 注入 `performanceUsage` 是同一写法。以后加第四档只改这张表,渲染器一个不动。 + +### 按轮折叠 + +per-key presentation 增加 `turnStarted`,来源是该轮 `turn/start` 是否在加载窗口内。seat 的折叠资格由 `turnStarted` 替代原来的全局 `!historyIncomplete`。分页加载完成时不再有任何全局 prop 变化,只有新增节点与它们所在轮的 presentation 变化。 + +### 复杂度契约 + +| 场景 | fold | builder | 发布 | +|---|---|---|---| +| 内容型 chunk,不改变组边界 | O(1) 状态转移,返回原对象 | 无重投影 | 零 | +| 改变组边界的事件 | O(1) | 只重投影所属轮,成本为该轮节点数 | 该轮内事实变化的 key | +| 结构变化与分页 | 按匹配的事件数 | 全量一次 | 事实变化的 key | +| 模式切换 | 无 | 无 | 无;重渲染由 seat 与渲染器的 selector 决定 | + +## 改动清单 + +| 包 | 文件 | 改动 | +|---|---|---| +| ui-conversation | `contract/conversation.ts` | 新增 `ConversationLocationDataUpdate`;`ConversationViewBuilder.apply` 入参增加可选 `changedLocationData` | +| ui-conversation | `conversation/assembler.ts` | `applyDirtyLocationData` 收集换了引用的 Location 数据并随 `apply` 传出 | +| ui-conversation | `index.ts` | 导出新类型 | +| ui-chat | `chat-settings.ts` | 三档枚举;schema 接受旧值 `normal` | +| ui-chat | `transcript-view.ts` | 读取时 `normal` 映射为 `detailed` | +| ui-chat | `presentation-policy.ts` 新增 | 策略对象、映射表、派生观察量 | +| ui-chat | `settings/TranscriptViewRow.tsx` | 三个选项 | +| ui-chat | `locale.ts` | 三档文案;组头文案 | +| ui-chat | `contract/slots.ts` | `ChatViewInjected.hooks.presentation`;`PresentationInjected`;`ChatNodeOwnerProps.stepGroup`;`StepGroupOwnerProps` | +| ui-chat | `contract/snapshot.ts` | `ChatTurnProcessPresentation.turnStarted`;`ChatNodeGroupPresentation`;`processSource` 的值改为 per-key 组合对象 | +| ui-chat | `contract/chat-nodes.ts` | `StepGroupChatData` | +| ui-chat | `contract/store.ts`、`stores.ts` | 组的手动展开状态与动作 | +| ui-chat | `conversation-nodes/common.ts` | 组头锚点偏移 | +| ui-chat | `conversation-nodes/step-groups.ts` 新增 | fold Definition 与 Turn 数据声明合并 | +| ui-chat | `conversation-nodes/step-group-nodes.ts` 新增 | 组头节点物化与成员归属投影 | +| ui-chat | `conversation-nodes/turn-process-presentation.ts` | `turnStarted`;组合 per-key presentation | +| ui-chat | `conversation-nodes/chat-snapshot-builder.ts` | 接入物化投影;消费 `changedLocationData`;store 支持删除 key | +| ui-chat | `conversation-nodes/register.ts` | 注册 fold Definition | +| ui-chat | `chat/ChatView.tsx` | 删除 `compactTranscript` 与 `historyIncomplete` 的传递;传 `usePresentation` | +| ui-chat | `chat/ChatNodeSeat.tsx` | 组合 selector;三个隐藏来源;`stepGroup` owner props | +| ui-chat | `chat/AssistantNodeView.tsx`、`chat/AssistantMarkdown.tsx`、`chat/ReasoningRow.tsx` | 注入策略;已完成推理的预览开关;关闭者的推理隐藏 | +| ui-chat | `chat/StepGroupNodeView.tsx` 新增 | 组头渲染器 | +| ui-chat | `chat/register-node-renderers.ts` | 注册组头渲染器;给 assistant-step 注入策略 | +| ui-chat | `apply.ts`、`index.ts` | 创建策略观察量并注入;导出类型 | + +本轮不改:README、i18n 配对文件、slot catalog、测试、快照。slot catalog 中 `ChatNodeOwnerProps` 的源码摘录与 key 列表需要在提 PR 前重新生成。 + +## 验收标准 + +下一轮补齐,本轮不实现。 + +**渲染次数隔离测试。** 在现有 `chat-view.client.spec` 的挂载方式上加计数:React Profiler 的 `onRender` 按 seat 计数,测试专用的计数渲染器注册进 `conversation.chat.node` 按 kind 计数。刺激与期望按「刷新范围」一节的表逐行断言精确集合。 + +**发布次数测试。** 同样的刺激下统计每个 key 的 source 通知次数,断言精确集合。 + +**fold 状态机单测。** 喂事件序列,断言组列表:开组、关组、同 step 先文本后工具开新组、轮次结束关组、幂等性、已关闭组对象引用不变、只有落盘消息的历史重放得到相同组列表。 + +**成员归属单测。** 给定组列表与该轮节点,断言每个 key 的角色。 + +**基准补位。** `conversation-fold` 增加带推理与工具的增量 apply 场景;`long-session-browser` 增加带工具与推理的流式 fixture。这是墙钟兜底,不是门禁。 + +## 风险与待决 + +- **builder 产出节点的边界。** 这是这套架构里第一次由视图 builder 产出节点。边界写死为:builder 只能从 Turn 或 Step 数据物化行,不能读事件,不能自己算业务结构。合成 key 用 `step-group:` 前缀,Context key 以数字开头,不会冲突。如果实践中被否决,退到路线 A。 +- **组头锚点随头成员移动一次。** 头成员是流式 Assistant 时,落盘让锚点从首个可见 chunk 移到消息事件,组头跟着移动一次。与 Assistant 自己的结构性更新同时发生。 +- **三档在本轮的实际差异。** 简洁与详细目前只差已完成推理的首行预览;运行中的详情标题、类别摘要等产品特性不在本轮。策略表已为它们留了位置。 +- **分页与半截轮。** 被分页切开的最老一轮不折叠、不分组;归属只按 step 计算,不受分页影响。 +- **组头文案。** 本轮只有「正在处理」与「已完成处理」加工具调用次数,类别摘要留给产品层。 +- **流式 chunk 的 step 字段。** `assistant/live-chunk` 事件携带 `step`,与 `turn-process` 的用法相同;若 chunk 缺少 step 则不开组。 + +## 备选保留:父 Context 派生子 Context + +如果 builder 物化节点在使用中暴露出比预期更多的边界问题,替代方案是给引擎加一个原语:Context 在 `update` 中可以派生一个指定 kind 与 id 的子 Context,并把后续匹配转发给它;子 Context 有自己的单个节点、自己的发布节奏与 Location。父 Context 重放时子 Context 按 id 对齐,未再派生的子 Context 按现有撤回规则转为隐藏。 + +它的收益是行的身份与内容都回到 Definition 层,一组一个 Context,没有数组碎片。代价是 assembler 要新增转发与重放级联,且 `match` 之外多出一条 Context 创建路径。切换条件:builder 物化节点的边界在两个以上业务里被突破,或成员归属的按 step 规则出现无法用 step 表达的用例。 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 new file mode 100644 index 0000000000..62381f453e --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md @@ -0,0 +1,120 @@ +# Agent Note: Chat 工作过程展示迁移进度 + +Status: proposed + +## 问题 + +PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发通知、工具行视觉和基础设施行隐藏。整批迁移会把独立功能与列表重组混在一起,也难以逐项判断性能变化。需要一份按功能维护的进度表,先完成不依赖 step-group 的部分,再处理分组,避免遗漏原有产品需求。 + +## 方案 + +当前迁移目标是 `feat/chat-presentation-channel`。`pr-4565` worktree 保存原 PR 的重放内容,作为产品行为和实现的参考;`feat/chat-step-groups` 保存尚未接入当前通道的分组实验。不能仅凭分支名或提交记录判断完成情况,还要核对各 worktree 中未提交的代码。 + +本记录管理功能范围、当前实现和验收进度。[展示策略通道与过程分组的基础架构](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md)保留架构路线及备选方案;本记录不替代它,也不把其中的方案视为已经实现。新记录没有完整取代既有架构决策,因此保留原记录,不做归档或删除。 + +本记录处于开发阶段,只维护中文版。新增迁移代码不自动暂存,不覆盖已有暂存内容。当前这一轮仅新增本记录,没有继续迁移功能代码。 + +### 状态定义 + +| 状态 | 含义 | +|---|---| +| 当前代码已有 | 已能在当前 worktree 源码中找到实现;不代表已提交、已发布或通过完整验收 | +| 待迁移 | 已纳入非分组迁移范围,当前尚未实现 | +| 分组阶段 | 产品需求保留,依赖分组结构或分组交互,暂不接入当前通道 | +| 待验收 | 需要行为、节点身份、刷新范围或浏览器性能证据;不能用编译结果代替 | + +### 当前通道已有的能力 + +| 能力 | compact | detailed | expanded | 实现状态 | 验收状态 | +|---|---|---|---|---|---| +| 三档设置与旧值读取 | 简洁 | 详细;旧值 `normal` 读作本档,不回写 | 完全展开 | 当前代码已有 | 待验收 | +| 按轮判断整轮折叠资格 | 本轮已结束、起点已加载且存在最终答案时可折叠 | 相同 | 相同 | 当前代码已有 | 待验收 | +| 已完成推理的收起摘要 | 只显示「思考」 | 显示首行 | 显示首行 | 当前代码已有 | 待验收 | +| 模式切换时保留摘要 DOM | 用 CSS 隐藏摘要及分隔点 | 显示原有摘要及分隔点 | 相同 | 当前代码已有 | 待验收 | +| 推理预览的局部订阅 | `ReasoningRow` 选择自己的可见性 | 相同 | 相同 | 当前代码已有 | 待验收 | +| 折叠行引用稳定 | 回调、图标及未变化的摘要保持引用;完整 Markdown 仅在展开时挂载 | 相同 | 相同 | 当前代码已有 | 待验收 | + +三档都保留整轮折叠。`expanded` 的产品含义是取消二级过程分组,并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。 + +### 不依赖分组的迁移清单 + +下表的“三档共用”表示三档都应用该行为,不表示切换档位时会改变它。 + +| 功能 | compact | detailed | expanded | 当前状态 | 迁移落点与注意事项 | +|---|---|---|---|---|---| +| 流式推理摘要 | 段落首行写完后才更新 | 相同 | 相同 | 待迁移 | 保留现有摘要 DOM 和局部订阅;配套右侧渐隐。当前仍跟随最新一行,不能标为完成。原版每次扫描全文的计算方式需要优化 | +| 工具标题中文化 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | Bash/Pwsh 为「运行命令」、Grep 为「搜索文件内容」、Glob 为「查找文件」、Skill 为「加载技能」;只改 UI 字典,不改模型工具名 | +| 工具行交互视觉 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 悬停时标题和摘要加深,展开箭头朝上;共享 `DisclosureRow` 的调用方一并核对 | +| Bash 停止状态的读屏提示 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 补齐停止状态的可访问名称,不改变执行或取消语义 | +| 工具及命令运行时文字扫光 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 保留固定文本节点,以运行状态控制动画;不把新建 ReactNode 和临时回调重新引入 memo 调用链;保留减少动态效果设置 | +| 取消、失败后保持过程展开 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 已停止和失败的轮次不自动收起,也不允许手动收起;可独立于计时控件迁移 | +| 整轮状态与用时控件 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 从轮次开始显示,运行时显示用时,完成后固定时长,停止或失败显示对应状态;移除底部重复运行条。控件提前出现需要处理锚点和排序,但不需要 step-group | +| 非人工触发通知 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 用持久化 Inbox 领取记录识别唤醒输入,显示独立通知;展开查看原始内容。来源标题仅使用已记录的来源字段,不解析正文猜测身份 | +| 隐藏基础设施行 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | Chat 隐藏系统提示词、普通上下文注入和 permission 命令;Session 数据及 Trajectory 保留。先保住唤醒通知,再隐藏普通上下文;可见性在节点定义处集中决定 | +| 轮次操作栏位置 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 操作栏放到轮次结束之后,反馈仍对应最后一条持久化文字回复;检查中断、重试和分支操作,不直接删除相关判定 | +| 操作栏常显条件 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 最新轮仅在最后可见内容为回复时常显,其余通过悬停或键盘聚焦显示;把判定放回 Turn 自己的投影,不引入原版分组索引 | + +开发者工具默认开启已由当前基线提供,不重复迁移。当前实现保留等待 Host 接受设置后才启用相关能力的保护,不以原 PR 的缺值回退覆盖它。原 PR 的 `Switch` 改动只有注释,不作为功能迁移项。 + +### 分组阶段保留的需求 + +这些项目当前均未接入 `feat/chat-presentation-channel`。隔壁 worktree 有实验代码,不代表这些产品行为或性能要求已经完成。 + +| 功能 | compact | detailed | expanded | 当前状态 | +|---|---|---|---|---| +| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行直接展示,不使用二级分组包装 | 分组阶段 | +| 同一 Assistant 中推理与回复的分别显隐 | 推理可归入前组,回复外露 | 相同 | 不做二级分组隐藏 | 分组阶段;不直接复制原版拆成两个渲染节点的实现 | +| 组头类别摘要 | 运行时显示当前类别,完成后聚合主要类别 | 相同 | 无组头 | 分组阶段 | +| 运行中组头详情 | 不显示 | 显示命令、路径、查询等详情,无运行工具时显示推理摘要 | 无组头 | 分组阶段 | +| 组头内容更新节奏 | 保留类别标题的最短展示时间 | 同时处理详情更新 | 不适用 | 分组阶段 | +| 组内限高滚动 | 展开内容限高、独立滚动、边缘渐隐 | 相同 | 不适用 | 分组阶段 | +| 分组与整轮折叠联动 | 整轮收起时隐藏分组 | 相同 | 仅整轮折叠 | 分组阶段;保留节点身份,内部开合状态是否重置单独确定 | +| 分页、流式结算与组身份 | 既有组头和成员稳定,半截轮单独处理 | 相同 | 同一批业务节点直接展示 | 分组阶段 | + +分组产品细节的核对项:完成摘要最多聚合前三类,超过三类使用「等」;调用计数用于分类排序,不直接显示为组头数字。详细模式的实时详情合并空白并限制为 160 个字符;标题最短展示时间为 150ms,只保留最新更新,不补播积压项。展开区高度不超过 400px 与半屏中的较小值,可滚动方向显示 24px 渐隐,并保留与外层滚动的衔接。重试行不属于二级过程组。这些数值是原 PR 的行为参考,不代表当前分组实验已经实现。 + +## 性能约束 + +| 场景 | 要保留的性质 | 所需证据 | +|---|---|---| +| 切换三档模式 | 不改变业务节点 key,不因模式切换重新注册 Definition;仅相关可见性或展示字段更新 | 节点身份和刷新范围检查 | +| 推理或工具内容增量更新 | 只更新所属节点;避免每个分片重新扫描完整推理或完整历史 | 增量工作量与长内容场景 | +| 整轮运行时钟 | 只有运行中的时钟内容按秒更新,不带动整轮成员、历史行和列表 | 更新范围及计时器释放检查 | +| 摘要显隐 | 轻量摘要常驻;完整 Markdown 正文不为模式切换而全部常驻 | DOM 身份、首次加载和内存观察 | +| 分页 | 新增节点与事实变化的节点更新,已完成轮次不因全局分页状态一起刷新 | 多页历史交互与刷新范围检查 | +| 状态切换 | 文字、图标、回调引用在内容未变时稳定;不通过重挂载清空内部状态 | 交互状态与组件生命周期检查 | + +保留 DOM 只减少挂载和卸载成本,不消除样式计算、布局与绘制,也会增加常驻内存。不能将“只改显示属性”写成“不会卡顿”的保证。性能目标是降低切换峰值,并保持首次加载、分页和流式更新的成本可接受。 + +## 备选方案与取舍 + +**整批重放原 PR。** 不采用。分组、节点拆分、整轮状态和独立视觉功能混在一起,难以把性能变化对应到具体功能,也会覆盖当前通道已完成的局部订阅与节点复用。 + +**切换模式时卸载、重新创建摘要或过程节点。** 摘要模式切换不采用该方式。稳定挂载让来回切换只改变显示状态;新增节点的初始成本仍须单独检查。 + +**为了切换平滑,把所有 Markdown 正文常驻。** 不采用。轻量摘要与完整正文的内存、DOM 和渲染成本不同;正文继续按手动展开状态挂载。 + +**收起整轮时重挂载 renderer,以重置工具卡。** 不直接迁移。该实现与稳定节点及保留交互状态的方向冲突;若后续仍需要重置行为,应明确状态所有者和触发规则。 + +## 验收标准 + +完成一项时更新它的实现状态、实际落点和验证记录;未完成项保留,不因拆分分支而删除。分组阶段开始前,逐行复核上面的产品需求和模式对应关系。 + +| 验收项 | 当前状态 | +|---|---| +| 当前通道已有能力的完整行为验证 | 待验收;既有测试包含只做类型接线、尚未调整的旧断言 | +| 非分组功能迁移 | 待迁移;本次仅创建进度记录 | +| 模式切换时的 DOM 身份和刷新范围 | 待验收;不能以 memo 或 selector 的存在代替实际证据 | +| 长会话首次打开、分页、流式输入与内存 | 待验收;原 PR 的历史测量不代表本实现 | +| 新行为的单元、浏览器与录制快照覆盖 | 待补;开发阶段不在此轮运行测试或录制 | +| 分组功能与两档差异 | 分组阶段 | + +此前通道调整有编译检查记录,但不是本次全部迁移的通过证明,也不代表当前所有测试通过。本轮未执行检查。 + +## 风险 + +- 当前 `expanded` 与 `detailed` 的展示相同;只有分组阶段完成后,两档才具备原 PR 设计的区别。 +- 整轮用时控件替换现有工具和消息计数标题,属于产品信息变化;迁移时不能只换样式而遗漏状态、可访问名称或空过程轮次。 +- 普通上下文隐藏若先于触发通知,会让后台唤醒原因消失;两项需按清单顺序衔接。 +- 原 PR 的页脚判定与分组索引耦合,需要按功能重写归属,不能为页脚单独引入整套分组基础设施。 +- 分组实验中的组身份、成员归属和 builder 物化路线仍需继续评估;本记录不将实验实现列为最终架构。 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 7d9e60da65..489c1e8723 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: eea8eeda01c3d0bd30669cdf3743c64be0d76fbe -README.zh.md: 58fef8e8c47ae5f67c8ecbf614a36bbfbf2e20b3 +README.md: 3a0e74332a0ee2c5453a024f1ffe9dfbb9ae0163 +README.zh.md: 89a385a61f08efa86c98d59fd4271f99c3b25210 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index eea8eeda01..3a0e74332a 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -67,6 +67,8 @@ The completed-turn action footer starts 20px below the preceding prose or extens 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. +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. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 58fef8e8c4..89a385a61f 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -67,6 +67,8 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H 每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 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 默认收起。 ----- diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts index 021a2b0616..ccc9bc55fd 100644 --- a/packages/client/ui-chat/src/chat-settings.ts +++ b/packages/client/ui-chat/src/chat-settings.ts @@ -5,15 +5,24 @@ import z from '@deepseek-ai/schemastery' /** Settings namespace owned by the Chat target. */ export const CHAT_SETTINGS_NAMESPACE = 'ui-chat' -/** Field carrying the completed-Turn transcript presentation mode. */ +/** Field carrying the work-details presentation mode. */ export const TRANSCRIPT_VIEW_FIELD = 'transcriptView' -/** Transcript presentation modes accepted at settings boundaries. */ -export const TRANSCRIPT_VIEW_MODES = ['normal', 'compact'] as const +/** Work-details presentation modes a user can choose. */ +export const TRANSCRIPT_VIEW_MODES = ['compact', 'detailed', 'expanded'] as const -/** Completed-Turn transcript presentation. */ +/** Work-details presentation mode. */ export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number] +/** + * Saved value from the two-mode generation of this setting. Read as `detailed`; + * never offered as a choice and never written back. + */ +export const LEGACY_TRANSCRIPT_VIEW_MODE = 'normal' + +/** Every value the durable field accepts: current modes plus the legacy saved value. */ +const TRANSCRIPT_VIEW_SETTING_VALUES = [...TRANSCRIPT_VIEW_MODES, LEGACY_TRANSCRIPT_VIEW_MODE] as const + /** Default preserves the compact process disclosure introduced by Chat. */ export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'compact' @@ -34,8 +43,8 @@ export const DEFAULT_LINK_OPENING: LinkOpening = 'sidebar' /** Durable Chat section shared by the Host schema and browser scope. */ export interface ChatSettings { - /** Presentation mode for completed Turn process content. */ - transcriptView: TranscriptViewMode + /** Work-details preference; the legacy value is accepted only from existing saved settings. */ + transcriptView: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE /** Detail level for composer statistics and completed-Turn usage. */ performanceUsage: PerformanceUsageMode /** Default destination for Chat HTTP(S) links. */ @@ -46,5 +55,5 @@ export interface ChatSettings { export const ChatSettingsSchema: z = z.object({ linkOpening: z.union(['sidebar', 'new-tab']).default(DEFAULT_LINK_OPENING), performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE), - [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE), + [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_SETTING_VALUES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE), }) diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 079a5e2512..70390ec889 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -34,6 +34,7 @@ import { en, NS, zh } from './locale.ts' import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx' import { createChatStore } from './stores.ts' import { TranscriptViewPolicy } from './transcript-view.ts' +import { derivePresentationPolicy } from './presentation-policy.ts' import { CHAT_SETTINGS_NAMESPACE, DEFAULT_LINK_OPENING, type ChatSettings } from '../chat-settings.ts' import { LinkOpeningRow, type LinkOpeningRowInjected } from './settings/LinkOpeningRow.tsx' import { PerformanceUsageRow, type PerformanceUsageRowInjected } from './settings/PerformanceUsageRow.tsx' @@ -111,9 +112,10 @@ export function apply(ctx: Context): void { }, LinkOpeningRow)) }) const transcriptView = new TranscriptViewPolicy(chatSettings) + const presentation = derivePresentationPolicy(transcriptView.mode) const performancePolicy = new PerformanceUsagePolicy(chatSettings) const performanceUsage = performancePolicy.mode - registerChatNodeRenderers(ctx, performanceUsage) + registerChatNodeRenderers(ctx, performanceUsage, presentation) ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', @@ -155,7 +157,7 @@ export function apply(ctx: Context): void { const session = binding.session const chat = chatSource(binding) return { - hooks: { transcriptView: transcriptView.mode }, + hooks: { presentation }, keyedHooks: { chatNode: key => chat.getSnapshot().nodes.source(key), chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key), diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 13ff7fae4b..967a7a09b1 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -2,7 +2,7 @@ import { Fragment, memo, useMemo } from 'react' import type { ReactNode } from 'react' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions, MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatNodeOwnerProps, ChatViewSlotProps, UsePresentation } from '../contract/slots.ts' import type { AssistantBlock } from '../contract/snapshot.ts' import { markdownLabels } from '../markdown-labels.ts' import { ReasoningRow } from './ReasoningRow.tsx' @@ -29,9 +29,11 @@ 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. */ + /** Hide reasoning that belongs to the Turn-level process disclosure or a collapsed step group. */ reasoningHidden?: boolean | undefined - /** Reveal the owning Turn-level process disclosure. */ + /** Live display policy for reasoning summaries. */ + usePresentation: UsePresentation + /** Reveal the disclosure that hides this reasoning. */ revealProcess?: (() => void) | undefined /** Resolved prose file mentions for this Assistant's closing turn. */ mentions?: MarkdownFileMentions | undefined @@ -42,7 +44,7 @@ export interface AssistantMarkdownProps { /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted, renderMessageImages, - reasoningHidden = false, revealProcess, mentions, t, + reasoningHidden = false, usePresentation, revealProcess, mentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -83,7 +85,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ hidden={reasoningHidden} reveal={revealProcess} > - + , ) break diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index 399897a9b5..9ef72941f5 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -1,11 +1,14 @@ import { memo, useCallback, useMemo } from 'react' -import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts' +import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' +import type { ChatNodeViewProps, PresentationInjected, TurnTailOwnerProps } from '../contract/slots.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' +type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace + /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ export const AssistantNodeView = memo(function AssistantNodeView({ - node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, t, -}: ChatNodeViewProps<'assistant-step'>) { + node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, usePresentation, t, +}: AssistantNodeViewProps) { const data = node.data const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn @@ -33,6 +36,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({ interrupted={data.status === 'interrupted'} renderMessageImages={renderMessageImages} reasoningHidden={reasoningHidden} + usePresentation={usePresentation} revealProcess={revealProcess} mentions={mentions} t={t} diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 40e08dbaf7..3ed6f7b8d8 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useMemo } from 'react' import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatNodeOwnerProps, ChatViewSlotProps, UsePresentation } from '../contract/slots.ts' import type { ChatNode } from '../contract/chat-nodes.ts' import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' import { storedTurnProcessEntry } from '../stores.ts' @@ -12,8 +12,7 @@ interface ChatNodeSeatProps extends ChatNodeOwnerProps { readonly nodeKey: string readonly useChatNode: ChatViewSlotProps['useChatNode'] readonly useChatNodeProcess: ChatViewSlotProps['useChatNodeProcess'] - readonly historyIncomplete: boolean - readonly compactTranscript: boolean + readonly usePresentation: UsePresentation readonly useStore: ChatViewSlotProps['useStore'] readonly actions: ChatViewSlotProps['actions'] readonly renderSlot: ChatViewSlotProps['renderSlot'] @@ -34,9 +33,13 @@ function turnOf(node: ChatNode | undefined): number | undefined { return location?.kind === 'turn' || location?.kind === 'step' ? location.turn.turn : undefined } -/** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */ +/** + * Subscribe, apply Turn-process visibility, and dispatch one stable Context key. + * Policy reads select this seat's own conclusion, so a mode change re-renders + * only seats whose visibility actually changes. + */ export const ChatNodeSeat = memo(function ChatNodeSeat({ - nodeKey, useChatNode, useChatNodeProcess, historyIncomplete, compactTranscript, + nodeKey, useChatNode, useChatNodeProcess, usePresentation, cwd, openFile, openSkill, inspectCall, forkAt, loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t, }: ChatNodeSeatProps) { @@ -59,13 +62,15 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ actions.setTurnProcessOpen(processSpec.turn, processSpec.answerStep, open) } }, [actions, processSpec]) + const foldCompleted = usePresentation(policy => policy.foldCompletedTurns) + // Folding is decided per Turn: the Turn is closed and its start is loaded. const processWindowReady = processSpec !== undefined && processPresentation !== undefined - && compactTranscript + && foldCompleted && processSpec.answerAnchorSeq !== null && processPresentation.turn === processSpec.turn && processPresentation.turnClosed - && !historyIncomplete + && processPresentation.turnStarted const processMember = routedNode !== undefined && processWindowReady && !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 257dad64ab..bad68eb828 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -113,7 +113,7 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod export function ChatView({ useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot, sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions, - useTranscriptView, useProjection, t, + usePresentation, useProjection, t, }: ChatViewSlotProps) { const order = useChat(s => s.order) const nodeStore = useChat(s => s.nodes) @@ -137,7 +137,6 @@ export function ChatView({ const openError = useSession(s => s.openError) const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) - const compactTranscript = useTranscriptView(mode => mode === 'compact') const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null) const [fileOpenBusy, setFileOpenBusy] = useState(false) // Close/retry must ignore a settlement that started before the latest @@ -237,8 +236,7 @@ export function ChatView({ order={order} useChatNode={useChatNode} useChatNodeProcess={useChatNodeProcess} - historyIncomplete={hasMore} - compactTranscript={compactTranscript} + usePresentation={usePresentation} useStore={useStore} actions={actions} cwd={cwd} diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css index 3ca51f8d85..5e98217171 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css @@ -97,6 +97,11 @@ text-overflow: clip; } +.root:not([data-preview]) .separator, +.root:not([data-preview]) .summary { + display: none; +} + .thinkBody { /* Indent under the title, which starts at leading (16 + delta) + gap 6. */ padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px)); diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index 4d484af95b..2d01eeb7fd 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -1,11 +1,13 @@ /** Assistant reasoning disclosure, independent of Tool-call presentation. */ -import { useMemo, useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { DisclosureRow, IconThinkOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatViewSlotProps, UsePresentation } from '../contract/slots.ts' import { markdownLabels } from '../markdown-labels.ts' import a11yCss from './accessibility.module.css' import css from './ReasoningRow.module.css' +const THINK_ICON = + function firstLine(text: string): string { const newline = text.indexOf('\n') return newline === -1 ? text : text.slice(0, newline) @@ -20,16 +22,40 @@ function latestLine(text: string): string { /** * Render one assistant reasoning block collapsed until the reader opens it. The * collapsed summary omits double-asterisk markers; expanded content renders - * the complete Markdown with secondary typography. + * the complete Markdown with secondary typography. A streaming row always + * previews its latest line. Mode changes toggle CSS display without unmounting + * collapsed summaries. * @param props.text - complete or streaming reasoning text. * @param props.running - whether this block is the streaming tail. + * @param props.usePresentation - live display-policy selector for this reasoning row. * @param props.t - conversation locale seat for status and Markdown actions. * @returns the reasoning disclosure. */ -export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) { +export const ReasoningRow = memo(function ReasoningRow({ text, running, usePresentation, t }: { + text: string + running: boolean + usePresentation: UsePresentation + t: ChatViewSlotProps['t'] +}) { const [expanded, setExpanded] = useState(false) const labels = useMemo(() => markdownLabels(t), [t]) + const toggle = useCallback(() => { setExpanded(value => !value) }, []) const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '') + const preview = usePresentation(policy => !expanded && summary !== '' + && (running || policy.settledReasoningPreview)) + const collapsedContent = useMemo(() => ( + <> + + + {summary} + + + ), [running, summary]) + const content = useMemo(() => expanded ? ( +
    + +
    + ) : undefined, [expanded, labels, running, text]) return (
    {running && {t('row.running')}} } + icon={THINK_ICON} title={t('message.think')} open={expanded} expandable expandOnRowClick - onToggle={() => { setExpanded(value => !value) }} - collapsedContent={( - <> - - - {summary} - - - )} + onToggle={toggle} + collapsedContent={collapsedContent} > -
    - -
    + {content}
    ) -} +}) diff --git a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts index f8f78000f1..9fa302992d 100644 --- a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts @@ -1,6 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PerformanceUsageMode } from '../../chat-settings.ts' +import type { ChatPresentationPolicy } from '../presentation-policy.ts' import { NS } from '../locale.ts' import { AssistantNodeView } from './AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx' @@ -14,10 +15,17 @@ import { TurnTailNodeView } from './TurnTailNodeView.tsx' /** * Register this package's business renderers behind the keyed Chat Node seat. + * Renderers whose output depends on the work-details mode receive the policy + * through their own registration; the seat and the other renderers do not. * @param ctx - owning UI Conversation context. * @param performanceUsage - live statistics detail preference. + * @param presentation - live presentation policy. */ -export function registerChatNodeRenderers(ctx: Context, performanceUsage: ObservableSnapshot): void { +export function registerChatNodeRenderers( + ctx: Context, + performanceUsage: ObservableSnapshot, + presentation: ObservableSnapshot, +): void { ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'user', locale: NS }, UserMessageNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( @@ -26,8 +34,12 @@ export function registerChatNodeRenderers(ctx: Context, performanceUsage: Observ { name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'system-prompt', locale: NS }, SystemPromptNodeView)) - ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( - { name: 'conversation.chat.node', key: 'assistant-step', locale: NS }, AssistantNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'assistant-step', + locale: NS, + inject: () => ({ hooks: { presentation } }), + }, AssistantNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'command', diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 5e811b7963..4086eb4d5b 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -9,17 +9,18 @@ import type { InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { createChatStore } from '../stores.ts' +import type { ChatPresentationPolicy } from '../presentation-policy.ts' import type { ToolCallId } from './store.ts' import type { ChatConversationViewNode, ChatNode, ChatNodeKind } from './chat-nodes.ts' import type { ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation, } from './snapshot.ts' import type { TurnProcessSpec } from './turn-process.ts' -import type { PerformanceUsageMode, TranscriptViewMode } from '../../chat-settings.ts' +import type { PerformanceUsageMode } from '../../chat-settings.ts' /** Selector hook over the current Conversation binding's Chat target. */ export type UseChat = SnapshotSelectorHook @@ -30,6 +31,13 @@ export type UseChatNode = KeyedSnapshotSelectorHook +/** + * Selector hook over the live presentation policy. Callers select one field or + * a derived conclusion, never the whole policy, so a mode change re-renders + * only components whose selected value changed. + */ +export type UsePresentation = SnapshotSelectorHook + /** Where in a file an open should land. */ export interface OpenFileOptions { /** 1-based line to reveal; absent = the file's beginning. */ @@ -105,6 +113,14 @@ export interface TurnProcessOwnerProps { setOpen(open: boolean): void } +/** Shared presentation policy source for renderers that depend on the work-details mode. */ +export interface PresentationInjected { + hooks: { + /** Live presentation policy derived from the accepted work-details mode. */ + presentation: ObservableSnapshot + } +} + /** Full props of one keyed Chat renderer. */ export type ChatNodeViewProps = PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'chat'> @@ -139,8 +155,8 @@ export interface PerformanceUsageInjected { /** Business callbacks injected into the Chat view. */ export interface ChatViewInjected { hooks: { - /** Persisted completed-Turn transcript presentation. */ - transcriptView: SnapshotStore + /** Live presentation policy derived from the accepted work-details mode. */ + presentation: ObservableSnapshot } keyedHooks: { /** Resolve the stable source for one Chat Node key. */ diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index 80f8654e7c..86833382c0 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -74,6 +74,8 @@ export interface ChatLocationNodeIndex { export interface ChatTurnProcessPresentation { readonly turn: number readonly spec: TurnProcessSpec + /** Whether the loaded window contains this Turn's `turn/start`; folding is decided per Turn on this fact. */ + readonly turnStarted: boolean readonly turnClosed: boolean readonly hasExternalProcess: boolean readonly compactAnswer: boolean 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 a49ae6031b..98a33a6c8b 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 @@ -16,6 +16,7 @@ function samePresentation( return left === right || (left !== undefined && right !== undefined && left.spec === right.spec && left.turn === right.turn + && left.turnStarted === right.turnStarted && left.turnClosed === right.turnClosed && left.hasExternalProcess === right.hasExternalProcess && left.compactAnswer === right.compactAnswer) @@ -64,6 +65,7 @@ function derivePresentation( return { turn, spec, + turnStarted: location.turn.start !== undefined, turnClosed: location.turn.status === 'closed', hasExternalProcess, compactAnswer, diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 9c2fe3a5d0..6bef23a2c5 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -30,11 +30,13 @@ export type { export type { ChatStoreState, ToolCallId, TurnProcessViewEntry } from './contract/store.ts' 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, - CommandRowOwnerProps, CommandRowProps, MessageImagesProps, OpenFileOptions, - TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, + CommandRowOwnerProps, CommandRowProps, MessageImagesProps, OpenFileOptions, PresentationInjected, + TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, UsePresentation, } from './contract/slots.ts' export type { TurnProcessSpec, diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 51d3adca00..a0440b4682 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -35,10 +35,11 @@ export const zh = { 'settings.links.description': '选择聊天中 HTTP(S) 链接的打开位置', 'settings.links.sidebar': '内置浏览器', 'settings.links.newTab': '浏览器新标签页', - 'settings.transcript.title': '对话显示', - 'settings.transcript.description': '控制已完成轮次的过程内容', - 'settings.transcript.normal': '标准', - 'settings.transcript.compact': '紧凑', + 'settings.transcript.title': '工作过程展示', + 'settings.transcript.description': '控制轮次和步骤的默认展开方式', + 'settings.transcript.compact': '简洁', + 'settings.transcript.detailed': '详细', + 'settings.transcript.expanded': '完全展开', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', 'message.extraBlock': '附加内容块', @@ -149,10 +150,11 @@ export const en = { 'settings.links.description': 'Choose where HTTP(S) links in chat open', 'settings.links.sidebar': 'Built-in browser', 'settings.links.newTab': 'New browser tab', - 'settings.transcript.title': 'Conversation display', - 'settings.transcript.description': 'Controls process content in completed turns', - 'settings.transcript.normal': 'Normal', + 'settings.transcript.title': 'Work details', + 'settings.transcript.description': 'Controls how turns and steps expand by default', 'settings.transcript.compact': 'Compact', + 'settings.transcript.detailed': 'Detailed', + 'settings.transcript.expanded': 'Expanded', 'fileOpen.title': 'Couldn’t open file', 'fileOpen.unknown': 'Couldn’t open this file', 'message.extraBlock': 'Extra content block', diff --git a/packages/client/ui-chat/src/client/presentation-policy.ts b/packages/client/ui-chat/src/client/presentation-policy.ts new file mode 100644 index 0000000000..9221afa711 --- /dev/null +++ b/packages/client/ui-chat/src/client/presentation-policy.ts @@ -0,0 +1,70 @@ +/** + * Runtime vocabulary derived from the persisted work-details mode. Renderers + * and seats select single fields of this policy; none of them compares the + * mode enum, so adding a mode changes only the table below. + */ + +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { TranscriptViewMode } from '../chat-settings.ts' + +/** Presentation capabilities that one work-details mode enables. */ +export interface ChatPresentationPolicy { + /** Mode this policy was derived from; for diagnostics, never for branching in renderers. */ + 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. + */ + readonly stepGrouping: 'collapsed' | 'none' + /** Whether a settled reasoning row previews its first line beside the Think title. */ + readonly settledReasoningPreview: boolean +} + +const POLICIES: Readonly> = { + compact: { + mode: 'compact', + foldCompletedTurns: true, + stepGrouping: 'collapsed', + settledReasoningPreview: false, + }, + detailed: { + mode: 'detailed', + foldCompletedTurns: true, + stepGrouping: 'collapsed', + settledReasoningPreview: true, + }, + expanded: { + mode: 'expanded', + foldCompletedTurns: true, + stepGrouping: 'none', + settledReasoningPreview: true, + }, +} + +/** + * Resolve the policy constant for one mode. The same mode always yields the + * same object, so selectors over a policy see stable identities. + * @param mode - persisted work-details mode. + * @returns the mode's presentation policy. + */ +export function presentationPolicyFor(mode: TranscriptViewMode): ChatPresentationPolicy { + return POLICIES[mode] +} + +/** + * Derive a policy observable from the mode observable without a subscription of + * its own: reads are a table lookup and change notifications are the mode's. + * @param mode - live work-details mode. + * @returns observable policy that changes exactly when the mode changes. + */ +export function derivePresentationPolicy( + mode: ObservableSnapshot, +): ObservableSnapshot { + return { + getSnapshot: () => POLICIES[mode.getSnapshot()], + subscribe: listener => mode.subscribe(listener), + } +} diff --git a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx index 7dc7df7363..2b29abacd9 100644 --- a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx +++ b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx @@ -1,18 +1,18 @@ -/** General Settings row for completed-Turn transcript presentation. */ +/** General Settings row for work-details presentation. */ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { TranscriptViewMode } from '../../chat-settings.ts' +import { TRANSCRIPT_VIEW_MODES, type TranscriptViewMode } from '../../chat-settings.ts' import type { ChatKey } from '../locale.ts' import { PreferenceRow } from './PreferenceRow.tsx' -/** Registration-side transcript preference face. */ +/** Registration-side work-details preference face. */ export interface TranscriptViewRowInjected { hooks: { - /** Persisted transcript preference bound as useTranscriptView. */ + /** Persisted work-details preference bound as useTranscriptView. */ transcriptView: SnapshotStore } - /** Change the completed-Turn transcript presentation. */ + /** Change the work-details presentation. */ setTranscriptView: (mode: TranscriptViewMode) => void } @@ -22,28 +22,26 @@ export type TranscriptViewRowProps = & PropsLocale<'chat'> & InjectFace -const OPTIONS: readonly { id: TranscriptViewMode; label: ChatKey }[] = [ - { id: 'normal', label: 'settings.transcript.normal' }, - { id: 'compact', label: 'settings.transcript.compact' }, -] +const LABELS = { + compact: 'settings.transcript.compact', + detailed: 'settings.transcript.detailed', + expanded: 'settings.transcript.expanded', +} as const satisfies Record /** - * Render the completed-Turn transcript mode selector. + * Render the work-details mode selector. * @param props - composed Settings slot props. * @returns the preference row. */ export function TranscriptViewRow({ useTranscriptView, setTranscriptView, t }: TranscriptViewRowProps) { const mode = useTranscriptView(value => value) - const selectedLabel = mode === 'normal' - ? 'settings.transcript.normal' - : 'settings.transcript.compact' return ( ({ id: option.id, label: t(option.label) }))} + selectedLabel={t(LABELS[mode])} + options={TRANSCRIPT_VIEW_MODES.map(id => ({ id, label: t(LABELS[id]) }))} onSelect={(value) => { setTranscriptView(value as TranscriptViewMode) }} /> ) diff --git a/packages/client/ui-chat/src/client/transcript-view.ts b/packages/client/ui-chat/src/client/transcript-view.ts index 5534a0139c..a97e17bda8 100644 --- a/packages/client/ui-chat/src/client/transcript-view.ts +++ b/packages/client/ui-chat/src/client/transcript-view.ts @@ -1,13 +1,13 @@ -/** Host-backed completed-Turn transcript presentation policy. */ +/** Host-backed work-details presentation policy. */ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { - DEFAULT_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, + DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, type ChatSettings, type TranscriptViewMode, } from '../chat-settings.ts' -/** Live transcript preference consumed by Chat and its Settings row. */ +/** Live work-details preference consumed by Chat and its Settings row. */ export class TranscriptViewPolicy { /** Reactive current mode; defaults to Compact before Host settings arrive. */ readonly mode: SnapshotStore = createSnapshotStore(DEFAULT_TRANSCRIPT_VIEW_MODE) @@ -22,7 +22,7 @@ export class TranscriptViewPolicy { /** * Publish and persist one explicit user choice. - * @param mode - Normal or Compact transcript presentation. + * @param mode - Compact, Detailed, or Expanded work details. */ setMode(mode: TranscriptViewMode): void { if (this.mode.getSnapshot() === mode) return @@ -33,7 +33,8 @@ export class TranscriptViewPolicy { /** Adopt the latest accepted Host section without writing it back. */ private adopt(): void { const section = this.host.getSnapshot().value - if (section === undefined || this.mode.getSnapshot() === section.transcriptView) return - this.mode.set(section.transcriptView) + if (section === undefined) return + const mode = section.transcriptView === LEGACY_TRANSCRIPT_VIEW_MODE ? 'detailed' : section.transcriptView + if (this.mode.getSnapshot() !== mode) this.mode.set(mode) } } diff --git a/packages/client/ui-chat/src/index.ts b/packages/client/ui-chat/src/index.ts index 6229537c23..cd0c89e5c5 100644 --- a/packages/client/ui-chat/src/index.ts +++ b/packages/client/ui-chat/src/index.ts @@ -5,7 +5,7 @@ import type {} from '@deepseek-ai/dsh-settings' import { CHAT_SETTINGS_NAMESPACE, ChatSettingsSchema } from './chat-settings.ts' export { - CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, + CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, TRANSCRIPT_VIEW_MODES, type ChatSettings, type TranscriptViewMode, } from './chat-settings.ts' diff --git a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx index 55a02209ed..77bc597140 100644 --- a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx +++ b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx @@ -2,6 +2,7 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { AssistantMarkdown, localPathMediaUrl } from '../src/client/chat/AssistantMarkdown.tsx' +import { useDetailedPresentation } from './presentation-fixture.client.ts' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../src/client/contract/slots.ts' import type { AssistantBlock } from '../src/client/contract/snapshot.ts' @@ -54,6 +55,7 @@ describe('AssistantMarkdown local-path images', () => { it('renders a local image path in closing prose through the same-origin API', () => { const { container } = render( { it('keeps non-absolute destinations inert', () => { const { container } = render( { const face = (row.inject as unknown as () => TranscriptViewRowInjected)() expect(face.hooks.transcriptView.getSnapshot()).toBe('compact') - face.setTranscriptView('normal') - expect(face.hooks.transcriptView.getSnapshot()).toBe('normal') - expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'normal') + face.setTranscriptView('detailed') + expect(face.hooks.transcriptView.getSnapshot()).toBe('detailed') + expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'detailed') b.chatSettings.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true, diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 013ed04786..f389653c72 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -16,6 +16,7 @@ import { UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' +import { useDetailedPresentation } from './presentation-fixture.client.ts' import { StatsPills } from '../src/client/chat/StatsPills.tsx' import { zh } from '../src/client/locale.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' @@ -1044,6 +1045,7 @@ describe('small branch tails', () => { it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => { const view = render( ()} /> case 'assistant-step': - return ()} /> + return ()} usePresentation={props.usePresentation} /> case 'command': return ( { openView('trajectory', callId) }, @@ -1636,7 +1637,7 @@ describe('ChatView', () => { expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') expect(processRow.getAttribute('hidden')).toBe('until-found') - act(() => { h.setTranscriptView('normal') }) + act(() => { h.setTranscriptView('detailed') }) expect(turnProcessControl(view.container)).toBeNull() expect(processRow.getAttribute('hidden')).toBeNull() diff --git a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx index 2a2883ebb3..bc759ada20 100644 --- a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx @@ -5,6 +5,7 @@ import { cleanup, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' +import { useDetailedPresentation } from './presentation-fixture.client.ts' import { zh } from '../src/client/locale.ts' const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) @@ -16,6 +17,7 @@ describe('tails', () => { it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => { const view = render( { expect(view.getByText(/未知内容块/)).toBeTruthy() const stopped = render( { // groups is layout noise (no text, no pulse, no interrupted marker). const empty = render( { ) expect(empty.container.firstChild).toBeNull() const blank = render( - , + , ) expect(blank.container.firstChild).toBeNull() }) 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 8bfb4014b6..d97056af81 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 @@ -5,6 +5,7 @@ import { cleanup, render } from '@testing-library/react' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' +import { useDetailedPresentation } from './presentation-fixture.client.ts' import { StatsPills } from '../src/client/chat/StatsPills.tsx' import { zh } from '../src/client/locale.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' @@ -22,6 +23,7 @@ describe('render branch tails', () => { it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => { const view = render( { it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => { const view = render( { const calls: MessageImagesRenderOwner[] = [] const view = render( { const calls: MessageImagesRenderOwner[] = [] const view = render( { const calls: MessageImagesRenderOwner[] = [] const view = render( selector(presentationPolicyFor('detailed')) 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 dedfa9198f..e1f399208e 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -5,6 +5,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' 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' afterEach(() => { cleanup() @@ -20,16 +21,22 @@ describe('ReasoningRow', () => { ])('starts collapsed and preserves manual expansion when $kind arrives', (nextBlock) => { const reasoning = { kind: 'reasoning' as const, text: 'Inspect the session\nCheck persistence' } const view = render( - , + , ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') fireEvent.click(view.getByText('思考')) view.rerender( - , + , ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') view.rerender( - , + , ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') expect(view.getByText(/Check persistence/)).toBeTruthy() @@ -40,6 +47,7 @@ describe('ReasoningRow', () => { it('follows the latest streaming line, then restores the settled first line', () => { const view = render( { view.rerender( { view.rerender( { it('expands from either Think or the reasoning summary', () => { const view = render( { ])('strips double-asterisk markers from the $label summary and renders body emphasis', ({ text, streaming }) => { const view = render( { .join('\n\n') + '\n\nReasoning body.' const view = render( { const first = '## Investigation\n\n**Check persistence**\n\n' const view = render( { const text = first + Array.from({ length: 8 }, (_, index) => `Paragraph ${index}.`).join('\n\n') view.rerender( { it('expanded Think drops the inline summary and renders prose without an IN card', () => { const view = render( { it('anchors the sticky-header selector: only an open Think row nests the disclosure row under data-expanded and data-open', () => { const view = render( { current = () => policy.mode.getSnapshot() expect(policy.mode.getSnapshot()).toBe('compact') - policy.setMode('normal') - expect(policy.mode.getSnapshot()).toBe('normal') - expect(observed).toEqual(['transcriptView=normal:normal']) - expect(host.set).toHaveBeenCalledWith('transcriptView', 'normal') + policy.setMode('detailed') + expect(policy.mode.getSnapshot()).toBe('detailed') + expect(observed).toEqual(['transcriptView=detailed:detailed']) + expect(host.set).toHaveBeenCalledWith('transcriptView', 'detailed') }) - it('adopts Host state and ignores identical writes', () => { + it('adopts Host state, reads the legacy value as Detailed, and ignores identical writes', () => { const host = stubSettingsScope() const policy = new TranscriptViewPolicy(host.scope) host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) - expect(policy.mode.getSnapshot()).toBe('normal') - policy.setMode('normal') + expect(policy.mode.getSnapshot()).toBe('detailed') + policy.setMode('detailed') expect(host.set).not.toHaveBeenCalled() host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 }) @@ -41,7 +41,7 @@ describe('TranscriptViewPolicy', () => { it('adopts an accepted section standing at construction', () => { const host = stubSettingsScope() - host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) - expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('normal') + host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'expanded', performanceUsage: 'detailed' }, revision: 1, writable: true }) + expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('expanded') }) }) 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 82c928ac97..a5887a4e1e 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 @@ -9,7 +9,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx' import { PerformanceUsageRow } from '../src/client/settings/PerformanceUsageRow.tsx' -import type { LinkOpening, PerformanceUsageMode } from '../src/chat-settings.ts' +import type { LinkOpening, PerformanceUsageMode, TranscriptViewMode } from '../src/chat-settings.ts' import { LinkOpeningRow } from '../src/client/settings/LinkOpeningRow.tsx' import { en, zh } from '../src/client/locale.ts' @@ -34,9 +34,9 @@ function noPendingInteraction() { // The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] -function mount(mode: 'normal' | 'compact' = 'compact', dictionary: typeof en | typeof zh = en) { - const source = createSnapshotStore(mode) - const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) }) +function mount(mode: TranscriptViewMode = 'compact', dictionary: typeof en | typeof zh = en) { + const source = createSnapshotStore(mode) + const setTranscriptView = vi.fn((next: TranscriptViewMode) => { source.set(next) }) const props: TranscriptViewRowProps = { usePanelInfo: selector => selector({ activePanelId: null }), useSessions: emptySessions(), diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 464ff55e17..c164dc8535 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -286,7 +286,7 @@ export const zh = { 'row.inspect': '查看', 'tool.title.search': '搜索', 'tool.title.read': '读取', - 'tool.title.bash': 'Bash', + 'tool.title.bash': '运行命令', 'tool.title.write': '写入', 'tool.title.edit': '编辑', 'tool.title.code': '代码', @@ -295,10 +295,10 @@ export const zh = { 'tool.title.runCordis': '运行 Cordis 插件', 'tool.title.stopCordis': '停止 Cordis 插件', 'tool.title.removeCordis': '移除 Cordis 插件', - 'tool.title.pwsh': 'Pwsh', + 'tool.title.pwsh': '运行命令', 'tool.title.readImage': '读取图片', - 'tool.title.grep': 'Grep', - 'tool.title.glob': 'Glob', + 'tool.title.grep': '搜索文件内容', + 'tool.title.glob': '查找文件', 'tool.title.webSearch': '网页搜索', 'tool.title.webFetch': '网页获取', 'tool.autoReviewRejected': 'Auto review 已拒绝', diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index dde0b3eec5..5e3699e8e5 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: 12fe2648e383189198ff972709819e6f1eb4c02f -README.zh.md: 3c6f62fee22c5ad7e750c860724f60ca6dd9fbcf +README.md: 2606bd0dd5895319c9887b15497b2ca6b7a045ce +README.zh.md: cdf816549ea6cf12382b819a493ded7f61ecddd9 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 12fe2648e3..2606bd0dd5 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -46,7 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | -| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. | +| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | | `Modal` | Centered dialog over a page mask. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | | `OnboardingSurface` | First-run stage that holds the application root inert. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 3c6f62fee2..cdf816549e 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -46,7 +46,7 @@ kind: "package-library" | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | -| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。 | +| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | | `Modal` | 页面遮罩之上的居中对话框。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | | `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 | diff --git a/packages/client/ui-primitives/src/DisclosureRow.tsx b/packages/client/ui-primitives/src/DisclosureRow.tsx index f79b7e4720..5644772bc0 100644 --- a/packages/client/ui-primitives/src/DisclosureRow.tsx +++ b/packages/client/ui-primitives/src/DisclosureRow.tsx @@ -1,4 +1,4 @@ -import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { memo, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { IconChevronDownOutlineRegular } from './icons/index.tsx' import css from './DisclosureRow.module.css' @@ -27,10 +27,11 @@ export interface DisclosureRowProps { /** * Render one disclosure header and its controlled expanded content. + * Shallow prop comparison requires stable callbacks and React nodes to skip unchanged renders. * @param props - Visual content, controlled state, and interaction policy. * @returns the disclosure row. */ -export function DisclosureRow({ +export const DisclosureRow = memo(function DisclosureRow({ icon, title, open, @@ -101,4 +102,4 @@ export function DisclosureRow({ {open && children}
    ) -} +}) diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts index d7a9b94228..ebd0910324 100644 --- a/packages/client/ui-skill/src/client/locales.ts +++ b/packages/client/ui-skill/src/client/locales.ts @@ -5,7 +5,7 @@ export const NS = 'skill' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'row.title': 'Skill', + 'row.title': '加载技能', 'row.running': '正在加载 skill', 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', From e24805a53616e5624fa4652b8dae22e7750f7a6e Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 21:56:54 +0800 Subject: [PATCH 141/196] feat(ui-chat): refine reasoning previews and disclosure cues --- .../client/chat/GenericCommandCard.module.css | 7 +++++ .../src/client/chat/ReasoningRow.module.css | 11 ++----- .../ui-chat/src/client/chat/ReasoningRow.tsx | 29 ++++++++++++++----- .../ui-primitives/src/DisclosureRow.tsx | 4 +-- .../client/tool/components/ToolRow.module.css | 11 +++++++ .../tool/toolviews/bash-sample.module.css | 7 +++++ .../src/client/tool/toolviews/bash-sample.tsx | 6 ++-- 7 files changed, 55 insertions(+), 20 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css index 513a826c46..eddba8e53d 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css @@ -39,6 +39,7 @@ .title { font-weight: 400; + transition: color 100ms ease; } .separator { @@ -59,6 +60,12 @@ line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; + transition: color 100ms ease; +} + +.row:hover .title, +.row:hover .summary:not([data-error]) { + color: var(--dsw-alias-label-primary); } .summary[data-error], diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css index 5e98217171..f59c04e404 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css @@ -83,17 +83,12 @@ text-overflow: ellipsis; } -.summary[data-follow-end] { - display: flex; - justify-content: flex-end; +.summary[data-streaming] { + mask-image: linear-gradient(to right, black calc(100% - 48px), transparent); } -.summary[data-follow-end] .summaryText { - flex: 0 0 auto; - width: max-content; - min-width: 100%; +.summary[data-streaming] .summaryText { overflow: visible; - text-align: start; text-overflow: clip; } diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index 2d01eeb7fd..53d1ab2a55 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -13,17 +13,29 @@ function firstLine(text: string): string { return newline === -1 ? text : text.slice(0, newline) } -function latestLine(text: string): string { - const visible = text.trimEnd() - const newline = visible.lastIndexOf('\n') - return newline === -1 ? visible : visible.slice(newline + 1) +function latestCompletedParagraphFirstLine(text: string): string { + let summary = '' + let paragraphStart = 0 + const separator = /\r?\n[\t ]*\r?\n/g + while (true) { + const nextParagraph = separator.exec(text) + const paragraphEnd = nextParagraph === null ? text.length + : nextParagraph.index + nextParagraph[0].indexOf('\n') + const newline = text.indexOf('\n', paragraphStart) + if (newline !== -1 && newline <= paragraphEnd) { + const candidate = text.slice(paragraphStart, newline).trim() + if (candidate !== '') summary = candidate + } + if (nextParagraph === null) return summary + paragraphStart = nextParagraph.index + nextParagraph[0].length + } } /** * Render one assistant reasoning block collapsed until the reader opens it. The * collapsed summary omits double-asterisk markers; expanded content renders - * the complete Markdown with secondary typography. A streaming row always - * previews its latest line. Mode changes toggle CSS display without unmounting + * the complete Markdown with secondary typography. A streaming preview advances + * when a paragraph's first line completes. Mode changes toggle CSS display without unmounting * collapsed summaries. * @param props.text - complete or streaming reasoning text. * @param props.running - whether this block is the streaming tail. @@ -40,13 +52,14 @@ export const ReasoningRow = memo(function ReasoningRow({ text, running, usePrese const [expanded, setExpanded] = useState(false) const labels = useMemo(() => markdownLabels(t), [t]) const toggle = useCallback(() => { setExpanded(value => !value) }, []) - const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '') + const summaryText = running ? latestCompletedParagraphFirstLine(text) : firstLine(text) + const summary = useMemo(() => summaryText.replaceAll('**', ''), [summaryText]) const preview = usePresentation(policy => !expanded && summary !== '' && (running || policy.settledReasoningPreview)) const collapsedContent = useMemo(() => ( <> - + {summary} diff --git a/packages/client/ui-primitives/src/DisclosureRow.tsx b/packages/client/ui-primitives/src/DisclosureRow.tsx index 5644772bc0..c2a59ce0c0 100644 --- a/packages/client/ui-primitives/src/DisclosureRow.tsx +++ b/packages/client/ui-primitives/src/DisclosureRow.tsx @@ -1,6 +1,6 @@ import { memo, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' -import { IconChevronDownOutlineRegular } from './icons/index.tsx' +import { IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from './icons/index.tsx' import css from './DisclosureRow.module.css' /** Shared 24px disclosure chrome for compact flow rows. */ @@ -67,7 +67,7 @@ export const DisclosureRow = memo(function DisclosureRow({ ) : icon const leading = open - ? + ? : collapsedLeading return ( 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 520e800a3a..73efca2964 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 @@ -67,6 +67,7 @@ .title { font-weight: 400; + transition: color 100ms ease; } .sep { @@ -87,6 +88,7 @@ font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); + transition: color 100ms ease; } /* Trailing summary fragment kept out of .summary's ellipsis, for a count whose @@ -101,6 +103,13 @@ font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); + transition: color 100ms ease; +} + +.row:hover .title, +.row:hover .summary:not(.errorSummary), +.row:hover .summarySuffix { + color: var(--dsw-alias-label-primary); } /* Diff-row +/- totals: the code font, matching the diff body the numbers @@ -143,8 +152,10 @@ text-decoration-thickness: 1px; text-underline-offset: 3px; cursor: pointer; + transition: color 100ms ease; } +.row:hover .fileLink, .fileLink:hover { color: var(--dsw-alias-label-primary); text-decoration-color: currentColor; 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 ab8acd692c..e45e761ab3 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,6 +172,7 @@ font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-secondary); + transition: color 100ms ease; } .sep { @@ -192,6 +193,12 @@ font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); + transition: color 100ms ease; +} + +.root:hover .title, +.root:hover .summary:not(.errorSummary) { + color: var(--dsw-alias-label-primary); } /* Error row's collapsed summary: the failure's first line in the error color. */ 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 7b1f8653c5..7c52b380e1 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 @@ -2,7 +2,8 @@ import { useMemo, useState, type KeyboardEvent } from 'react' import type { Context } from '@deepseek-ai/cordis' import clsx from 'clsx' import { - IconApiOutlineRegular, IconChevronDownOutlineRegular, IconInspectOutlineRegular, TerminalBlock, + IconApiOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconInspectOutlineRegular, + TerminalBlock, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' @@ -25,6 +26,7 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null { switch (state) { case 'running': return t('bash.running') case 'error': return t('bash.failed') + case 'stopped': return t('bash.stopped') default: return null } } @@ -71,7 +73,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: } const businessIcon = const leading = open - ? + ? : expandable ? ( <> From 007241d2a8e242c09ecf9a8a0434f071f9323a82 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 22:08:38 +0800 Subject: [PATCH 142/196] feat(ui-chat): add text shimmer to running rows --- .../client/chat/GenericCommandCard.module.css | 32 ----------------- .../src/client/chat/GenericCommandCard.tsx | 8 +++-- .../ui-primitives/src/DisclosureRow.tsx | 6 +++- .../ui-primitives/src/TextShimmer.module.css | 27 ++++++++++++++ .../client/ui-primitives/src/TextShimmer.tsx | 23 ++++++++++++ packages/client/ui-primitives/src/index.ts | 2 ++ .../client/tool/components/ToolRow.module.css | 36 ++----------------- .../src/client/tool/components/ToolRow.tsx | 10 +++--- .../tool/toolviews/bash-sample.module.css | 25 ------------- .../src/client/tool/toolviews/bash-sample.tsx | 7 ++-- 10 files changed, 76 insertions(+), 100 deletions(-) create mode 100644 packages/client/ui-primitives/src/TextShimmer.module.css create mode 100644 packages/client/ui-primitives/src/TextShimmer.tsx diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css index eddba8e53d..a4bb6ef0cc 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css @@ -3,32 +3,6 @@ flex-direction: column; } -.row { - position: relative; - overflow: hidden; -} - -.root[data-state='running'] .row::after { - content: ''; - position: absolute; - inset-block: 0; - left: 0; - width: 300px; - background: linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, - transparent 100% - ); - animation: dsh-command-row-sweep 2.6s ease-out infinite; - pointer-events: none; -} - -@keyframes dsh-command-row-sweep { - 0% { left: -300px; } - 90%, 100% { left: 100%; } -} - .leading { flex-shrink: 0; } @@ -85,9 +59,3 @@ font: var(--dsw-font-markdown-code-block-small); white-space: pre-wrap; } - -@media (prefers-reduced-motion: reduce) { - .root[data-state='running'] .row::after { - animation: none; - } -} diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx index 2864836301..0374637e17 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts' -import { DisclosureRow, IconApiOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconApiOutlineRegular, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives' import a11yCss from './accessibility.module.css' import css from './GenericCommandCard.module.css' @@ -29,6 +29,7 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa // command name. const title = node.name ?? t('command.title') const state = stateOf(node.outcome) + const running = state === 'running' const body = text !== undefined && text.includes('\n') ? text : null const open = expanded && body !== null return ( @@ -42,6 +43,7 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa chevronClassName={css.chevron} icon={} title={title} + running={running} open={open} expandable={body !== null} expandOnRowClick @@ -50,7 +52,9 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa collapsedContent={( <> - {summary} + + {summary} + )} > diff --git a/packages/client/ui-primitives/src/DisclosureRow.tsx b/packages/client/ui-primitives/src/DisclosureRow.tsx index c2a59ce0c0..a4fa7a2124 100644 --- a/packages/client/ui-primitives/src/DisclosureRow.tsx +++ b/packages/client/ui-primitives/src/DisclosureRow.tsx @@ -1,6 +1,7 @@ import { memo, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from './icons/index.tsx' +import { TextShimmer } from './TextShimmer.tsx' import css from './DisclosureRow.module.css' /** Shared 24px disclosure chrome for compact flow rows. */ @@ -10,6 +11,8 @@ export interface DisclosureRowProps { open: boolean expandable: boolean onToggle: () => void + /** Animate the title while its owning operation is running. */ + running?: boolean | undefined /** Makes the complete title row the disclosure target. */ expandOnRowClick?: boolean | undefined /** Replaces the collapsed icon with a chevron while the row is hovered. */ @@ -37,6 +40,7 @@ export const DisclosureRow = memo(function DisclosureRow({ open, expandable, onToggle, + running = false, expandOnRowClick = false, previewChevron = expandable, keepContentWhenOpen = false, @@ -96,7 +100,7 @@ export const DisclosureRow = memo(function DisclosureRow({ {leading} )} - {title} + {title} {(keepContentWhenOpen || !open) && collapsedContent} {open && children} diff --git a/packages/client/ui-primitives/src/TextShimmer.module.css b/packages/client/ui-primitives/src/TextShimmer.module.css new file mode 100644 index 0000000000..bdc2de9a44 --- /dev/null +++ b/packages/client/ui-primitives/src/TextShimmer.module.css @@ -0,0 +1,27 @@ +.root[data-text-shimmer] { + background-image: linear-gradient( + 90deg, + currentColor calc(50% - var(--dsh-text-shimmer-spread)), + color-mix(in oklab, currentColor 50%, transparent), + currentColor calc(50% + var(--dsh-text-shimmer-spread)) + ); + background-position: 100% center; + background-repeat: no-repeat; + background-size: 250% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: dsh-text-shimmer 1.5s cubic-bezier(0.33, 0, 0.67, 1) infinite; +} + +@keyframes dsh-text-shimmer { + 66.6667%, 100% { background-position: 0% center; } +} + +@media (prefers-reduced-motion: reduce) { + .root[data-text-shimmer] { + background-image: none; + -webkit-text-fill-color: currentColor; + animation: none; + } +} diff --git a/packages/client/ui-primitives/src/TextShimmer.tsx b/packages/client/ui-primitives/src/TextShimmer.tsx new file mode 100644 index 0000000000..8249626435 --- /dev/null +++ b/packages/client/ui-primitives/src/TextShimmer.tsx @@ -0,0 +1,23 @@ +/** Text-only activity animation with a stable span across lifecycle changes. */ +import { memo, useMemo, type CSSProperties } from 'react' +import clsx from 'clsx' +import css from './TextShimmer.module.css' + +/** Text and activity supplied by the owning row. */ +export interface TextShimmerProps { + children: string + active: boolean + className?: string | undefined +} + +/** + * Render text with an optional moving highlight; inactive text keeps the same node. + * @param props - localized text, running state, and owner styling. + * @returns the retained text span. + */ +export const TextShimmer = memo(function TextShimmer({ children, active, className }: TextShimmerProps) { + const style = useMemo(() => ({ + '--dsh-text-shimmer-spread': `${children.length * 8}px`, + } as CSSProperties), [children.length]) + return {children} +}) diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 25ba54ea24..551ced88ea 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -6,6 +6,8 @@ export { StateDot } from './StateDot.tsx' export type { StateDotState } from './StateDot.tsx' export { DisclosureRow } from './DisclosureRow.tsx' export type { DisclosureRowProps } from './DisclosureRow.tsx' +export { TextShimmer } from './TextShimmer.tsx' +export type { TextShimmerProps } from './TextShimmer.tsx' export { Button } from './Button.tsx' export type { ButtonVariant } from './Button.tsx' export { Pill } from './Pill.tsx' 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 73efca2964..3bf04ae4ac 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 @@ -11,37 +11,6 @@ flex-direction: column; } -.row { - position: relative; /* sweep-glare overlay anchor */ - overflow: hidden; -} - -/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band — - theme background at 60% — glides over the row content from off-left to - off-right, washing glyphs and icon toward the background as it passes. - ease-out with a 10% end hold gives each pass a beat before the next. */ -.root[data-state='running'] .row::after { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - width: 300px; - background: linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, - transparent 100% - ); - animation: dsh-tool-row-sweep 2.6s ease-out infinite; - pointer-events: none; -} - -@keyframes dsh-tool-row-sweep { - 0% { left: -300px; } - 90%, 100% { left: 100%; } -} - .leading { flex-shrink: 0; } @@ -371,8 +340,9 @@ border: 0.5px solid var(--dsw-alias-border-l1); } -/* Visually hidden run-state label for assistive technology: the running sweep - and settlement summary tones are color-only. */ +/* Visually hidden run-state label for assistive technology: the business icon + and animated text treatment do not name the state, so this text carries the + running/failed/interrupted state to a screen reader. */ .visuallyHidden { position: absolute; width: 1px; 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 3799370527..874beb9252 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -2,7 +2,7 @@ import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode import clsx from 'clsx' import { CodeBlock, DiffBlock, DisclosureRow, IconInspectOutlineRegular, ReadBlock, SearchBlock, - TerminalBlock, WebBlock, + TerminalBlock, TextShimmer, WebBlock, diffTotals, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRenderSlots, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' @@ -156,6 +156,7 @@ export function ToolRow({ [card, inputRaw, open, variant], ) const status = stateStatus(state, t) + const running = state === 'running' const normalSummary = terminalBody?.description ?? (open ? detailsBody?.expandedSummary ?? summary : summary) // A failure keeps its first result line when available and otherwise turns // the ordinary summary red. An interruption turns the tool-owned summary @@ -202,6 +203,7 @@ export function ToolRow({ chevronClassName={css.chevron} icon={icon} title={title} + running={running} open={open} expandable={expandable} expandOnRowClick @@ -219,7 +221,7 @@ export function ToolRow({ onClick={openFile} onKeyDown={fileLinkKeyDown} > - {summaryText} + {summaryText} ) : ( - {summaryText} + {summaryText} )} {suffix !== null && ( - {suffix} + {suffix} )} )} 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 e45e761ab3..be9b547ce6 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 @@ -85,37 +85,12 @@ } .root { - position: relative; /* sweep-glare overlay anchor */ - overflow: hidden; display: flex; align-items: center; height: calc(24px + var(--dsh-content-font-delta, 0px)); min-width: 0; } -/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ -.root[data-state='running']::after { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - width: 300px; - background: linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, - transparent 100% - ); - animation: dsh-bash-row-sweep 2.6s ease-out infinite; - pointer-events: none; -} - -@keyframes dsh-bash-row-sweep { - 0% { left: -300px; } - 90%, 100% { left: 100%; } -} - .leading { position: relative; /* .chevronHover overlay anchor */ flex: none; 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 7c52b380e1..37b1d355d0 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 @@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import clsx from 'clsx' import { IconApiOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconInspectOutlineRegular, - TerminalBlock, + TerminalBlock, TextShimmer, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' @@ -63,6 +63,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: const settlementLine = state === 'error' ? model.errorSummary ?? normalSummary : state === 'stopped' ? t('bash.stopped') : null + const running = state === 'running' const toggleExpand = () => { setExpanded(v => !v) } @@ -98,14 +99,14 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: > {leading} {status !== null && {status}} - {t(model.titleKey)} + {t(model.titleKey)} - {settlementLine ?? normalSummary} + {settlementLine ?? normalSummary} {open && ( From b6f99fad556a1bfe7d8fe106e91b58a162ba532c Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 22:19:45 +0800 Subject: [PATCH 143/196] 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 144/196] 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 145/196] 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 146/196] 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 147/196] 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 148/196] 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 149/196] 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 150/196] 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 151/196] 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 152/196] 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 (