mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge remote-tracking branch 'origin/master' into fix/derive-shipped-preset-root-per-composition
This commit is contained in:
+6
@@ -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-08-23-client-derived-tool-presentation.md
|
||||
2026-08-23-client-derived-tool-presentation.md: 5598c1fbc6073a71f63a20274cd5a791b6b5e6b3
|
||||
2026-08-23-client-derived-tool-presentation.zh.md: 5a87433377af58b1ca9d378b76393dada4ca4a1e
|
||||
+705
@@ -0,0 +1,705 @@
|
||||
# Agent Note: Client-Derived Presentation from Raw Session Tool Events
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-23-client-derived-tool-presentation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Session history is a durable journal interface, while tool cards are Client presentation. Computing card views during `page` or `follow` would couple history reads to the Tools registry, Agent presets, restored scopes, presenter execution, and transient UI types.
|
||||
|
||||
A `tool/result` does not repeat the tool name or arguments. Host-side result presentation therefore requires either a call index or a backward scan by `callId`; repeated scans over a tool-dense page can approach quadratic work because `maxMessages` does not directly bound the event count.
|
||||
|
||||
Host projection would also duplicate structured data. Read, diff, search, and web results already persist bounded facts in `tool/result.data.meta`; another view object increases Remote payload size and Client decoding without adding durable meaning.
|
||||
|
||||
The Client already owns a complete tool-presentation entry point. `ui-chat` assembles `tool/call`, `tool/result`, and Code Dispatch events into stable `ToolCallBlock` values. `ui-tool` owns the recursive call tree, the `tool.call.toolview` keyed slot dispatched by tool name, the Generic fallback, card models, and details output. A business Client plugin can register a renderer for its own tool names.
|
||||
|
||||
Splitting presentation between Host presenters and Client keyed renderers creates two interpretations of the same event. The keyed renderer is the Web extension point, so an intermediate Host view provides no independent Web capability.
|
||||
|
||||
`ToolDefinition.presentCall` and `presentResult` remain useful Host APIs even though ACP is automation-only and the repository has no production TUI consumer. Removing their definitions is a separate decision from keeping Session reads independent of presentation.
|
||||
|
||||
The required result is one raw Session journal and one Client presentation owner without visual degradation or incidental enhancement. Specialized cards, interactions, and Code Dispatch topology remain stable while the transport stops carrying transient views.
|
||||
|
||||
## Decision
|
||||
|
||||
The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view.
|
||||
|
||||
The Client Conversation layer continues to own tool call/result identity, pairing, lifecycle, Code Dispatch topology, and stable Chat Nodes. It does not interpret individual tool names or produce terminal, diff, read, search, or web component props.
|
||||
|
||||
Client `ui-tool` continues to own card models and concrete renderers. Each card model directly reads the tool name, raw arguments, result content, error, durable metadata, Session cwd, and Host home from `ToolCallBlock`, and produces the same component props as the current page.
|
||||
|
||||
The Client has no second presenter registry. Tool-name dispatch uses only the existing `tool.call.toolview` keyed slot. Pure Client card-model helpers are renderer implementation details, not a Cordis service, public registry, or wire DTO.
|
||||
|
||||
The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCallView`, `ToolResultView`, and existing tool presenter implementations remain. The Session Controller does not invoke them, and the Client does not import or consume them. A future non-Client consumer is outside this decision.
|
||||
|
||||
`ToolOutputDefinition.presentationMeta` and durable `tool/result.data.meta` remain. They carry execution-result facts required by existing specialized cards that the model-visible result text cannot represent losslessly. The Client validates and consumes `meta` directly rather than requiring the Host to convert it into a view during history reads.
|
||||
|
||||
### Goals and non-goals
|
||||
|
||||
| Category | Decision |
|
||||
|---|---|
|
||||
| Absent | `SessionEventEntry.view`, `SessionToolView`, and `SessionToolCallView` |
|
||||
| Absent | `viewFor`, `backscanArgs`, `parseToolCall`, `jsonView`, and presenter-scope lookup from `history.ts` |
|
||||
| Absent | `openCalls` and fallback event scans used only for follow presentation |
|
||||
| Absent | the Client Session's parallel `views` array, Conversation input `view`, and Tool block `callView`/`resultView` |
|
||||
| Derived | terminal, diff, read, search, and web card models read raw blocks and metadata |
|
||||
| Derived | Deliverables reads successful mutation names and arguments |
|
||||
| Retained | Host `ToolDefinition.presentCall`/`presentResult` APIs, types, implementations, and direct tests |
|
||||
| Retained | `output.presentationMeta` and durable `tool/result.data.meta` |
|
||||
| Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology |
|
||||
| Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure |
|
||||
| Forbidden | a new Client presenter service, parallel registry, or wire renderer id |
|
||||
| Forbidden | new cards, visual redesign, interaction redesign, or Code Dispatch rich-card enhancements |
|
||||
| Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field |
|
||||
|
||||
## Terminology
|
||||
|
||||
**Raw Session event** means a `SessionEvent` fact from the durable log, including the `name` and raw `arguments` string on `tool/call`, and the `content`, `isError`, structured error, and optional `meta` on `tool/result`.
|
||||
|
||||
**Durable metadata** means the JSON value produced by `ToolOutputDefinition.presentationMeta` after a tool succeeds and stored in `tool/result.data.meta`. It is part of the result facts, not a pre-laid-out React or card DTO.
|
||||
|
||||
**Host tool view** means the `ToolCallView` or `ToolResultView` returned by `ToolDefinition.presentCall` or `presentResult`. Session Remote does not transport it.
|
||||
|
||||
**Client card model** means the pure props data under `ui-tool/src/client/tool/models/` consumed directly by `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, or `ToolRow`.
|
||||
|
||||
**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, status dots, and ordinary IN/OUT text remain part of the generic tool row.
|
||||
|
||||
**Equivalent** means that the same supported input produces the user-visible result and interaction pinned by the existing component, assembly, and browser evidence. It does not require the same intermediate TypeScript types or internal calls.
|
||||
|
||||
**No enhancement** means that this decision does not give an input pinned to Generic fallback a new specialized card or expand an existing card's data or interactions.
|
||||
|
||||
## Architecture and Ownership
|
||||
|
||||
### Tool execution and persistence
|
||||
|
||||
1. A tool registers `output.schema`, `output.render`, and optional `output.presentationMeta`.
|
||||
2. Successful execution produces a canonical JSON value.
|
||||
3. The Tools runtime snapshots, schema-validates, and freezes the value.
|
||||
4. `output.render(args, value)` produces model-visible `ContentBlock[]`.
|
||||
5. When a top-level call declares `output.presentationMeta`, the runtime also produces JSON-safe metadata.
|
||||
6. The agent loop writes the model-visible result and metadata into a `tool/result` Session event.
|
||||
7. The Session log does not store `ToolCallView` or `ToolResultView`.
|
||||
|
||||
### Host journal reads
|
||||
|
||||
1. `session.page` obtains attached or persisted events.
|
||||
2. `paginate()` cuts pages on append-origin user/assistant message boundaries.
|
||||
3. A tail page obtains its baseline from the registered projection snapshot/restore path.
|
||||
4. Every page entry contains only `{event}`.
|
||||
5. `session.follow` establishes its listener before catch-up reads, emits the opening cursor, and then streams contiguous `{event}` frames.
|
||||
6. Neither path resolves a preset or Tools scope for presentation, parses tool arguments, invokes presenters, or indexes calls.
|
||||
|
||||
### Client data and presentation
|
||||
|
||||
1. The Client Session stores one contiguous raw event window.
|
||||
2. `SessionEventSource` publishes `SessionEventEntry` values containing only events.
|
||||
3. `ui-conversation` folds each event without a presentation companion.
|
||||
4. The Chat and Trajectory Tool Definitions pair top-level calls and results by callId and assemble Code Dispatch subtrees.
|
||||
5. `RunningToolCall` and `ToolResultNode` retain raw facts, metadata, and existing parent identity.
|
||||
6. `ToolCallTree` dispatches `tool.call.toolview` by wire tool name.
|
||||
7. `ui-tool` derives card component props from the block at the render site.
|
||||
|
||||
### Production consumer audit
|
||||
|
||||
| Object | Producer | Production consumer | Decision |
|
||||
|---|---|---|---|
|
||||
| `presentCall`/`presentResult` | Host tools | non-Client callers, if any | retained outside Session Remote |
|
||||
| `SessionEventEntry.view` | none | none | absent from the wire |
|
||||
| `callView`/`resultView` | none | none | absent from the Client model |
|
||||
| `presentationMeta` | Tools runtime | `tool/result`, Client card models, and Host presenters | retained durable input |
|
||||
| fixture presenter mirror | none | none | fixtures send raw metadata |
|
||||
|
||||
ACP does not consume a Session tool view or map Host render intent. The repository has no production TUI consumer. Host presenters remain available without making Session Remote their transport.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```text
|
||||
Tool execute
|
||||
-> canonical value
|
||||
-> output.render(args, value)
|
||||
-> model-visible result content
|
||||
-> output.presentationMeta(args, value), when declared
|
||||
-> durable tool/result event
|
||||
|
||||
Session page/follow
|
||||
-> raw Session event envelope
|
||||
-> no tool lookup
|
||||
-> no preset lookup for presentation
|
||||
-> no call backscan
|
||||
-> no render-intent serialization
|
||||
|
||||
Client SessionEventSource
|
||||
-> Conversation Tool Definition
|
||||
-> root call/result pairing + Code Dispatch topology
|
||||
-> ToolCallBlock(name, argsRaw, content, error, meta)
|
||||
-> tool.call.toolview keyed dispatch
|
||||
-> Client card model
|
||||
-> existing React component
|
||||
```
|
||||
|
||||
This path retains one durable metadata projection because it runs while the canonical result is still in memory. It removes the second presentation projection performed while reading history.
|
||||
|
||||
### Layer responsibilities
|
||||
|
||||
| Layer | Owns | Does not own |
|
||||
|---|---|---|
|
||||
| Tools runtime | execution, canonical value, model text, replayable metadata | Web card selection and component props |
|
||||
| Session log | durable facts, ordering, replay | transient card DTOs |
|
||||
| Session Controller | addressing, authority, cold reads, pagination, follow, projection baseline | tool lookup, presenters, presentation scope |
|
||||
| Client Session | Remote journal lifecycle and contiguous window | tool meaning and card types |
|
||||
| Conversation Tool Definition | call/result pairing, lifecycle, root/subcall topology | mapping a tool name to a component |
|
||||
| `ui-tool` | card models, Generic fallback, Chat/Details presentation | Session pagination and the Host registry |
|
||||
| Business Client plugin | keyed renderer for its own tool name | root/subcall assembly and a global registry |
|
||||
| `ui-deliverables` | produced paths for current first-party mutations | UI cards or Host render intent |
|
||||
|
||||
## Remote and Durable Data Contracts
|
||||
|
||||
### `SessionEventEntry`
|
||||
|
||||
`SessionEventEntry` remains the journal-entry envelope and contains only `event: SessionWireEvent`. This change does not also turn page entries into bare events or refactor the general `RemoteJournalStream` entry contract.
|
||||
|
||||
`SessionPage.events` remains `SessionEventEntry[]`.
|
||||
|
||||
`SessionFollowFrame` remains either an opening frame or an event frame containing `event`.
|
||||
|
||||
`SessionToolCallView`, `SessionToolView`, and `SessionEventEntry.view` are deleted.
|
||||
|
||||
The Client connection stops re-exporting `ToolCallView` and `ToolResultView` from `dsh-tools/presentation` for Session consumers.
|
||||
|
||||
Generated catalogs and graphs derive the narrowed Remote types and package dependencies from their owning sources.
|
||||
|
||||
### Durable log
|
||||
|
||||
- `tool/call.data.name` remains unchanged.
|
||||
- `tool/call.data.arguments` remains the model-produced raw JSON string.
|
||||
- `tool/result.data.message.content` remains the model-visible result.
|
||||
- `tool/result.data.error` remains the structured failure identity.
|
||||
- `tool/result.data.meta` remains a tool-private JSON value.
|
||||
- Client card models do not write to the Session log.
|
||||
- Renderer keys and Host tool implementation ids do not enter the Session log.
|
||||
- Existing durable Sessions need no migration, and `SESSION_FORMAT_VERSION` does not change.
|
||||
|
||||
### `presentationMeta`
|
||||
|
||||
`presentationMeta` is not a Host tool view. It reads the canonical value when tool execution completes, and that value is not persisted. Removing it would make the following existing presentation impossible to reconstruct losslessly:
|
||||
|
||||
- read path, offset, lines, totalLines, and lang;
|
||||
- applied contextual hunks for write/edit;
|
||||
- grouped grep/glob results, truncation flag, and total;
|
||||
- web_search source fields and provider answer;
|
||||
- web_fetch final URL, HTTP status, and effective truncation flag.
|
||||
|
||||
The Client narrows `meta` locally at runtime. Renaming `presentationMeta` to more neutral result metadata is outside this decision.
|
||||
|
||||
## Host Design
|
||||
|
||||
After obtaining source events, `SessionHistoryController.page()` performs only pagination and the existing projection-baseline calculation. Attached Sessions use the projection registry snapshot; detached Sessions use its restore path over the inspected log. History does not mount a preset to change the registered projection set.
|
||||
|
||||
`SessionHistoryController.follow()` retains listener-first setup, opening cursors, gap-free replay, live buffering, cancellation, and teardown. It maintains no additional state for tool events.
|
||||
|
||||
The controller has no `presenterScopeFor()`, `viewFor()`, `backscanArgs()`, `parseToolCall()`, or `jsonView()` path. Page state contains no presenter scope or argument resolver; follow state contains no `openCalls`, `fallbackEvents`, or presentation argument resolver. Each page/follow event is wrapped only as `{event}` while addressing, ownership, cursor, sequence, and projection logic remains intact.
|
||||
|
||||
An immutable event-conversion helper may remain narrow or be inlined; its name is irrelevant as long as history performs no presentation work.
|
||||
|
||||
Session Controller dependencies remain only when another package responsibility requires them. Manifest and project references contain no presentation-only dependency.
|
||||
|
||||
### Performance constraints
|
||||
|
||||
- `page()` performs no tool-specific work.
|
||||
- Adding tool results to a page does not cause repeated scans over existing page events.
|
||||
- `follow()` maintains no presentation index.
|
||||
- History does not trigger the Cordis `tools` service proxy.
|
||||
- History does not wait for a presenter standing scope.
|
||||
- History does not parse tool-argument JSON.
|
||||
- History does not perform tool-view JSON clones.
|
||||
- The Remote payload does not repeat structured data already expressed by `meta`.
|
||||
- The Client does not scan the complete Session event window to build one card.
|
||||
- The Client derives a card model again only when the corresponding immutable Tool block changes.
|
||||
|
||||
## Client Session and Conversation
|
||||
|
||||
The Client Session has no private `views` array parallel to the raw event window. `installWindow()`, `prependWindow()`, and `appendLive()` handle only event entries, cursor/hasMore state, queues, projection, and notifications.
|
||||
|
||||
`ConversationEventInput` contains only `event`. The Conversation assembler does not know `SessionToolView`; its replace/prepend/append behavior, Context identity, Location, and publication cadence remain unchanged.
|
||||
|
||||
The Chat and Trajectory Tool Definitions read no views. They derive the following data from events:
|
||||
|
||||
- callId;
|
||||
- tool name;
|
||||
- raw arguments;
|
||||
- turn, step, seq, and time;
|
||||
- result content;
|
||||
- isError and structured error;
|
||||
- result metadata;
|
||||
- root/subcall parent-child topology;
|
||||
- synthetic interruption results.
|
||||
|
||||
`RunningToolCall` has no `callView`.
|
||||
|
||||
`ToolResultNode` has no `callView` or `resultView`.
|
||||
|
||||
`ToolCallBlock` does not gain a generic `view`, `card`, `kind`, or `locations` field to replace the deleted fields. Concrete presentation remains the responsibility of `ui-tool` and keyed renderers.
|
||||
|
||||
### Root and Code Dispatch subcalls
|
||||
|
||||
Host presenter APIs describe top-level calls and results. Code Dispatch subcalls use the Generic, flattened Client presentation; recognizing a subcall name does not grant it a structured card.
|
||||
|
||||
Code Dispatch start and result events already carry `parentCallId`. Conversation preserves that existing fact on each child `ToolCallBlock`; root Session calls omit it. The five structured card models accept only blocks without `parentCallId`, while existing renderers that intentionally support nested calls continue receiving the same child block.
|
||||
|
||||
The Details panel delegates the selected block unchanged. The same card models observe `parentCallId` and keep a selected Code Dispatch child on the existing raw fallback, so the Details slot needs no placement field.
|
||||
|
||||
The keyed slot continues dispatching every subcall by its real tool name. `parentCallId` controls only the terminal, diff, read, search, and web structured models covered by this decision. Existing specialized renderers such as Skill and Cordis, which already read raw blocks, remain unchanged.
|
||||
|
||||
### Missing call head
|
||||
|
||||
When a result node has no matching call in the current window, `ToolResultNode.call` remains `null`. The Client does not scan the window, issue another RPC, or infer a tool name from result text.
|
||||
|
||||
A specialized derivation that needs the name or arguments uses the current Generic fallback when `call === null`. A model that could use result metadata alone does not gain new presentation, because the current Host `presentResult` must first recover the matching call.
|
||||
|
||||
If a later older page supplies the call head, the Conversation Context rebuilds under existing replay rules and may then produce the already-supported specialized card.
|
||||
|
||||
### Argument and metadata narrowing
|
||||
|
||||
The Client parses JSON from `argsRaw`; a parse failure returns the Generic form instead of throwing a React render error.
|
||||
|
||||
Chat and Details reuse parsing for the same block through pure helpers. Any future cache must use immutable block identity and must not create cross-Session global state keyed by callId.
|
||||
|
||||
Each specialized model checks only the fields it needs. The Client does not copy complete Host tool schemas or invoke a Host `defineTool` validator.
|
||||
|
||||
Valid first-party events must be equivalent to current presenter output. Malformed, old-version, or manually edited logs promise only a crash-free Generic fallback.
|
||||
|
||||
## Client Card-Model Design
|
||||
|
||||
The existing `ui-tool/src/client/tool/models/` directory remains the single source of shared derivation for Chat and Details. Helpers return component props directly; they do not return `ToolCallView` or `ToolResultView`, and they do not create an isomorphic `ClientToolView` union.
|
||||
|
||||
Branches on tool name exist only in `ui-tool` card models, existing row-classification tables, or the Client plugin that owns a keyed renderer for that tool. They must not enter the Session Controller, Client Session, Conversation assembler, or generic Slot renderer.
|
||||
|
||||
Unknown tools continue to use `GenericToolCard` with the name, raw arguments, result content, and error.
|
||||
|
||||
### Generic tool row
|
||||
|
||||
`toolRowModel()` derives the generic row directly from `toolName`, `argsRaw`, result content, error, cwd, and home. It preserves:
|
||||
|
||||
- classification into `search`, `read`, `bash`, `write`, `edit`, `code`, and `others`;
|
||||
- existing titles and tool-specific titles;
|
||||
- summary-field priority and single-line truncation;
|
||||
- comma joining of multiple queries;
|
||||
- cwd-relative paths and home abbreviation;
|
||||
- file-path clicks;
|
||||
- pretty JSON arguments and non-JSON raw-text fallback;
|
||||
- flattened result content and structured-error fallback;
|
||||
- running, ok, error, and stopped states.
|
||||
|
||||
The title, kind, rawInput, content, and locations from Generic Host `presentCall` do not currently drive an ordinary Web row. Generic `presentResult.content` also does not drive Web output, so the Client need not copy these unconsumed values.
|
||||
|
||||
### Terminal card
|
||||
|
||||
The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, existing `parentCallId`, and Session cwd.
|
||||
|
||||
| Input | Preserved result |
|
||||
|---|---|
|
||||
| running standard `bash`/`pwsh` foreground call | terminal prompt, description, cwd, and running state |
|
||||
| successful standard foreground call | terminal output, exit code/signal, and success or failure status dot |
|
||||
| `run_in_background:true` | Generic row and raw result |
|
||||
| tool execution error | Generic IN/OUT and error summary |
|
||||
| running persistent `bash`/`pwsh` | terminal prompt |
|
||||
| settled persistent `bash`/`pwsh` | Generic flattened result, with no new exit card |
|
||||
| foreground `terminal_send` | terminal prompt and output |
|
||||
| background/error `terminal_send` | Generic result |
|
||||
| Code Dispatch child | current flattened Generic form |
|
||||
|
||||
Standard shell results continue parsing trailing `[exit code: N]` and `[killed by signal: X]` markers. A parsed marker is removed from the body; timeout, sandbox denial, and markers without a pill remain in the body.
|
||||
|
||||
Call `description` remains above the card and overrides the collapsed summary. Workdir continues handling absolute, relative, and missing values. Relative paths resolve against the Session cwd while preserving normalization for `.`, `..`, drive letters, and UNC roots.
|
||||
|
||||
For `terminal_send`, non-empty input and the session id remain verbatim tool data; the empty-input fallback and session label resolve through the render site's conversation locale.
|
||||
|
||||
Standard and persistent providers sharing the same tool name are a special compatibility point. The Client uses currently valid argument and result features to preserve their delivered differences. Input that cannot be identified unambiguously uses a Generic settled result rather than gaining new presentation.
|
||||
|
||||
`TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged.
|
||||
|
||||
### Diff card
|
||||
|
||||
| Input | Preserved result |
|
||||
|---|---|
|
||||
| running `write` | intended added-only diff from `file_path` and `content` |
|
||||
| running `edit` | intended replacement diff from `file_path`, `old_string`, and `new_string` |
|
||||
| running `str_replace_editor create` | intended added-only diff from `path` and `file_text` |
|
||||
| running `str_replace_editor str_replace` | intended replacement diff from `path`, `old_str`, and `new_str` |
|
||||
| successful settled `write`/`edit` | applied contextual hunks from `meta.diffs` |
|
||||
| settled `str_replace_editor` | Generic, because the tool defines no result presenter |
|
||||
| write create or missing/malformed/empty applied metadata | current argument fallback |
|
||||
| error, malformed arguments, edit with malformed metadata, or Code Dispatch child | Generic |
|
||||
|
||||
Paths, `oldText:null`, `newText`, result-over-call diff precedence, the eight-line Chat limit, full-height Details presentation, and file-opening behavior remain unchanged.
|
||||
|
||||
### Read card
|
||||
|
||||
A running `read` continues to show only the summary row. A successful settled `read` reads path, offset, lines, totalLines, and lang from result metadata and confirms that the result is one text block matching the read envelope.
|
||||
|
||||
Missing metadata, malformed fields, a mismatched result envelope, an error, a missing call head, or a Code Dispatch child all use Generic. Cwd-relative path labels, home abbreviation, syntax language, total line count, the eight-line Chat limit, and full-height Details presentation remain unchanged.
|
||||
|
||||
The Client does not need to construct Host `ReadResultView.content`; Generic fallback can always read raw result content directly.
|
||||
|
||||
### Search card
|
||||
|
||||
A running `grep` or `glob` continues to show only the argument summary. Successful results produce grouped matches or a path list from `meta.shape:'matches'` and `meta.shape:'paths'`, respectively.
|
||||
|
||||
The Client validates path, lineNumber, line, truncated, and total. Empty matches or paths form a valid card. Missing or malformed metadata, an unknown shape, an error, a missing call head, or a Code Dispatch child uses Generic.
|
||||
|
||||
When `truncated:true`, the card continues to show a recovery locator from raw result content. It does not show one when untruncated. The eight-line Chat limit, full-height Details presentation, and expansion behavior remain unchanged.
|
||||
|
||||
### Web card
|
||||
|
||||
A running `web_search` or `web_fetch` continues to show only the summary row. A successful search builds the card from `meta.sources`, `meta.answer`, and `meta.truncated`; a successful fetch builds it from `meta.url`, `meta.statusCode`, and `meta.truncated`.
|
||||
|
||||
The Client validates every source's url, title, snippet, and publishedAt, and continues rendering only http/https URLs as links. Missing or malformed metadata, an error, a missing call head, or a Code Dispatch child uses Generic.
|
||||
|
||||
Search answer text, source ordering, label fallback, and truncation notice remain unchanged. The fetch final URL, status, truncation notice, and raw body below Details remain unchanged.
|
||||
|
||||
### Renderers already using raw blocks
|
||||
|
||||
- Todo rows continue deriving completed/active summaries from arguments.
|
||||
- Question rows continue deriving waiting, answered, cancelled, and interrupted states from result content and errors.
|
||||
- Skill rows continue deriving names and states from calls and results.
|
||||
- Cordis define/run/action rows continue deriving from calls, results, and their own Client services.
|
||||
- These renderers retain their props, slot keys, registration order, and visible results.
|
||||
|
||||
## Deliverables
|
||||
|
||||
`ui-deliverables` derives mutation business facts independently of presentation intent, so produced-file behavior is not coupled to card screenshots.
|
||||
|
||||
The Deliverables Definition observes root `tool/call` and successful `tool/result` events by callId and retains a minimal Client-owned mutation candidate without scanning the Session window or depending on a UI renderer.
|
||||
|
||||
| Tool | Mutation condition | Path source |
|
||||
|---|---|---|
|
||||
| `write` | any successful call | `file_path` |
|
||||
| `edit` | any successful call | `file_path` |
|
||||
| `str_replace_editor` | `create`, `str_replace`, or `insert` | `path` |
|
||||
| `str_replace_editor` | `view` | produces no path |
|
||||
| Other | no current first-party mutation semantics | produces no path |
|
||||
|
||||
Failures, interruptions, orphan results, missing paths, and malformed arguments produce no deliverable. Paths retain first-seen deduplication, and results settled after the closing Assistant seq remain excluded.
|
||||
|
||||
This change does not add a general tool-side-effect registry. The ability for a Host-only third-party presenter to join Deliverables automatically through `kind:'edit'` or `locations` is intentionally removed. A future real third-party mutation requirement must use a Client business contribution and cannot restore Session views.
|
||||
|
||||
## Fixtures and Test Data
|
||||
|
||||
The Client fixture deletes its handwritten `presentCall()`, `presentResult()`, `viewFor()`, and fixture tool-view types. It continues producing the same raw calls, result content, and result metadata as a real log.
|
||||
|
||||
| Fixture | Raw facts that must remain |
|
||||
|---|---|
|
||||
| terminal | arguments and real result status markers |
|
||||
| diff | arguments and result `meta.diffs` |
|
||||
| read | result metadata path/offset/lines/totalLines/lang |
|
||||
| grep/glob | result metadata shape/files or paths/truncated/total |
|
||||
| web | result metadata sources/answer or url/statusCode/truncated |
|
||||
| generic/custom | name, argsRaw, content, and error |
|
||||
|
||||
The fixture does not import Host tool packages to compute page presentation and retains no presenter mirror. The same raw fixture continues to drive jsdom, built Web snapshots, and the `?fixture` browser path.
|
||||
|
||||
## Presentation-Equivalence Matrix
|
||||
|
||||
“Current presentation” is defined by committed component tests, assembly tests, and Web browser expected outputs. A transport or ownership refactor does not justify refreshing snapshots; an approved product change requires separate evidence.
|
||||
|
||||
| Scenario | Required presentation |
|
||||
|---|---|
|
||||
| unknown tool, running | Generic row with tool name and argument summary |
|
||||
| unknown tool, settled | Generic row and raw output |
|
||||
| malformed arguments | safe Generic fallback |
|
||||
| orphan result | callId title and Generic output |
|
||||
| interrupted call | warning/stopped state |
|
||||
| foreground bash/pwsh | current terminal prompt, body, cwd, and state |
|
||||
| background/error bash/pwsh | current Generic IN/OUT |
|
||||
| persistent shell | current running terminal and settled Generic form |
|
||||
| terminal_send | current foreground terminal and background/error Generic form |
|
||||
| write/edit | current intended/applied diff and error fallback |
|
||||
| read | current running summary, settled ReadBlock, and error fallback |
|
||||
| grep/glob | current grouped/path card, truncation, and recovery |
|
||||
| web_search/web_fetch | current source/summary card and raw body |
|
||||
| Todo/Question/Skill/Cordis | current specialized rows |
|
||||
| Code Dispatch subcall | current Generic/flattened form |
|
||||
| Chat and Details | identical card fields for the same call |
|
||||
| Trajectory | current identity, tree, selection, and details |
|
||||
| Deliverables | current successful-mutation chips and links |
|
||||
|
||||
## Client Extension Contract
|
||||
|
||||
`tool.call.toolview` remains the sole tool UI registration mechanism. A tool that needs specialized Client presentation must have a Client plugin register its wire tool name.
|
||||
|
||||
The registrant receives the raw `ToolCallBlock`, Session path information, and host actions, and validates the argument and metadata fields it recognizes. It does not call the Host tool registry, depend on `presentCall` or `presentResult`, or require `SessionEventEntry.view`.
|
||||
|
||||
A tool with no Client renderer consistently degrades to Generic. Only one keyed registration for a tool name can be active, and duplicate keys continue to fail loudly.
|
||||
|
||||
A Session-scoped slot can express Client-side Session differences, but no renderer variant is inferred from a preset. A Host-only presenter does not grant a Web rich card automatically. This is the explicit boundary between “the Host describes presentation” and “the Client plugin owns presentation.”
|
||||
|
||||
## Failures and Fallback
|
||||
|
||||
- The Client treats arguments and metadata as wire JSON and narrows them at the consumption site.
|
||||
- Argument JSON parse failure uses Generic.
|
||||
- A known tool missing required fields uses Generic.
|
||||
- Missing or malformed metadata uses Generic, except successful `write`, whose current presenter preserves its argument-derived whole-file diff.
|
||||
- An error result does not show a success card merely because metadata is present.
|
||||
- A missing call head does not trigger guesses about the tool name or arguments.
|
||||
- Unknown metadata fields are ignored.
|
||||
- A new metadata variant uses Generic in an older Client.
|
||||
- Card-model helpers catch expected parse failures instead of relying on a React error boundary for ordinary fallback.
|
||||
- Unexpected failures inside a keyed renderer remain isolated by existing Slot error handling.
|
||||
|
||||
## Same-Named Host Providers
|
||||
|
||||
The Host registry allows different scopes to provide different definitions under the same tool name. Through presenter scope, a Session view can theoretically select a different render intent by preset. After removing the view, the Client keyed slot observes only the wire name and cannot observe Host definition identity.
|
||||
|
||||
The notable current first-party examples are standard and persistent `bash` and `pwsh`. Client derivation uses valid argument and result features to preserve their delivered differences without a provider-id wire field. Malformed or custom same-name provider input that cannot be distinguished uses Generic.
|
||||
|
||||
This change does not promise to preserve differences expressed only through a Host presenter by third-party same-name providers. If the product later requires distinct Client presentation for same-name providers, it must define a stable, non-presentational Client identity and must not restore per-page Host view computation.
|
||||
|
||||
## Shipped Scope
|
||||
|
||||
### Session Controller
|
||||
|
||||
- `SessionEventEntry` contains only the raw event.
|
||||
- Both Session tool-view types are absent.
|
||||
- History has no presentation imports, helpers, or page/follow presentation state.
|
||||
- Addressing, pagination, follow, and projection logic remain in the Session owner.
|
||||
- Host tests assert the raw journal contract.
|
||||
|
||||
### Session Controller Client
|
||||
|
||||
- `Session.views` is absent.
|
||||
- EventSource replace/prepend/append deltas remain unchanged.
|
||||
- Transport, fixture, and test-support types carry raw entries.
|
||||
- Event identity and reference stability remain unchanged.
|
||||
|
||||
### UI Conversation, Chat, and Trajectory
|
||||
|
||||
- Conversation input and Tool blocks contain no view fields.
|
||||
- Chat and Trajectory Tool Definitions read raw events.
|
||||
- Event pairing, Context replay, trees, and target snapshots remain unchanged.
|
||||
- Child Tool blocks preserve the existing Code Dispatch `parentCallId`; row and Details slot owner props add no separate placement field.
|
||||
|
||||
### UI Tool and Deliverables
|
||||
|
||||
- Card models derive from raw blocks and metadata.
|
||||
- Chat and Details share the same helpers.
|
||||
- Generic fallback and keyed dispatch remain unchanged.
|
||||
- Deliverables recognizes first-party mutation arguments.
|
||||
|
||||
### Fixtures, documentation, and generated artifacts
|
||||
|
||||
- Fixtures send only raw events and metadata.
|
||||
- Session Controller and Client README/JSDoc contracts describe the raw journal and Client presentation owner.
|
||||
- The tool cookbook documents the Web Client integration path.
|
||||
- This Agent Note is the decision owner; retained Host presenter notes keep their independent decisions.
|
||||
- Authored Remote types, dependencies, READMEs, pairing records, and generated references remain synchronized.
|
||||
|
||||
## Verification Matrix
|
||||
|
||||
### Host
|
||||
|
||||
- page returns contiguous raw event entries.
|
||||
- follow returns an opening cursor and contiguous raw event entries.
|
||||
- page/follow behave identically without the Tools service.
|
||||
- A cold page does not resolve or mount a preset.
|
||||
- A tail page computes its baseline through the standard projection registry; provider availability follows the projection composition rather than a history-side setup path.
|
||||
- Addressing, ownership, message-aligned boundaries, and tail projection remain unchanged.
|
||||
- Listener-before-read, reconnect catch-up, and gap repair remain unchanged.
|
||||
- Many tool results do not trigger a backscan per result.
|
||||
- Wire results contain no view.
|
||||
|
||||
`session-history-journal.host.spec.ts` owns pagination, continuity, and history error behavior without presenter assertions.
|
||||
|
||||
### Client Conversation
|
||||
|
||||
- replace, prepend, and append accept entries without views.
|
||||
- Chat and Trajectory root call/result pairing remains unchanged.
|
||||
- The Code Dispatch tree remains unchanged.
|
||||
- Result-only fallback remains unchanged.
|
||||
- A synthetic interruption result copies no view.
|
||||
- Node identity across registry rebuild, older prepend, and live append remains unchanged.
|
||||
|
||||
### Client card model
|
||||
|
||||
- terminal produces the pinned props from raw arguments/content.
|
||||
- diff produces the pinned diffs from arguments/metadata.
|
||||
- read produces the pinned lines from metadata/content.
|
||||
- search produces the pinned grouped/path card and recovery from metadata/content.
|
||||
- web produces the pinned sources/fetch summary from metadata/content.
|
||||
- unknown, malformed, error, missing-call, and missing-metadata cases remain Generic.
|
||||
- absent and present `parentCallId` cases prove that structured presentation does not reach Code Dispatch descendants.
|
||||
- Chat and Details produce identical card fields for the same block.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Successful write/edit calls produce `file_path`.
|
||||
- str_replace_editor create/str_replace/insert calls produce `path`.
|
||||
- str_replace_editor view produces no path.
|
||||
- failure, interruption, malformed input, and orphan results produce no path.
|
||||
- First-seen deduplication and the closing-seq cutoff remain unchanged.
|
||||
|
||||
### Assembly and browser
|
||||
|
||||
- terminal, diff, read, search, and web browser expected outputs all pass without refresh.
|
||||
- Visible assertions for the tool tree, details, trajectory, and deliverables retain their expected values.
|
||||
- The built Client still displays the same cards after obtaining raw events from real Remote page/follow operations.
|
||||
- Fixtures and the real Host use the same Client derivation.
|
||||
- A minimal preset independently pins persistent-shell behavior.
|
||||
|
||||
### Static and documentation
|
||||
|
||||
- Production code contains no `SessionToolView` or `SessionToolCallView`.
|
||||
- Session history does not reference `dsh-tools/presentation`, `ctx.tools`, `presenterScopeFor`, or `backscanArgs`.
|
||||
- Client Conversation does not reference `ToolCallView` or `ToolResultView`.
|
||||
- Client models do not read `callView` or `resultView`.
|
||||
- The fixture defines no presenter mirror.
|
||||
- Host `presentCall`, `presentResult`, and `presentationMeta` remain.
|
||||
- No new Client registry or Host-to-Client presentation hint exists.
|
||||
- Affected authored types, READMEs, Agent Notes, catalogs, and graphs are synchronized.
|
||||
|
||||
## Verification Commands
|
||||
|
||||
Changes to this decision use `dsh-pre-push-checks` to select commands for the final diff. Required evidence includes:
|
||||
|
||||
- focused Session Controller history/transport tests;
|
||||
- ui-chat and ui-trajectory Tool Definition tests;
|
||||
- ui-tool terminal, diff, read, search, web, row, tree, and details tests;
|
||||
- ui-deliverables produced-file tests;
|
||||
- connection fixture and Client runtime tests;
|
||||
- affected Host and Client TypeScript faces;
|
||||
- lint and duplication;
|
||||
- per-file 100% coverage for affected source files;
|
||||
- `DSH_SNAPSHOT=replay pnpm run test:web`, without refreshing existing presentation goldens;
|
||||
- authored Remote type and TypeScript checks;
|
||||
- `pnpm run doc-sync`;
|
||||
- `git diff --check`.
|
||||
|
||||
## Shipped Invariants
|
||||
|
||||
- Session page/follow does not read the Tools registry or a presenter scope.
|
||||
- Session history has no callId backscan, presentation cache, or view clone.
|
||||
- A Remote Session entry carries no view.
|
||||
- The Session log and `SESSION_FORMAT_VERSION` remain unchanged.
|
||||
- Result metadata passes byte-for-byte through the log and Remote to the Client.
|
||||
- Conversation assembles `ToolCallBlock` only from raw events.
|
||||
- `ToolCallBlock` contains no Host render-intent fields.
|
||||
- The five structured card models read only raw blocks, their existing `parentCallId`, and Session path facts.
|
||||
- Generic, Todo, Question, Skill, and Cordis rows remain unchanged.
|
||||
- Deliverables does not depend on render intent and preserves current paths.
|
||||
- Text, components, expanded content, states, links, and ordering for all first-party top-level tools remain unchanged.
|
||||
- Malformed, missing-metadata, error, orphan, and unknown-tool cases continue to fall back safely.
|
||||
- Code Dispatch subcalls remain Generic and flattened.
|
||||
- Chat, Details, and Trajectory behavior remains unchanged.
|
||||
- Existing Web browser expected outputs pass without refresh.
|
||||
- Host presenter APIs, implementations, and direct tests remain unchanged.
|
||||
- ACP output remains unchanged.
|
||||
- No new downstream presentation field or second Client registry is introduced.
|
||||
- Pagination cost no longer grows as the number of results multiplied by page event count.
|
||||
- Downstream payloads no longer duplicate result metadata in a card DTO.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### Optimize only `backscanArgs` and retain views
|
||||
|
||||
Building one `callId → {name,args}` Map before processing a page would make backscan linear, and live follow already has an `openCalls` fast path. It would leave Host lookups, preset scopes, presenters, JSON clones, duplicate payloads, and dual ownership intact, so this alternative is rejected.
|
||||
|
||||
### Add a presenter registry to the Client
|
||||
|
||||
Copying the `presentCall` and `presentResult` interfaces into the browser would duplicate the registration, lifecycle, fallback, and override semantics of the `tool.call.toolview` slot. Renderers would still have to convert presenter DTOs into component props, so this alternative is rejected.
|
||||
|
||||
### Have the Conversation Tool Definition produce one unified view
|
||||
|
||||
This would put tool names and UI-card semantics into the target-neutral Conversation owner and recreate an intermediate DTO isomorphic to the Host view, so this alternative is rejected.
|
||||
|
||||
### Delete `presentationMeta`
|
||||
|
||||
Read line structure, applied diffs, search grouping, web sources, and effective truncation cannot be recovered losslessly from model text. Parsing free-form text would also bind the UI to output wording, so this alternative is rejected.
|
||||
|
||||
### Persist canonical tool results
|
||||
|
||||
This would enlarge the Session log, expose internal result structures, change the durable format, and potentially store objects far larger than presentation requires. Existing metadata is sufficient, so this alternative is rejected.
|
||||
|
||||
### Delete Host presenter APIs
|
||||
|
||||
Deleting them would shrink more code, but the decision preserves Host `presentCall` and `presentResult`. Their APIs, implementations, tests, and types remain independent of Session Remote.
|
||||
|
||||
### Import Host tool implementations into the Client
|
||||
|
||||
Tool packages include Node, filesystem, subprocess, or provider dependencies and cannot enter the browser bundle. The Client consumes only raw JSON and maintains narrow parsers inside its own renderers, so this alternative is rejected.
|
||||
|
||||
### Query presentation from the Host per result
|
||||
|
||||
An on-demand RPC would turn one page read into N network calls and would still require Host lookups, scope restoration, callId recovery, and error coordination, so this alternative is rejected.
|
||||
|
||||
### Allow presentation enhancements
|
||||
|
||||
The Client could produce more rich cards for Code Dispatch subcalls, missing call heads, or history whose Host presenter was unavailable. That would mix an ownership change with product behavior and prevent snapshots from proving equivalence, so this alternative is rejected.
|
||||
|
||||
### Accept temporary Generic degradation
|
||||
|
||||
Stopping view delivery before completing Client cards would temporarily degrade terminal, diff, read, search, web, and Deliverables behavior. Client-equivalent derivation and Host removal must land in the same releasable change.
|
||||
|
||||
## Consequences
|
||||
|
||||
The decision removes presentation work, repeated scans, and duplicate view payloads from Session reads. Its cost is that the retained Host presenter and Client card derivation can evolve independently, so both sides require owner-specific tests and Web equivalence remains an explicit product constraint.
|
||||
|
||||
### Client and Host logic drift
|
||||
|
||||
Each tool may have one Host render intent and one Client card derivation. They serve different consumers and do not share a runtime path. Unrefreshed browser expected outputs pin visual equivalence for the first-party Web experience, while Host presenter tests constrain only the Host API.
|
||||
|
||||
### Same-named providers lack stable identity
|
||||
|
||||
A raw event records the tool name but not the specific ToolDefinition. The Client uses valid event fields to preserve differences between standard and persistent shells. Ambiguous custom or malformed input falls back to Generic; the wire has no extra hint for theoretical extensibility.
|
||||
|
||||
### Metadata is unknown JSON
|
||||
|
||||
Old Sessions may lack fields, and manually edited logs may contain malformed values. Each Client model must narrow locally and cannot pass unknown arrays or objects directly into UI primitives.
|
||||
|
||||
### Preset-owned projection availability
|
||||
|
||||
History does not compensate for projection units absent from the current composition. A preset-owned unit that must remain visible across a cold read requires the shared Session preparation/projection composition to make its definition available before restore; history must not regain a preset-mount or presenter setup branch.
|
||||
|
||||
### Two targets must stay synchronized
|
||||
|
||||
Chat and Trajectory have separate Tool Definitions and both carry the raw fields. Card derivation remains only in `ui-tool` and cannot be copied into either Definition.
|
||||
|
||||
### Deliverables has a hidden dependency
|
||||
|
||||
Deliverables is not a visual component, so its mutation parser must remain synchronized with supported first-party write tools. Dedicated tests pin file chips and Markdown links independently of card screenshots.
|
||||
|
||||
### Fixtures can create false confidence
|
||||
|
||||
Fixtures send raw events and metadata rather than handwritten views. Real-Host assembly coverage remains necessary because fixture-only snapshots cannot prove the transport path.
|
||||
|
||||
### Incorrectly refreshing snapshots
|
||||
|
||||
This change promises unchanged user-visible output. A snapshot difference must be fixed in Client derivation. Expected outputs must not be refreshed unless the owner separately approves a specific visual change.
|
||||
|
||||
### Documentation drift
|
||||
|
||||
The Agent Note, package READMEs, cookbook, root rules, and generated references must change together whenever the raw journal or Client presentation owner changes. Host API documentation remains separate.
|
||||
|
||||
### Remote protocol narrowing
|
||||
|
||||
The absence of optional `view` is a prerelease wire-type decision shared by all consumers. There is no compatibility shim, dual-writing, or version negotiation.
|
||||
|
||||
## Relationship to Existing Decisions
|
||||
|
||||
This note partially supersedes the implementation fact in [Client tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) that “card models receive Host views.” Its core decisions remain: `ui-tool` owns presentation, business plugins use keyed slots, and Conversation owns only lifecycle and topology.
|
||||
|
||||
This note preserves [toolview dissolution](2026-07-23-toolview-dissolution.md): the Client still has one slot registration model and does not restore `ToolViewRegistry`.
|
||||
|
||||
This note narrows the consumer scope of the [render-intent union](2026-07-02-tool-render-intent-union.md). The Host APIs and types remain, while the Session Remote and Web Client do not consume them. This note owns the transport split without rewriting that presenter decision.
|
||||
|
||||
This note updates the entry contract from [Session history and Remote event transport](2026-08-18-session-history-and-event-transport.md): the journal transports only raw events plus an independent projection baseline, not transient tool views.
|
||||
|
||||
This note follows [Conversation Node assembly](2026-08-09-client-conversation-node-assembly.md): the Tool Definition owns event pairing and the call tree, while concrete card models remain in `ui-tool`.
|
||||
|
||||
This note preserves result metadata from the [canonical tool output contract](2026-07-20-canonical-tool-output-contract.md), because it is the lossless, replayable input to Client derivation.
|
||||
|
||||
## Deferred
|
||||
|
||||
- A separate explicit decision may evaluate deleting Host presenters if they remain without production consumers; this decision does not prejudge it.
|
||||
- Specialized cards for Code Dispatch subcalls require a separate design and visible-snapshot updates; this decision preserves current behavior.
|
||||
- A third-party mutation tool that joins Deliverables requires a new Client-owned contribution; this decision does not create a registry for an absent consumer.
|
||||
- Distinct Client presentation for same-named providers first requires a stable, non-presentational identity; it must not restore per-page Host views.
|
||||
- If Client card-model performance needs measurement, an immutable-block microbenchmark can be added; the shipped architecture already prohibits scanning the Session window.
|
||||
+705
@@ -0,0 +1,705 @@
|
||||
# Agent Note: Client 从原始 Session 工具事件派生展示
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-23-client-derived-tool-presentation.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Session 历史是持久 journal 接口,工具卡片属于 Client 展示。在 `page`/`follow` 中计算卡片 view 会让历史读取依赖 Tools registry、Agent preset、恢复后的 scope、presenter 执行和临时 UI 类型。
|
||||
|
||||
`tool/result` 不重复记录工具名称和参数。Host 端结果展示因此需要 call index 或按 `callId` 回扫;`maxMessages` 不直接限制事件数量,工具密集页面上的重复扫描可能接近二次方成本。
|
||||
|
||||
Host 投影还会重复结构化数据。read、diff、search 与 web 结果已在 `tool/result.data.meta` 中持久化有界事实;另一份 view 只增加 Remote payload 与 Client 解码成本,不增加持久语义。
|
||||
|
||||
Client 已经拥有完整的工具展示入口。`ui-chat` 将 `tool/call`、`tool/result` 与 Code Dispatch 事件组装成稳定的 `ToolCallBlock`;`ui-tool` 拥有递归调用树、按工具名称分发的 `tool.call.toolview` keyed slot、Generic fallback、卡片模型和 details output;业务 Client 插件可以为自己的工具名称注册 renderer。
|
||||
|
||||
Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的两套解释。keyed renderer 是 Web 扩展点,因此中间 Host view 不提供独立 Web 能力。
|
||||
|
||||
`ToolDefinition.presentCall`/`presentResult` 仍是保留的 Host API;ACP 采用 automation-only 协议,仓库也没有生产 TUI consumer。是否删除这些定义与 Session 读取是否独立于展示是两个决定。
|
||||
|
||||
所需结果是一条原始 Session journal 和一个 Client 展示 owner,且不发生可见退化或顺带增强。专用卡片、交互和 Code Dispatch 拓扑保持稳定,transport 不再携带临时 view。
|
||||
|
||||
## Decision
|
||||
|
||||
Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。
|
||||
|
||||
Client Conversation 层继续负责工具调用与结果的 identity、配对、生命周期、Code Dispatch 拓扑和稳定 Chat Node。它不解释具体工具名称,也不生成 terminal、diff、read、search 或 web 组件 props。
|
||||
|
||||
Client `ui-tool` 继续负责 card model 和具体 renderer。每个 card model 改为直接读取 `ToolCallBlock` 中的工具名称、原始参数、结果内容、错误、持久 metadata、Session cwd 与 Host home,并生成与现有页面相同的组件 props。
|
||||
|
||||
Client 不建立第二套 presenter registry。工具名称分发只使用现有 `tool.call.toolview` keyed slot;Client 中的纯 card-model helper 属于 renderer 实现,不成为 Cordis service、公开 registry 或 wire DTO。
|
||||
|
||||
Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCallView`、`ToolResultView` 及现有 presenter 实现全部保留。Session Controller 不调用它们,Client 不导入或消费它们;未来非 Client consumer 是否使用它们不属于本决定。
|
||||
|
||||
`ToolOutputDefinition.presentationMeta` 与持久 `tool/result.data.meta` 保留。它们携带模型可见结果文本无法无损表达、而现有专用卡片需要的执行结果事实。Client 直接校验并消费 `meta`,不要求 Host 在历史读取时再把它转换成 view。
|
||||
|
||||
### 目标与非目标
|
||||
|
||||
| 类别 | 决定 |
|
||||
|---|---|
|
||||
| 不存在 | `SessionEventEntry.view`、`SessionToolView`、`SessionToolCallView` |
|
||||
| 不存在 | `history.ts` 的 `viewFor`、`backscanArgs`、`parseToolCall`、`jsonView` 与 presenter scope lookup |
|
||||
| 不存在 | follow 中只服务 presentation 的 `openCalls` 与 fallback event scan |
|
||||
| 不存在 | Client Session 的平行 `views` 数组、Conversation input 的 `view`、Tool block 的 `callView`/`resultView` |
|
||||
| 派生 | terminal、diff、read、search、web card model 读取 raw block/meta |
|
||||
| 派生 | Deliverables 读取成功 mutation 的名称与参数 |
|
||||
| 保留 | Host `ToolDefinition.presentCall`/`presentResult` API、类型、实现与直接测试 |
|
||||
| 保留 | `output.presentationMeta` 与持久 `tool/result.data.meta` |
|
||||
| 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology |
|
||||
| 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 |
|
||||
| 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id |
|
||||
| 禁止 | 新卡片、视觉改版、交互改版或 Code Dispatch rich-card 增强 |
|
||||
| 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 |
|
||||
|
||||
## 术语
|
||||
|
||||
**原始 Session event**指持久日志中的 `SessionEvent` 事实,包括 `tool/call` 的 `name` 与原始 `arguments` 字符串,以及 `tool/result` 的 `content`、`isError`、结构化错误和可选 `meta`。
|
||||
|
||||
**持久 metadata**指 `ToolOutputDefinition.presentationMeta` 在工具成功执行时生成并写入 `tool/result.data.meta` 的 JSON 值。它是结果事实的一部分,不是预先排版的 React 或 card DTO。
|
||||
|
||||
**Host tool view**指 `ToolDefinition.presentCall`/`presentResult` 返回的 `ToolCallView`/`ToolResultView`;Session Remote 不运输它。
|
||||
|
||||
**Client card model**指 `ui-tool/src/client/tool/models/` 下直接供 `TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 或 `ToolRow` 使用的纯 props 数据。
|
||||
|
||||
**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、状态点和普通 IN/OUT 文本仍属于通用工具行。
|
||||
|
||||
**对等**指同一受支持输入产生由现有组件、组装与浏览器证据固定的用户可见结果和交互,不要求相同的中间 TypeScript 类型或内部函数调用。
|
||||
|
||||
**无增强**指本决定不让被固定为 Generic fallback 的输入获得新专用卡片,也不扩大已有卡片的数据或交互。
|
||||
|
||||
## 架构与所有权
|
||||
|
||||
### 工具执行与持久化
|
||||
|
||||
1. 工具注册 `output.schema`、`output.render` 和可选 `output.presentationMeta`。
|
||||
2. 成功执行产生 canonical JSON value。
|
||||
3. Tools runtime 对 value 做快照、schema 校验和冻结。
|
||||
4. `output.render(args, value)` 生成模型可见 `ContentBlock[]`。
|
||||
5. 顶层调用若声明 `output.presentationMeta`,runtime 同时生成 JSON-safe metadata。
|
||||
6. Agent loop 把模型可见结果与 metadata 写入 `tool/result` Session event。
|
||||
7. Session log 不保存 `ToolCallView` 或 `ToolResultView`。
|
||||
|
||||
### Host journal 读取
|
||||
|
||||
1. `session.page` 取得 attached 或 persisted 事件。
|
||||
2. `paginate()` 按 append-origin user/assistant message 边界切页。
|
||||
3. tail page 通过已注册 projection 的 snapshot/restore 路径取得 baseline。
|
||||
4. 每个 page entry 只包含 `{event}`。
|
||||
5. `session.follow` 先建立 listener,再执行 catch-up read、发送 opening cursor 并流式下发连续 `{event}` frame。
|
||||
6. 两条路径都不为展示解析 preset/Tools scope、解析工具参数、调用 presenter 或建立 call index。
|
||||
|
||||
### Client 数据与展示
|
||||
|
||||
1. Client Session 保存一个连续 raw event window。
|
||||
2. `SessionEventSource` 发布只含 event 的 `SessionEventEntry`。
|
||||
3. `ui-conversation` 在没有 presentation companion 的情况下 fold 每个事件。
|
||||
4. Chat 与 Trajectory Tool Definition 按 callId 配对顶层 call/result,并组装 Code Dispatch 子树。
|
||||
5. `RunningToolCall` 与 `ToolResultNode` 保存 raw facts、metadata 与既有 parent identity。
|
||||
6. `ToolCallTree` 按 wire tool name 分发 `tool.call.toolview`。
|
||||
7. `ui-tool` 在 render site 从 block 派生 card component props。
|
||||
|
||||
### 生产消费者审计
|
||||
|
||||
| 对象 | 生产者 | 生产消费者 | 决定 |
|
||||
|---|---|---|---|
|
||||
| `presentCall`/`presentResult` | 各 Host 工具 | 可能存在的非 Client caller | 保留在 Session Remote 之外 |
|
||||
| `SessionEventEntry.view` | 无 | 无 | wire 不存在 |
|
||||
| `callView`/`resultView` | 无 | 无 | Client model 不存在 |
|
||||
| `presentationMeta` | Tools runtime | `tool/result`、Client card model 与 Host presenter | 保留的持久输入 |
|
||||
| fixture presenter mirror | 无 | 无 | fixture 下发 raw metadata |
|
||||
|
||||
ACP 不消费 Session tool view,也不映射 Host render intent。仓库没有生产 TUI consumer;Host presenter 保留,但 Session Remote 不作为其 transport。
|
||||
|
||||
## 数据流
|
||||
|
||||
```text
|
||||
Tool execute
|
||||
-> canonical value
|
||||
-> output.render(args, value)
|
||||
-> model-visible result content
|
||||
-> output.presentationMeta(args, value), when declared
|
||||
-> durable tool/result event
|
||||
|
||||
Session page/follow
|
||||
-> raw Session event envelope
|
||||
-> no tool lookup
|
||||
-> no preset lookup for presentation
|
||||
-> no call backscan
|
||||
-> no render-intent serialization
|
||||
|
||||
Client SessionEventSource
|
||||
-> Conversation Tool Definition
|
||||
-> root call/result pairing + Code Dispatch topology
|
||||
-> ToolCallBlock(name, argsRaw, content, error, meta)
|
||||
-> tool.call.toolview keyed dispatch
|
||||
-> Client card model
|
||||
-> existing React component
|
||||
```
|
||||
|
||||
这条链路保留一次持久 metadata 投影,因为它发生在 canonical result 尚在内存时;删除的是读取历史时的第二次展示投影。
|
||||
|
||||
### 分层责任
|
||||
|
||||
| 层 | 负责 | 不负责 |
|
||||
|---|---|---|
|
||||
| Tools runtime | 执行、canonical value、模型文本、可重放 metadata | Web 卡片选择和组件 props |
|
||||
| Session log | 持久事实、顺序、回放 | 临时 card DTO |
|
||||
| Session Controller | 地址、权限、冷读、分页、follow、projection baseline | tool lookup、presenter、展示 scope |
|
||||
| Client Session | Remote journal 生命周期与连续窗口 | 工具含义、卡片类型 |
|
||||
| Conversation Tool Definition | call/result 配对、lifecycle、root/subcall topology | 工具名到组件的解释 |
|
||||
| `ui-tool` | card model、通用 fallback、Chat/Details 展示 | Session 分页与 Host registry |
|
||||
| 业务 Client 插件 | 自有 tool name 的 keyed renderer | root/subcall 编排与全局 registry |
|
||||
| `ui-deliverables` | 当前第一方 mutation 的 produced path | UI card 或 Host render intent |
|
||||
|
||||
## Remote 与持久数据约定
|
||||
|
||||
### `SessionEventEntry`
|
||||
|
||||
`SessionEventEntry` 保留为 journal entry envelope,只含 `event: SessionWireEvent`。本次不顺带把 page entries 改成裸事件,也不重构 `RemoteJournalStream` 的通用 entry 约定。
|
||||
|
||||
`SessionPage.events` 仍是 `SessionEventEntry[]`。
|
||||
|
||||
`SessionFollowFrame` 仍是 opening frame 或带 `event` 的 event frame。
|
||||
|
||||
删除 `SessionToolCallView`、`SessionToolView` 和 `SessionEventEntry.view`。
|
||||
|
||||
Client connection 不再从 `dsh-tools/presentation` 转出 `ToolCallView`/`ToolResultView` 供 Session 消费。
|
||||
|
||||
生成 catalog 与 graph 从各自 source owner 派生已收窄的 Remote 类型和 package dependency。
|
||||
|
||||
### 持久日志
|
||||
|
||||
- `tool/call.data.name` 保持原样。
|
||||
- `tool/call.data.arguments` 保持模型产生的原始 JSON 字符串。
|
||||
- `tool/result.data.message.content` 保持模型可见结果。
|
||||
- `tool/result.data.error` 保持结构化失败身份。
|
||||
- `tool/result.data.meta` 保持工具私有 JSON 值。
|
||||
- Client card model 不写入 Session log。
|
||||
- renderer key 与 Host tool implementation id 不写入 Session log。
|
||||
- 现有持久 Session 无需迁移,`SESSION_FORMAT_VERSION` 不变。
|
||||
|
||||
### `presentationMeta`
|
||||
|
||||
`presentationMeta` 不是 Host tool view。它在工具执行完成时读取 canonical value,而该 value 不会持久化;删除它会使下列现有展示无法无损恢复:
|
||||
|
||||
- read 的 path、offset、lines、totalLines 与 lang;
|
||||
- write/edit 的 applied contextual hunks;
|
||||
- grep/glob 的分组结果、截断标志与总数;
|
||||
- web_search 的来源字段与 provider answer;
|
||||
- web_fetch 的最终 URL、HTTP status 与有效截断标志。
|
||||
|
||||
Client 对 `meta` 做局部运行时收窄。是否把 `presentationMeta` 改名为更中性的 result metadata 不属于本决定。
|
||||
|
||||
## Host 端设计
|
||||
|
||||
`SessionHistoryController.page()` 在取得 source events 后只执行分页与现有 projection baseline 计算。attached Session 使用 projection registry snapshot;detached Session 使用该 registry 对 inspected log 的 restore 路径。history 不通过挂载 preset 改变已注册的 projection 集合。
|
||||
|
||||
`SessionHistoryController.follow()` 保留 listener-first、opening cursor、gap-free replay、live buffering、取消和 teardown;它不为工具事件维护额外状态。
|
||||
|
||||
Controller 不存在 `presenterScopeFor()`、`viewFor()`、`backscanArgs()`、`parseToolCall()` 或 `jsonView()` 路径。page state 不含 presenter scope 或参数 resolver;follow state 不含 `openCalls`、`fallbackEvents` 或 presentation 参数 resolver。每个 page/follow event 只包装成 `{event}`,地址、ownership、cursor、seq 与 projection 逻辑保持完整。
|
||||
|
||||
不可变 event 转换 helper 可以保持窄实现或内联;只要 history 不执行 presentation 工作,其名称没有语义。
|
||||
|
||||
Session Controller dependency 只在其他 package responsibility 需要时保留;manifest 与 project reference 不含 presentation-only dependency。
|
||||
|
||||
### 性能约束
|
||||
|
||||
- `page()` 的工具相关工作为零。
|
||||
- 页面增加 tool result 不增加对既有页面事件的重复扫描。
|
||||
- `follow()` 不维护展示索引。
|
||||
- history 不触发 Cordis `tools` service proxy。
|
||||
- history 不等待 presenter standing scope。
|
||||
- history 不执行工具参数 JSON parse。
|
||||
- history 不执行 tool view JSON clone。
|
||||
- Remote payload 不重复携带 `meta` 已表达的结构化数据。
|
||||
- Client 不扫描完整 Session event window 生成单个卡片。
|
||||
- Client 只在对应 immutable Tool block 变化时重新派生 card model。
|
||||
|
||||
## Client Session 与 Conversation
|
||||
|
||||
Client Session 不含与 raw event window 平行的私有 `views` 数组。`installWindow()`、`prependWindow()` 和 `appendLive()` 只处理 event entries、cursor/hasMore、queue、projection 与通知。
|
||||
|
||||
`ConversationEventInput` 只携带 `event`。Conversation assembler 不认识 `SessionToolView`,其 replace/prepend/append、Context identity、Location 与 publication cadence 不变。
|
||||
|
||||
Chat 和 Trajectory 的 Tool Definition 都不读取 view,而从事件生成以下数据:
|
||||
|
||||
- callId;
|
||||
- tool name;
|
||||
- raw arguments;
|
||||
- turn、step、seq 与 time;
|
||||
- result content;
|
||||
- isError 与 structured error;
|
||||
- result metadata;
|
||||
- root/subcall parent-child topology;
|
||||
- interruption synthetic result。
|
||||
|
||||
`RunningToolCall` 不含 `callView`。
|
||||
|
||||
`ToolResultNode` 不含 `callView` 与 `resultView`。
|
||||
|
||||
`ToolCallBlock` 不新增通用 `view`、`card`、`kind` 或 `locations` 字段替代被删除字段。具体展示仍只属于 `ui-tool` 与 keyed renderer。
|
||||
|
||||
### Root 与 Code Dispatch 子调用
|
||||
|
||||
Host presenter API 描述顶层 call/result。Code Dispatch 子调用使用 Generic/flattened Client 展示;Client 能识别子调用名称并不赋予它结构化卡片。
|
||||
|
||||
Code Dispatch start 与 result event 已经携带 `parentCallId`。Conversation 在每个 child `ToolCallBlock` 上保留这项现有事实,root Session call 则不携带它。五类结构化 card model 只接受没有 `parentCallId` 的 block,原本有意支持嵌套调用的 renderer 则继续收到同一个 child block。
|
||||
|
||||
Details panel 原样委托选中的 block。同一组 card model 读取 `parentCallId`,让选中的 Code Dispatch child 保持现有 raw fallback,因此 Details slot 不需要 placement 字段。
|
||||
|
||||
keyed slot 仍按每个子调用的真实 tool name 分发;`parentCallId` 只控制本决定覆盖的 terminal/diff/read/search/web 结构化模型。Skill、Cordis 等已经直接读取 raw block 的专用 renderer 保持现状。
|
||||
|
||||
### 缺失调用头
|
||||
|
||||
结果节点在当前窗口没有配对 call 时,`ToolResultNode.call` 保持 `null`。Client 不扫描窗口、不发额外 RPC,也不根据 result 文本猜测工具名称。
|
||||
|
||||
需要名称或参数的专用派生在 `call === null` 时走当前 Generic fallback。只依赖 result metadata 的模型也不借机增强,因为当前 Host `presentResult` 必须先取得配对调用。
|
||||
|
||||
older page 后续补入调用头时,Conversation Context 按既有 replay 规则重建,届时才允许生成当前已有的专用卡片。
|
||||
|
||||
### 参数与 metadata 收窄
|
||||
|
||||
Client 从 `argsRaw` 解析 JSON,解析失败返回 Generic,不抛出 React render 错误。
|
||||
|
||||
Chat 与 Details 通过纯 helper 复用同一 block 的解析。未来缓存必须使用 immutable block identity,不能按 callId 建立跨 Session 全局状态。
|
||||
|
||||
每个专用模型只检查它需要的字段。Client 不复制完整 Host tool schema,也不调用 Host `defineTool` validator。
|
||||
|
||||
合法第一方事件必须与当前 presenter 输出等价。畸形、旧版本或手工修改日志只承诺不崩溃并使用 Generic fallback。
|
||||
|
||||
## Client card-model 设计
|
||||
|
||||
现有 `ui-tool/src/client/tool/models/` 继续是 Chat 与 Details 共享派生的唯一位置。helper 直接返回组件 props,不返回 `ToolCallView`/`ToolResultView`,也不创建同构的 `ClientToolView` union。
|
||||
|
||||
工具名称分支只存在于 `ui-tool` card model、现有 row 分类表,或拥有该工具 keyed renderer 的 Client 插件;不得进入 Session Controller、Client Session、Conversation assembler 或通用 Slot renderer。
|
||||
|
||||
未知工具继续由 `GenericToolCard` 显示 name、原始 args、结果 content 与错误。
|
||||
|
||||
### 通用工具行
|
||||
|
||||
`toolRowModel()` 直接从 `toolName`、`argsRaw`、result content、error、cwd 与 home 派生通用行,并保持以下行为:
|
||||
|
||||
- `search`、`read`、`bash`、`write`、`edit`、`code` 与 `others` 分类;
|
||||
- 现有标题与工具专用标题;
|
||||
- summary 字段优先级和单行截断;
|
||||
- 多 query 的逗号拼接;
|
||||
- cwd 相对化与 home 缩写;
|
||||
- file path 点击;
|
||||
- args pretty JSON 与非 JSON 原文 fallback;
|
||||
- result content flatten 与 structured error fallback;
|
||||
- running、ok、error 与 stopped 状态。
|
||||
|
||||
Generic Host `presentCall` 的 title、kind、rawInput、content 与 locations 当前并不驱动普通 Web 行;Generic `presentResult.content` 也不驱动 Web 输出,因此无需把这些未消费值复制到 Client。
|
||||
|
||||
### Terminal 卡片
|
||||
|
||||
Client terminal model 从工具名称、调用参数、结果 content、error、现有 `parentCallId` 与 Session cwd 派生现有 `TerminalBlock` props。
|
||||
|
||||
| 输入 | 保持的结果 |
|
||||
|---|---|
|
||||
| 标准 `bash`/`pwsh` 前台 running | terminal prompt、description、cwd、running 状态 |
|
||||
| 标准前台 success | terminal output、exit code/signal、成功或失败状态点 |
|
||||
| `run_in_background:true` | Generic 行与原始结果 |
|
||||
| 工具执行 error | Generic IN/OUT 与错误摘要 |
|
||||
| persistent `bash`/`pwsh` running | terminal prompt |
|
||||
| persistent `bash`/`pwsh` settled | Generic flattened result,不新增 exit card |
|
||||
| `terminal_send` 前台 | terminal prompt 与 output |
|
||||
| `terminal_send` background/error | Generic 结果 |
|
||||
| Code Dispatch child | 当前 flattened Generic 形态 |
|
||||
|
||||
标准 shell 结果继续解析末尾 `[exit code: N]` 与 `[killed by signal: X]`。已解析的 marker 从正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。
|
||||
|
||||
调用 `description` 继续显示在 card 上方并覆盖折叠摘要。workdir 继续按绝对、相对和缺失三种情况处理;相对路径基于 Session cwd,且保留 `.`、`..`、盘符与 UNC root 的归一化。
|
||||
|
||||
对于 `terminal_send`,非空 input 与 session id 保持为逐字工具数据;空 input fallback 与 session label 通过 render site 的 conversation locale 解析。
|
||||
|
||||
同名普通与 persistent provider 是特殊兼容点。Client 使用当前有效参数与结果特征保留已交付差异;不足以无歧义识别的输入选择 Generic settled 结果,不增加新表现。
|
||||
|
||||
TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本完全不变。
|
||||
|
||||
### Diff 卡片
|
||||
|
||||
| 输入 | 保持的结果 |
|
||||
|---|---|
|
||||
| running `write` | 从 `file_path` 与 `content` 生成 intended added-only diff |
|
||||
| running `edit` | 从 `file_path`、`old_string`、`new_string` 生成 intended replacement diff |
|
||||
| running `str_replace_editor create` | 从 `path` 与 `file_text` 生成 intended added-only diff |
|
||||
| running `str_replace_editor str_replace` | 从 `path`、`old_str` 与 `new_str` 生成 intended replacement diff |
|
||||
| settled `write`/`edit` success | 从 `meta.diffs` 生成 applied contextual hunks |
|
||||
| settled `str_replace_editor` | Generic,因为该工具没有 result presenter |
|
||||
| write create 或 applied metadata 缺失、畸形、为空 | 当前 args fallback |
|
||||
| error、畸形 args、edit 的 metadata 畸形、Code Dispatch child | Generic |
|
||||
|
||||
路径、`oldText:null`、`newText`、结果覆盖调用时 diff、Chat 8 行上限、Details 全高显示和文件打开行为不变。
|
||||
|
||||
### Read 卡片
|
||||
|
||||
running `read` 继续只有摘要行。成功 settled `read` 从 result meta 读取 path、offset、lines、totalLines 与 lang,并确认结果是单个文本块且符合 read envelope。
|
||||
|
||||
meta 缺失、字段畸形、result envelope 不匹配、error、缺失 call head 或 Code Dispatch child 都走 Generic。路径 label 的 cwd 相对化、home 缩写、语法语言、总行数、Chat 8 行上限与 Details 全高显示不变。
|
||||
|
||||
Client 不需要构造 Host `ReadResultView.content`;Generic fallback 始终可直接读取原始 result content。
|
||||
|
||||
### Search 卡片
|
||||
|
||||
running `grep`/`glob` 继续只有参数摘要。成功结果分别从 `meta.shape:'matches'` 与 `meta.shape:'paths'` 生成 grouped matches 或 path list。
|
||||
|
||||
Client 校验 path、lineNumber、line、truncated 与 total。空 matches/paths 是有效卡片;缺失/畸形 meta、未知 shape、error、缺失 call head 与 Code Dispatch child 走 Generic。
|
||||
|
||||
`truncated:true` 时继续从原始 result content 显示 recovery locator;未截断时不显示。Chat 8 行上限、Details 全高显示和展开行为不变。
|
||||
|
||||
### Web 卡片
|
||||
|
||||
running `web_search`/`web_fetch` 继续只有摘要行。成功 search 从 `meta.sources`、`meta.answer`、`meta.truncated` 生成卡片;成功 fetch 从 `meta.url`、`meta.statusCode`、`meta.truncated` 生成卡片。
|
||||
|
||||
Client 校验每个 source 的 url、title、snippet 与 publishedAt,并继续只把 http/https URL 渲染为链接。meta 缺失或畸形、error、缺失 call head 与 Code Dispatch child 走 Generic。
|
||||
|
||||
search 的 answer、来源顺序、label fallback 与截断提示不变;fetch 的最终 URL、状态、截断提示与 Details 下方原始正文不变。
|
||||
|
||||
### 已直接使用 raw block 的 renderer
|
||||
|
||||
- Todo row 继续从 args 计算 completed/active 摘要。
|
||||
- Question row 继续从 result content 与 error 计算等待、回答、取消和中止状态。
|
||||
- Skill row 继续从 args/result 计算名称与状态。
|
||||
- Cordis define/run/action rows 继续从 args/result 与各自 Client service 计算。
|
||||
- 这些 renderer 的 props、slot key、注册顺序与可见结果不变。
|
||||
|
||||
## Deliverables
|
||||
|
||||
`ui-deliverables` 独立于展示意图派生 mutation 业务事实,因此 produced-file 行为不与卡片截图耦合。
|
||||
|
||||
Deliverables Definition 按 callId 观察 root `tool/call` 与成功 `tool/result`,保存最小的 Client-owned mutation candidate,不扫描 Session window,也不依赖 UI renderer。
|
||||
|
||||
| 工具 | mutation 判定 | path 来源 |
|
||||
|---|---|---|
|
||||
| `write` | 任意成功调用 | `file_path` |
|
||||
| `edit` | 任意成功调用 | `file_path` |
|
||||
| `str_replace_editor` | `create`、`str_replace`、`insert` | `path` |
|
||||
| `str_replace_editor` | `view` | 不产生 path |
|
||||
| 其他 | 无当前第一方 mutation 语义 | 不产生 path |
|
||||
|
||||
失败、interrupted、orphan result、缺失 path 与畸形 args 不产生 deliverable。同一路径保持 first-seen 去重,closing Assistant seq 之后落定的结果继续排除。
|
||||
|
||||
本次不新增通用“工具副作用”注册表。Host-only 第三方 presenter 通过 `kind:'edit'`/`locations` 自动加入 Deliverables 的能力被有意移除;未来若有真实第三方 mutation 需求,应由 Client 业务贡献表达,不能恢复 Session view。
|
||||
|
||||
## Fixture 与测试数据
|
||||
|
||||
Client fixture 删除手写 `presentCall()`、`presentResult()`、`viewFor()` 与 fixture tool-view 类型。它继续产生与真实日志相同的 raw call、result content 和 result meta。
|
||||
|
||||
| Fixture | 必须保留的原始事实 |
|
||||
|---|---|
|
||||
| terminal | 参数与真实结果 status marker |
|
||||
| diff | 参数与 result `meta.diffs` |
|
||||
| read | result meta 的 path/offset/lines/totalLines/lang |
|
||||
| grep/glob | result meta 的 shape/files 或 paths/truncated/total |
|
||||
| web | result meta 的 sources/answer 或 url/statusCode/truncated |
|
||||
| generic/custom | name、argsRaw、content、error |
|
||||
|
||||
fixture 不导入 Host 工具包来计算页面展示,也不保留 presenter 镜像。同一 raw fixture 继续驱动 jsdom、built Web snapshot 与 `?fixture` 浏览器路径。
|
||||
|
||||
## 展示等价矩阵
|
||||
|
||||
“当前展示”由已提交的组件测试、组装测试与 Web browser expected 共同定义。transport 或 ownership 重构不能作为 refresh snapshot 的理由;获批产品变化需要独立证据。
|
||||
|
||||
| 场景 | 必须保持的展示 |
|
||||
|---|---|
|
||||
| 未知工具 running | Generic 行,工具名与 args 摘要 |
|
||||
| 未知工具 settled | Generic 行与原始 output |
|
||||
| malformed args | 安全 Generic fallback |
|
||||
| orphan result | callId 标题与 Generic output |
|
||||
| interrupted call | warning/stopped 状态 |
|
||||
| bash/pwsh 前台 | 当前 terminal prompt、正文、cwd 与状态 |
|
||||
| bash/pwsh background/error | 当前 Generic IN/OUT |
|
||||
| persistent shell | 当前 running terminal、settled Generic |
|
||||
| terminal_send | 当前前台 terminal、后台/error Generic |
|
||||
| write/edit | 当前 intended/applied diff 与 error fallback |
|
||||
| read | 当前 running 摘要、settled ReadBlock 与 error fallback |
|
||||
| grep/glob | 当前 grouped/path card、截断与 recovery |
|
||||
| web_search/web_fetch | 当前来源/摘要 card 与原始正文 |
|
||||
| Todo/Question/Skill/Cordis | 当前专用行 |
|
||||
| Code Dispatch subcall | 当前 Generic/flattened 形态 |
|
||||
| Chat 与 Details | 同一调用使用相同 card fields |
|
||||
| Trajectory | 当前 identity、树、选择和 details |
|
||||
| Deliverables | 当前成功 mutation chips 与链接 |
|
||||
|
||||
## Client 扩展约定
|
||||
|
||||
`tool.call.toolview` 继续是唯一工具 UI 注册机制。一个工具若要在 Client 获得专用表现,必须由 Client 插件注册自己的 wire tool name。
|
||||
|
||||
注册方接收 raw `ToolCallBlock`、Session path 信息和宿主动作,自行校验它认识的 args/meta 字段。注册方不调用 Host tool registry,不依赖 `presentCall`/`presentResult`,也不能要求 `SessionEventEntry.view`。
|
||||
|
||||
没有 Client renderer 的工具稳定降级为 Generic。同一 tool name 只能有一个生效 keyed registration,重复 key 继续 loud failure。
|
||||
|
||||
Session-scoped slot 可以表达 Client 侧会话差异,但不从 preset 推断 renderer 变体。Host-only presenter 不自动赋予 Web rich card,这是“Host 描述展示”与“Client 插件拥有展示”的明确边界。
|
||||
|
||||
## 失败与 fallback
|
||||
|
||||
- Client 把 args 与 meta 当作 wire JSON,在消费点收窄。
|
||||
- 参数 JSON 解析失败走 Generic。
|
||||
- 已知工具缺少必要字段走 Generic。
|
||||
- metadata 缺失或畸形走 Generic;成功 `write` 例外,它按当前 presenter 行为保留由参数派生的整文件 diff。
|
||||
- error result 不因 metadata 存在而显示成功卡片。
|
||||
- 缺失 call head 不猜测工具名称或参数。
|
||||
- 未知 metadata 字段被忽略。
|
||||
- 新 metadata variant 在旧 Client 中走 Generic。
|
||||
- card-model helper 捕获可预期解析失败,不依赖 React error boundary 完成普通 fallback。
|
||||
- keyed renderer 自身的意外异常仍由现有 Slot error isolation 处理。
|
||||
|
||||
## 同名 Host provider
|
||||
|
||||
Host registry 允许不同 scope 为同一 tool name 提供不同定义;Session view 通过 presenter scope 理论上可以按 preset 选择不同 render intent。删除 view 后,Client keyed slot 只观察 wire name,不能观察 Host definition identity。
|
||||
|
||||
当前第一方显著实例是普通与 persistent `bash`/`pwsh`。Client 派生使用有效参数与结果特征保持它们的已交付差异,不增加 provider-id wire 字段;无法判别的畸形或自定义同名 provider 输入采用 Generic。
|
||||
|
||||
本次不承诺保留第三方同名 provider 仅通过 Host presenter 表达的差异。若未来产品确需同名 provider 的不同 Client 展示,必须定义稳定、非展示性的 Client identity;不得恢复按页 Host view 计算。
|
||||
|
||||
## 已交付范围
|
||||
|
||||
### Session Controller
|
||||
|
||||
- `SessionEventEntry` 只包含 raw event。
|
||||
- 两个 Session tool-view 类型都不存在。
|
||||
- history 不含 presentation import、helper 或 page/follow presentation state。
|
||||
- 地址、分页、follow 与 projection 逻辑仍由 Session owner 负责。
|
||||
- Host 测试固定 raw journal 约定。
|
||||
|
||||
### Session Controller Client
|
||||
|
||||
- `Session.views` 不存在。
|
||||
- EventSource replace/prepend/append delta 保持不变。
|
||||
- transport、fixture 与 test-support 类型携带 raw entry。
|
||||
- event identity 与引用稳定性保持不变。
|
||||
|
||||
### UI Conversation、Chat 与 Trajectory
|
||||
|
||||
- Conversation input 与 Tool block 不含 view 字段。
|
||||
- Chat/Trajectory Tool Definition 读取 raw event。
|
||||
- event pairing、Context replay、树与 target snapshot 保持不变。
|
||||
- child Tool block 保留现有 Code Dispatch `parentCallId`;row 与 Details slot owner props 都不增加独立 placement 字段。
|
||||
|
||||
### UI Tool 与 Deliverables
|
||||
|
||||
- card model 从 raw block/meta 派生。
|
||||
- Chat 与 Details 复用相同 helper。
|
||||
- Generic fallback 与 keyed dispatch 保持不变。
|
||||
- Deliverables 识别第一方 mutation args。
|
||||
|
||||
### Fixture、文档与生成物
|
||||
|
||||
- fixture 只发 raw event/meta。
|
||||
- Session Controller 与 Client README/JSDoc 描述 raw journal 和 Client presentation owner。
|
||||
- 工具 cookbook 记录 Web Client 接入路径。
|
||||
- 本文是该决定的 owner;保留的 Host presenter Note 继续拥有各自决定。
|
||||
- 手写 Remote 类型、dependency、README、pairing record 与 generated reference 保持同步。
|
||||
|
||||
## 验证矩阵
|
||||
|
||||
### Host
|
||||
|
||||
- page 返回连续 raw event entries。
|
||||
- follow 返回 opening cursor 与连续 raw event entries。
|
||||
- page/follow 在无 Tools service 时行为相同。
|
||||
- cold page 不解析或挂载 preset。
|
||||
- tail page 通过标准 projection registry 计算 baseline;provider 是否存在由 projection composition 决定,不引入 history 侧 setup 路径。
|
||||
- 地址、ownership、message-aligned boundary 与 tail projection 不变。
|
||||
- listener-before-read、reconnect catch-up 与 gap repair 不变。
|
||||
- 大量 tool results 不触发每结果回扫。
|
||||
- wire 结果不含 view。
|
||||
|
||||
`session-history-journal.host.spec.ts` 负责分页、连续性和 history error 行为,不含 presenter 断言。
|
||||
|
||||
### Client Conversation
|
||||
|
||||
- replace、prepend 与 append 接受无 view entry。
|
||||
- Chat 与 Trajectory root call/result 配对不变。
|
||||
- Code Dispatch 树不变。
|
||||
- result-only fallback 不变。
|
||||
- interruption synthetic result 不复制 view。
|
||||
- registry rebuild、older prepend 与 live append 的 Node identity 不变。
|
||||
|
||||
### Client card model
|
||||
|
||||
- terminal 用 raw args/content 得到已固定的 props。
|
||||
- diff 用 args/meta 得到已固定的 diffs。
|
||||
- read 用 meta/content 得到已固定的 lines。
|
||||
- search 用 meta/content 得到已固定的 grouped/path card 与 recovery。
|
||||
- web 用 meta/content 得到已固定的 sources/fetch summary。
|
||||
- unknown、malformed、error、missing-call 与 missing-meta 继续 Generic。
|
||||
- `parentCallId` 缺失与存在的用例证明结构化展示不会到达 Code Dispatch descendant。
|
||||
- Chat 与 Details 对同一 block 得到相同 card fields。
|
||||
|
||||
### Deliverables
|
||||
|
||||
- write/edit 成功产生 `file_path`。
|
||||
- str_replace_editor create/str_replace/insert 产生 `path`。
|
||||
- str_replace_editor view 不产生 path。
|
||||
- failure、interrupted、malformed 与 orphan 不产生 path。
|
||||
- first-seen 去重与 closing seq cut 不变。
|
||||
|
||||
### 组装与浏览器
|
||||
|
||||
- terminal、diff、read、search、web browser expected 不刷新并全部通过。
|
||||
- tool tree、details、trajectory 与 deliverables 的可见断言不改预期。
|
||||
- built Client 通过真实 Remote page/follow 取得 raw events 后仍显示同样卡片。
|
||||
- fixture 与真实 Host 使用同一 Client derivation。
|
||||
- minimal preset 单独固定 persistent shell 行为。
|
||||
|
||||
### 静态与文档
|
||||
|
||||
- 生产代码不存在 `SessionToolView`/`SessionToolCallView`。
|
||||
- Session history 不引用 `dsh-tools/presentation`、`ctx.tools`、`presenterScopeFor` 或 `backscanArgs`。
|
||||
- Client Conversation 不引用 `ToolCallView`/`ToolResultView`。
|
||||
- Client model 不读取 `callView`/`resultView`。
|
||||
- fixture 不定义 presenter mirror。
|
||||
- Host `presentCall`/`presentResult` 与 `presentationMeta` 仍存在。
|
||||
- 没有新增 Client registry 或 Host→Client presentation hint。
|
||||
- 受影响的手写类型、README、Agent Note、catalog 与 graph 保持同步。
|
||||
|
||||
## 验证命令
|
||||
|
||||
修改本决定时使用 `dsh-pre-push-checks` 按最终 diff 选择命令;所需证据包括:
|
||||
|
||||
- Session Controller history/transport 聚焦测试;
|
||||
- ui-chat 与 ui-trajectory Tool Definition 测试;
|
||||
- ui-tool terminal、diff、read、search、web、row、tree 与 details 测试;
|
||||
- ui-deliverables produced-files 测试;
|
||||
- connection fixture 与 Client runtime 测试;
|
||||
- 受影响 Host/Client TypeScript face;
|
||||
- lint 与 duplication;
|
||||
- 受影响源文件 per-file 100% coverage;
|
||||
- `DSH_SNAPSHOT=replay pnpm run test:web`,不得 refresh 现有展示 golden;
|
||||
- 手写 Remote 类型与 TypeScript 检查;
|
||||
- `pnpm run doc-sync`;
|
||||
- `git diff --check`。
|
||||
|
||||
## 已交付不变量
|
||||
|
||||
- Session page/follow 不读取 Tools registry 或 presenter scope。
|
||||
- Session history 不存在 callId backscan、presentation cache 或 view clone。
|
||||
- Remote Session entry 不携带 view。
|
||||
- Session 日志与 `SESSION_FORMAT_VERSION` 不变。
|
||||
- result meta 逐字节通过日志与 Remote 到达 Client。
|
||||
- Conversation 只从 raw event 组装 ToolCallBlock。
|
||||
- ToolCallBlock 不含 Host render-intent 字段。
|
||||
- 五类结构化 card model 只读 raw block、其现有 `parentCallId` 与 Session path facts。
|
||||
- Generic、Todo、Question、Skill 与 Cordis 行行为不变。
|
||||
- Deliverables 不依赖 render intent 且保持当前 paths。
|
||||
- 所有第一方顶层工具的文本、组件、展开内容、状态、链接与排序不变。
|
||||
- malformed、missing-meta、error、orphan 与 unknown-tool 继续安全 fallback。
|
||||
- Code Dispatch 子调用保持 Generic/flattened。
|
||||
- Chat、Details 与 Trajectory 行为不变。
|
||||
- 现有 Web browser expected 无需刷新即可通过。
|
||||
- Host presenter API、实现与直接测试不变。
|
||||
- ACP 输出不变。
|
||||
- 没有新下行展示字段或第二套 Client registry。
|
||||
- 分页成本不再随 result 数量乘以页面事件数增长。
|
||||
- 下行 payload 不再重复 result meta 的 card DTO。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### 只优化 `backscanArgs`,保留 view
|
||||
|
||||
page 前建立一次 `callId → {name,args}` Map 可以把回扫降为线性,live 已有 `openCalls` 快路径;但 Host lookup、preset scope、presenter、JSON clone、重复 payload 和双重所有权仍存在,因此拒绝。
|
||||
|
||||
### 在 Client 建 presenter registry
|
||||
|
||||
把 `presentCall`/`presentResult` 接口复制到浏览器会与 `tool.call.toolview` slot 重复注册、生命周期、fallback 和覆盖语义;renderer 仍需把 presenter DTO 转成组件 props,因此拒绝。
|
||||
|
||||
### 让 Conversation Tool Definition 生成统一 view
|
||||
|
||||
这会把工具名称和 UI card 语义放进 target-neutral Conversation owner,并重建与 Host view 同构的中间 DTO,因此拒绝。
|
||||
|
||||
### 删除 `presentationMeta`
|
||||
|
||||
read 行结构、applied diff、search 分组、web sources 和有效 truncation 无法从模型文本无损恢复;解析自由文本也会把 UI 绑到输出措辞,因此拒绝。
|
||||
|
||||
### 持久化 canonical tool result
|
||||
|
||||
这会扩大 Session log、暴露内部结果结构、改变持久格式,并可能保存远超展示所需的大对象;已有 metadata 足够,因此拒绝。
|
||||
|
||||
### 删除 Host presenter API
|
||||
|
||||
一并删除可以继续收缩代码,但本决定保留 Host `presentCall`/`presentResult`;其 API、实现、测试与类型独立于 Session Remote。
|
||||
|
||||
### Client 导入 Host 工具实现
|
||||
|
||||
工具包包含 Node、filesystem、subprocess 或 provider 依赖,不能进入浏览器 bundle;Client 只消费 raw JSON,并在自己的 renderer 内维护窄解析,因此拒绝。
|
||||
|
||||
### 按结果向 Host 查询 presentation
|
||||
|
||||
按需 RPC 会把一页读取变成 N 次网络调用,仍需 Host lookup、scope、callId 查找与错误协调,因此拒绝。
|
||||
|
||||
### 允许展示增强
|
||||
|
||||
Client 可以为 Code Dispatch 子调用、缺失 call head 或 Host presenter 不可用的历史生成更多 rich card,但这会混淆 ownership 变化与产品行为,并使快照无法证明对等,因此拒绝。
|
||||
|
||||
### 接受临时 Generic 退化
|
||||
|
||||
先停发 view 再逐步补 Client card 会让 terminal、diff、read、search、web 与 Deliverables 在中间版本退化。Client 对等实现与 Host 删除必须在同一可发布变更中完成。
|
||||
|
||||
## Consequences
|
||||
|
||||
本决定从 Session 读取中删除 presentation 工作、重复扫描和重复 view payload;代价是保留的 Host presenter 与 Client card derivation 可以独立演进,因此两侧都需要 owner 专属测试,Web 展示对等仍是明确产品约束。
|
||||
|
||||
### Client 与 Host 逻辑漂移
|
||||
|
||||
同一工具可以有一份 Host render intent 和一份 Client card derivation。两者面向不同消费方,不共享运行路径;不刷新的 browser expected 固定第一方 Web 视觉对等,Host presenter 测试只约束 Host API。
|
||||
|
||||
### 同名 provider 无稳定 identity
|
||||
|
||||
raw event 只记录 tool name,不记录具体 ToolDefinition。Client 使用有效事件字段保留普通与 persistent shell 的差异;无法判别的自定义或畸形输入回退 Generic,wire 不为理论扩展性增加 hint。
|
||||
|
||||
### Metadata 是未知 JSON
|
||||
|
||||
旧 Session 可能缺字段,手工修改日志可能带畸形值。每个 Client model 必须局部收窄,不能把未知数组或对象直接传给 UI primitive。
|
||||
|
||||
### preset-owned projection 可用性
|
||||
|
||||
history 不为当前组合中缺失的 projection unit 补偿。需要在冷读中保持可见的 preset-owned unit,必须由共享的 Session preparation/projection 组合在 restore 前提供其定义;history 不得重新增加 preset mount 或 presenter setup 分支。
|
||||
|
||||
### 双 target 同步
|
||||
|
||||
Chat 与 Trajectory 各有独立 Tool Definition,两者都携带 raw fields;card derivation 只能留在 `ui-tool`,不能复制进两个 Definition。
|
||||
|
||||
### Deliverables 隐性依赖
|
||||
|
||||
Deliverables 不是视觉组件,因此 mutation parser 必须与受支持的第一方写工具保持同步;专用测试独立于卡片截图固定 file chips 与 Markdown links。
|
||||
|
||||
### Fixture 假绿
|
||||
|
||||
fixture 下发 raw event/meta,不下发手写 view。真实 Host 组装覆盖仍然必要,因为 fixture-only snapshot 不能证明 transport 路径。
|
||||
|
||||
### 错误刷新快照
|
||||
|
||||
本次承诺用户可见输出不变。出现 snapshot diff 时必须修 Client 派生;除非 owner 单独批准具体视觉变化,否则不得 refresh expected。
|
||||
|
||||
### 文档漂移
|
||||
|
||||
raw journal 或 Client presentation owner 变化时,Agent Note、package README、cookbook、根规则与 generated reference 必须一起更新;Host API 文档保持独立。
|
||||
|
||||
### Remote 协议收缩
|
||||
|
||||
optional `view` 的缺失是所有 consumer 共同遵守的预发布 wire 类型决定;没有兼容 shim、双写或版本协商。
|
||||
|
||||
## 与现有决策的关系
|
||||
|
||||
本文部分取代 [Client 工具展示所有权](2026-08-08-client-tool-presentation-ownership.zh.md) 中“card model 接收 Host view”的实现事实;`ui-tool` 拥有展示、业务插件使用 keyed slot、Conversation 只拥有生命周期与拓扑的核心决定保持不变。
|
||||
|
||||
本文保留 [toolview 溶解](2026-07-23-toolview-dissolution.zh.md) 的决定:Client 仍只有 slot 注册模型,不恢复 `ToolViewRegistry`。
|
||||
|
||||
本文收窄 [render-intent union](2026-07-02-tool-render-intent-union.zh.md) 的消费范围:Host API 与类型保留,Session Remote 与 Web Client 不消费它。本文独自规定 transport 拆分,不改写该 presenter 决策。
|
||||
|
||||
本文更新 [Session 历史与 Remote 事件传输](2026-08-18-session-history-and-event-transport.zh.md) 的 entry 约定:journal 只运输原始 event 与独立 projection baseline,不承载临时 tool view。
|
||||
|
||||
本文遵循 [Conversation Node 组装](2026-08-09-client-conversation-node-assembly.zh.md):Tool Definition 负责事件配对与调用树,具体 card model 留在 `ui-tool`。
|
||||
|
||||
本文保留 [规范工具输出约定](2026-07-20-canonical-tool-output-contract.zh.md) 的 result metadata,因为它是无损、可重放 Client 派生的输入。
|
||||
|
||||
## Deferred
|
||||
|
||||
- Host presenter 若长期没有生产消费者,可由另一项明确决策评估删除;本决定不预判。
|
||||
- Code Dispatch 子调用若要专用卡片,需单独设计并更新可见快照;本决定保持现状。
|
||||
- 第三方 mutation tool 若要加入 Deliverables,需新增 Client-owned 贡献;本决定不为尚无消费者的扩展性建 registry。
|
||||
- 同名 provider 若要不同 Client 展示,需先定义稳定、非展示性的 identity;不得恢复按页 Host view。
|
||||
- Client card model 若需量化性能,可以增加 immutable-block 微基准;已交付架构禁止扫描 Session window。
|
||||
@@ -124,7 +124,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
|
||||
- **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)).
|
||||
- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
|
||||
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless runnable-example snapshot; package, e2e-only, and mock-only tests do not substitute. Fixtures replay on macOS/Linux; fix fixtures, not normalizers.
|
||||
- **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
|
||||
- **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)).
|
||||
- **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change.
|
||||
- **Both SDKs project the loop.** Agent-loop, session-lifecycle, and `SessionEventMap` changes update the TypeScript and Python SDK expected outputs in the same PR; `pnpm run test` covers neither ([surfaces](docs/testing.md#when-a-snapshot-test-is-required)).
|
||||
- **Choose PR history deliberately.** Split independent changes and fix the introducing PR before propagation. Standalone/stack branches may merge-forward or rebase. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; preserve an in-progress merge-forward checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)).
|
||||
|
||||
@@ -258,13 +258,11 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
// The seed carries a session/title event: the title unit is host-plane, so
|
||||
// it folds the detached log and serves the value with nothing composed.
|
||||
expect(typeof projections?.values.title).toBe('string')
|
||||
// `todos` IS here, as its empty fold (null). Its unit is registered by
|
||||
// `tool-todo` inside the default preset's STANDING mount, which the read
|
||||
// itself ensures — deterministically, not because some unrelated session
|
||||
// happens to be composed. A present-but-null key is what keeps the
|
||||
// client's "omitted key = capability absent → clear the row" rule from
|
||||
// wiping preset-owned projections on cold reads.
|
||||
expect(projections?.values).toHaveProperty('todos', null)
|
||||
// `todos` is absent because its unit belongs to the agent preset and this
|
||||
// directly seeded session never composed that preset. History computes
|
||||
// the baseline through the standard projection registry without mounting
|
||||
// an Agent composition as a read side effect.
|
||||
expect(projections?.values).not.toHaveProperty('todos')
|
||||
// The session-stats unit is a shipped web-app bundle row: whole-log
|
||||
// turn/step counts ride the same tail block (the stats strip's source).
|
||||
const sessionStats = projections?.values.sessionStats as { turns: number; steps: number } | undefined
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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: b71639edd666c4ac86f38c9a55bcf30ef9064cc9
|
||||
config-catalog.zh.md: 4301147cd9d2ba87f89a60e80387334543c615e7
|
||||
config-catalog.md: a845fe22e13ed085765668c7ec8d54d6bbdf129a
|
||||
config-catalog.zh.md: 39ba9d48368f99483733292f997609ba3a8aa43e
|
||||
|
||||
@@ -277,7 +277,7 @@ Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/co
|
||||
|
||||
## `@deepseek-ai/dsh-api-session-controller`
|
||||
|
||||
Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `tools` · `typert` · `workspaceRegistry`
|
||||
Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `typert` · `workspaceRegistry`
|
||||
|
||||
```ts config-catalog
|
||||
/** Session Controller deployment policy. */
|
||||
@@ -2998,7 +2998,7 @@ export interface Config {
|
||||
export type ToolPresentationMode = 'native' | 'code' | 'both'
|
||||
```
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:654`](../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:655`](../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-typert-loader"></a>
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-api-session-controller`
|
||||
|
||||
需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `tools` · `typert` · `workspaceRegistry`
|
||||
需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `typert` · `workspaceRegistry`
|
||||
|
||||
```ts config-catalog
|
||||
/** Session Controller deployment policy. */
|
||||
@@ -3000,7 +3000,7 @@ export interface Config {
|
||||
export type ToolPresentationMode = 'native' | 'code' | 'both'
|
||||
```
|
||||
|
||||
来源:[`packages/core/tools/src/index.ts:654`](../packages/core/tools/src/index.ts)
|
||||
来源:[`packages/core/tools/src/index.ts:655`](../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-typert-loader"></a>
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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: 37516521de4d00de964003fd6f877831774fdcd3
|
||||
adding-a-tool.zh.md: 6a24d5dc303990f9fe13e77c9a92b3a24ca16647
|
||||
adding-a-tool.md: 4e07c33dd372ae95391fcad5236832a6f7662e82
|
||||
adding-a-tool.zh.md: 17a024a0db0d63ec9ef8c9407e77a471263427c9
|
||||
|
||||
@@ -87,7 +87,13 @@ Hard rules (they bite if broken):
|
||||
- **UI-only formatting stays out of the model result.** A fenced ` ```console ` block, a diff, a relativized path—none of these belongs in the canonical value or Native content merely to serve a UI. `output.render` owns model-facing prose; `presentationMeta` plus the card presenters own replayable UI state. A `terminal` result view carries raw output and the adapter adds any fallback framing.
|
||||
- **`defineTool` soft-validates the display path.** Malformed or older logged arguments make the wrapper return `undefined` (a generic fallback) rather than throw — display must never crash a replay.
|
||||
|
||||
The neutral vocabulary lives in `dsh-tools`; tools never import a UI or transport type. Host/client runtimes map each `card` into their own view. The design and the why are in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations.
|
||||
The neutral vocabulary lives in `dsh-tools`; tools never import a UI or transport type. Consumers of this API map each `card` into their own view. The design and the why are in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations.
|
||||
|
||||
## Web Client presentation
|
||||
|
||||
The built-in Web Client does not consume `presentCall` or `presentResult`. Session `page` and `follow` transport raw `tool/call` and `tool/result` events, including persisted `result.meta`. A Client plugin registers its wire tool name in the `tool.call.toolview` keyed slot and derives component props from the `ToolCallBlock` arguments, content, error, metadata, existing Code Dispatch `parentCallId`, and Session path facts. It validates these wire values locally and returns the generic row for malformed or unsupported input.
|
||||
|
||||
Use `output.presentationMeta(args, value)` when an existing Web card needs bounded structured result facts that model-facing content cannot preserve losslessly. Do not store React props or a selected card in metadata, import a Host tool implementation into a browser bundle, or create another Client presenter registry. Defining Host presentation methods alone does not add a specialized Web card. The [Client-derived presentation Agent Note](../../.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md) defines ownership, fallback, and equivalence requirements.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -89,7 +89,13 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的
|
||||
- **UI 格式不进入模型结果。** 围栏 ` ```console ` 块、diff、相对化路径均不应仅为服务 UI 而进入规范值或 Native 内容。`output.render` 负责模型可见的自然语言;`presentationMeta` 和卡片展示器负责可回放的 UI 状态。`terminal` 结果视图携带原始输出,由适配器按需添加回退格式。
|
||||
- **`defineTool` 对展示路径做软校验。** 格式错误或旧版日志中的参数会使包装器返回 `undefined`(通用回退)而非抛异常——展示绝不能导致回放崩溃。
|
||||
|
||||
中性词汇定义在 `dsh-tools` 中;工具绝不导入 UI 或传输类型。host/client 运行时将每个 `card` 映射到各自的视图。设计与原因见[渲染意图联合体 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。
|
||||
中性词汇定义在 `dsh-tools` 中;工具绝不导入 UI 或传输类型。使用该 API 的消费方把每个 `card` 映射到自己的视图。设计与原因见[渲染意图联合体 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。
|
||||
|
||||
## Web Client 展示
|
||||
|
||||
内置 Web Client 不消费 `presentCall` 或 `presentResult`。Session `page` 与 `follow` 运输原始 `tool/call` 和 `tool/result` 事件,包括持久化的 `result.meta`。Client 插件在 keyed slot `tool.call.toolview` 中注册自己的 wire 工具名称,并从 `ToolCallBlock` 的参数、内容、错误、metadata、现有 Code Dispatch `parentCallId` 与 Session 路径事实派生组件 props。插件在本地校验这些 wire 值,并让格式错误或不受支持的输入回退到 generic 行。
|
||||
|
||||
现有 Web 卡片需要模型可见内容无法无损保存的有界结构化结果事实时,使用 `output.presentationMeta(args, value)`。不要在 metadata 中保存 React props 或预选卡片,不要把 Host 工具实现导入浏览器 bundle,也不要建立另一套 Client presenter registry。只定义 Host 展示方法不会增加专用 Web 卡片。[Client 派生展示 Agent Note](../../.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md)规定 owner、fallback 与对等要求。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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: 8cf11d8be322686c89f8bc57c9f0bc2c4a3aeb74
|
||||
event-producer-consumer.zh.md: 6b79a0fded3b5fa6956e9d7b047e78b68ded1f48
|
||||
event-producer-consumer.md: 2be5a84969b9f14823abf90cf289a0a41e48dd11
|
||||
event-producer-consumer.zh.md: 5bbae1be5d03c3e443d36093ce60dbf7e4b07971
|
||||
|
||||
@@ -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:217`](../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:178`](../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:278`](../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:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:442`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:469`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:448`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:455`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:444`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:424`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:451`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:430`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:437`](../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` |
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../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:178`](../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:278`](../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:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:442`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:469`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:448`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:455`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:444`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:424`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:451`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:430`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:437`](../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` |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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: 0d26be0a33b6aeca80fd04d9fdabc8f977a714df
|
||||
module-graph.zh.md: 396d5879e26c8da1446d83323991de13acf0bd61
|
||||
module-graph.md: d70aa9a7704a7de5b669928a6cafd8358fb2a3b0
|
||||
module-graph.zh.md: 2333d71e61bd935fa482fc766bb7d96bb75d56db
|
||||
|
||||
@@ -1083,7 +1083,6 @@ flowchart TD
|
||||
pkg_client_connection --> pkg_llm
|
||||
pkg_client_connection --> pkg_session
|
||||
pkg_client_connection --> pkg_tool_todo
|
||||
pkg_client_connection --> pkg_tools
|
||||
pkg_compaction_tool_result_pruner --> pkg_compaction
|
||||
pkg_compaction_tool_result_pruner --> pkg_invariants
|
||||
pkg_compaction_tool_result_pruner --> pkg_llm
|
||||
@@ -1255,7 +1254,6 @@ flowchart TD
|
||||
pkg_api_session_controller --> pkg_session_query
|
||||
pkg_api_session_controller --> pkg_session_title
|
||||
pkg_api_session_controller --> pkg_subagent
|
||||
pkg_api_session_controller --> pkg_tools
|
||||
pkg_api_session_controller --> pkg_typert_protocol
|
||||
pkg_api_session_controller --> pkg_typert_registry
|
||||
pkg_api_session_controller --> pkg_util_workspace_path
|
||||
@@ -1820,7 +1818,7 @@ flowchart TD
|
||||
| [`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) |
|
||||
| [`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) |
|
||||
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
|
||||
| [`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) |
|
||||
| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`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) |
|
||||
@@ -1842,7 +1840,7 @@ 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-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), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`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), [`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) |
|
||||
| [`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) |
|
||||
|
||||
@@ -1085,7 +1085,6 @@ flowchart TD
|
||||
pkg_client_connection --> pkg_llm
|
||||
pkg_client_connection --> pkg_session
|
||||
pkg_client_connection --> pkg_tool_todo
|
||||
pkg_client_connection --> pkg_tools
|
||||
pkg_compaction_tool_result_pruner --> pkg_compaction
|
||||
pkg_compaction_tool_result_pruner --> pkg_invariants
|
||||
pkg_compaction_tool_result_pruner --> pkg_llm
|
||||
@@ -1257,7 +1256,6 @@ flowchart TD
|
||||
pkg_api_session_controller --> pkg_session_query
|
||||
pkg_api_session_controller --> pkg_session_title
|
||||
pkg_api_session_controller --> pkg_subagent
|
||||
pkg_api_session_controller --> pkg_tools
|
||||
pkg_api_session_controller --> pkg_typert_protocol
|
||||
pkg_api_session_controller --> pkg_typert_registry
|
||||
pkg_api_session_controller --> pkg_util_workspace_path
|
||||
@@ -1822,7 +1820,7 @@ flowchart TD
|
||||
| [`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) |
|
||||
| [`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) |
|
||||
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
|
||||
| [`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) |
|
||||
| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`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) |
|
||||
@@ -1844,7 +1842,7 @@ 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-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), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`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), [`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) |
|
||||
| [`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) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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: edb8f4ebb427bfce6e4def023e65f4697608ceb2
|
||||
session.zh.md: 7b3c7a8e50688ba19694d5f45e43d224c2245ef1
|
||||
session.md: b7806a4989684be7585d8d42ac215fe1ab1540f0
|
||||
session.zh.md: a80a3146b50c4c0fdcf4c3e54e1dc4943eb28642
|
||||
|
||||
@@ -696,7 +696,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
|
||||
/**
|
||||
* Read one cold-safe, message-aligned Session history page.
|
||||
* @param request - durable address, backward cursor, and page budget.
|
||||
* @param signal - cancellation for persistence and presentation reads.
|
||||
* @param signal - cancellation for persistence reads.
|
||||
* @returns one chronological page and optional latest projections.
|
||||
*/
|
||||
@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>
|
||||
|
||||
@@ -700,7 +700,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
|
||||
/**
|
||||
* Read one cold-safe, message-aligned Session history page.
|
||||
* @param request - durable address, backward cursor, and page budget.
|
||||
* @param signal - cancellation for persistence and presentation reads.
|
||||
* @param signal - cancellation for persistence reads.
|
||||
* @returns one chronological page and optional latest projections.
|
||||
*/
|
||||
@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>
|
||||
|
||||
@@ -52,7 +52,7 @@ export type {
|
||||
MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
|
||||
RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
|
||||
SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
|
||||
SubagentAddress, SubagentCatalog, ToolCallView, ToolResultView,
|
||||
SubagentAddress, SubagentCatalog,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
export type {} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote'
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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: cda9349e432472a0ed9fd623afef0b689ff72f73
|
||||
README.zh.md: 2aaee8f968cf7373110e291c197adbeca21f490e
|
||||
README.md: 7631e1623f90f9349eca78bc76d46505d13d2e0e
|
||||
README.zh.md: 7a733b45b1cdbb17096d1e76bb25b54d3bdc0e06
|
||||
|
||||
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
|
||||
|
||||
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, message-aligned history, live log following, and Host-wide control state.
|
||||
|
||||
History pages and follow event frames carry only raw `SessionWireEvent` values. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
|
||||
|
||||
Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces.
|
||||
|
||||
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. 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.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、按消息对齐的历史、live 日志跟随和 Host 范围 control 状态。
|
||||
|
||||
历史页与 follow event frame 只携带原始 `SessionWireEvent`。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
|
||||
|
||||
每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。
|
||||
|
||||
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
|
||||
|
||||
@@ -96,7 +96,6 @@
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
@@ -106,8 +105,7 @@
|
||||
"@deepseek-ai/dsh-jobs": { "optional": true },
|
||||
"@deepseek-ai/dsh-session-persistence": { "optional": true },
|
||||
"@deepseek-ai/dsh-session-projection": { "optional": true },
|
||||
"@deepseek-ai/dsh-session-projection-cache": { "optional": true },
|
||||
"@deepseek-ai/dsh-tools": { "optional": true }
|
||||
"@deepseek-ai/dsh-session-projection-cache": { "optional": true }
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
@@ -131,7 +129,6 @@
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:^",
|
||||
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
SessionQueuedItem,
|
||||
SessionRequestId,
|
||||
SessionError,
|
||||
SessionToolView,
|
||||
} from '../../types.ts'
|
||||
import type { ClientFailure, ClientResult } from '../contract/result.ts'
|
||||
import { transportResult } from '../contract/result.ts'
|
||||
@@ -74,9 +73,6 @@ export interface SessionOptions {
|
||||
export class Session implements SessionFace {
|
||||
// ---- Window and derived state (all private; the snapshot is the only read API) ----
|
||||
private eventWindow: SessionEvent[] = []
|
||||
/** Wire views aligned with `eventWindow` by index (envelope annotations; undefined = no view).
|
||||
* Kept parallel so `eventWindow` remains the raw log slice (model-visible ⟺ logged). */
|
||||
private views: (SessionToolView | undefined)[] = []
|
||||
private baseSeq = 0
|
||||
private hasMore = false
|
||||
private openState: OpenState = 'cold'
|
||||
@@ -405,7 +401,6 @@ export class Session implements SessionFace {
|
||||
this.openState = 'cold'
|
||||
this.openError = null
|
||||
this.eventWindow = []
|
||||
this.views = []
|
||||
this.baseSeq = 0
|
||||
this.notifier.markDirty()
|
||||
await this.open()
|
||||
@@ -582,7 +577,6 @@ export class Session implements SessionFace {
|
||||
/** Replace the complete contiguous window and apply page-owned projection metadata. */
|
||||
private installWindow(entries: readonly SessionEventEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
|
||||
this.eventWindow = entries.map(entry => entry.event as SessionEvent)
|
||||
this.views = entries.map(entry => entry.view)
|
||||
this.baseSeq = this.eventWindow[0]?.seq ?? 0
|
||||
this.hasMore = hasMore
|
||||
if (this.eventWindow.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false
|
||||
@@ -594,7 +588,6 @@ export class Session implements SessionFace {
|
||||
/** Prepend one stream-validated history page. */
|
||||
private prependWindow(entries: readonly SessionEventEntry[], hasMore: boolean): void {
|
||||
this.eventWindow = [...entries.map(entry => entry.event as SessionEvent), ...this.eventWindow]
|
||||
this.views = [...entries.map(entry => entry.view), ...this.views]
|
||||
this.baseSeq = this.eventWindow[0]?.seq ?? 0
|
||||
this.hasMore = hasMore
|
||||
this.eventSource.prepend(entries, hasMore)
|
||||
@@ -604,7 +597,6 @@ export class Session implements SessionFace {
|
||||
private appendLive(entry: SessionEventEntry): boolean {
|
||||
const event = entry.event as SessionEvent
|
||||
this.eventWindow.push(event)
|
||||
this.views.push(entry.view)
|
||||
const awaitingFirstTurn = this.firstPromptPendingTurn
|
||||
if (event.type === 'turn/start') this.firstPromptPendingTurn = false
|
||||
const queueChanged = this.queueMirror.acceptDurable(event)
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
/** Cold Session history pagination and live-event source. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {
|
||||
SessionAddress,
|
||||
@@ -19,34 +15,21 @@ import type {
|
||||
SessionPageRequest,
|
||||
SessionProjectionsBlock,
|
||||
SessionProjectionValues,
|
||||
SessionToolCallView,
|
||||
SessionToolView,
|
||||
SessionWireEvent,
|
||||
} from './types.ts'
|
||||
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
|
||||
|
||||
interface ToolCallData {
|
||||
readonly callId: string
|
||||
readonly name: string
|
||||
readonly arguments: string
|
||||
}
|
||||
|
||||
type SessionSource =
|
||||
| { readonly kind: 'attached'; readonly session: Session }
|
||||
| { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: readonly SessionEvent[] }
|
||||
|
||||
interface BufferedEvent {
|
||||
readonly session: Session
|
||||
readonly event: SessionEvent
|
||||
}
|
||||
|
||||
/** Implements cold-safe history operations delegated by the Session Controller. */
|
||||
export class SessionHistoryController {
|
||||
private readonly closeFollowers = new Set<() => void>()
|
||||
|
||||
/** @param ctx - Host context carrying Session, persistence, presenter, and projection services. */
|
||||
/** @param ctx - Host context carrying Session, persistence, and projection services. */
|
||||
constructor(private readonly ctx: Context) {
|
||||
ctx.effect(() => () => {
|
||||
for (const close of this.closeFollowers) close()
|
||||
@@ -57,7 +40,7 @@ export class SessionHistoryController {
|
||||
/**
|
||||
* Read one message-aligned history page without activating an Agent.
|
||||
* @param request - durable address and backwards-page cursor.
|
||||
* @param signal - caller cancellation for persistence and preset reads.
|
||||
* @param signal - caller cancellation for persistence reads.
|
||||
* @returns a contiguous event page and a projection baseline on tail reads.
|
||||
*/
|
||||
async page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
|
||||
@@ -77,11 +60,8 @@ export class SessionHistoryController {
|
||||
if ((events.at(-1)?.seq ?? -1) !== request.throughSeq) {
|
||||
reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {})
|
||||
}
|
||||
const scope = await this.presenterScopeFor(addressId(request.address), source, events)
|
||||
signal.throwIfAborted()
|
||||
const page = paginate(events, request.beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
|
||||
const argsFor = (callId: string) => backscanArgs(page.events, callId)
|
||||
const entries = page.events.map(event => entryFor(this.ctx, event, argsFor, scope))
|
||||
const entries = page.events.map(entryFor)
|
||||
const projections = request.beforeSeq === undefined
|
||||
? this.projectionsFor(request.address, source, events)
|
||||
: undefined
|
||||
@@ -102,12 +82,7 @@ export class SessionHistoryController {
|
||||
validateFollowRequest(request)
|
||||
const { address, afterSeq } = request
|
||||
const target = addressId(address)
|
||||
const buffered: BufferedEvent[] = []
|
||||
const openCalls = new Map<string, { readonly name: string; readonly args: unknown }>()
|
||||
let fallbackEvents: readonly SessionEvent[] = []
|
||||
const argsFor = (callId: string): { readonly name: string; readonly args: unknown } | undefined => (
|
||||
openCalls.get(callId) ?? backscanArgs(fallbackEvents, callId)
|
||||
)
|
||||
const buffered: SessionEvent[] = []
|
||||
let wake: (() => void) | undefined
|
||||
const notify = (): void => {
|
||||
const resume = wake
|
||||
@@ -122,7 +97,7 @@ export class SessionHistoryController {
|
||||
this.closeFollowers.add(close)
|
||||
const disposeEvent = this.ctx.on('session/event', (session, event) => {
|
||||
if (session.id !== target) return
|
||||
buffered.push({ session, event })
|
||||
buffered.push(event)
|
||||
notify()
|
||||
}, { global: true })
|
||||
const disposeCreated = this.ctx.on('session/created', (session) => {
|
||||
@@ -130,7 +105,7 @@ export class SessionHistoryController {
|
||||
// Session construction appends session/end-seed before attachment, so the
|
||||
// marker has no session/event notification. Earlier session/created listeners
|
||||
// may publish later setup events first; this suffix must precede those notifications.
|
||||
const suffix = session.events.slice(session.firstLiveSeq).map(event => ({ session, event }))
|
||||
const suffix = session.events.slice(session.firstLiveSeq)
|
||||
buffered.unshift(...suffix)
|
||||
notify()
|
||||
}, { global: true })
|
||||
@@ -139,8 +114,6 @@ export class SessionHistoryController {
|
||||
try {
|
||||
const source = await this.sourceFor(address, signal)
|
||||
const events = [...sourceEvents(source)]
|
||||
fallbackEvents = events
|
||||
const scope = await this.presenterScopeFor(target, source, events)
|
||||
signal.throwIfAborted()
|
||||
const cursor = events.at(-1)?.seq ?? -1
|
||||
if (afterSeq !== undefined && afterSeq > cursor) {
|
||||
@@ -155,7 +128,7 @@ export class SessionHistoryController {
|
||||
reject('internal', `session event replay skipped seq ${String(nextSeq)}`, {})
|
||||
}
|
||||
nextSeq++
|
||||
yield { type: 'event', ...entryFor(this.ctx, event, argsFor, scope) }
|
||||
yield { type: 'event', ...entryFor(event) }
|
||||
}
|
||||
}
|
||||
while (!follower.closed && !signal.aborted) {
|
||||
@@ -164,26 +137,12 @@ export class SessionHistoryController {
|
||||
await new Promise<void>((resolve) => { wake = resolve })
|
||||
continue
|
||||
}
|
||||
if (item.event.seq < nextSeq) continue
|
||||
if (item.event.seq !== nextSeq) {
|
||||
if (item.seq < nextSeq) continue
|
||||
if (item.seq !== nextSeq) {
|
||||
reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {})
|
||||
}
|
||||
nextSeq++
|
||||
if (item.event.type === 'tool/call') {
|
||||
const data = item.event.data as ToolCallData
|
||||
const call = parseToolCall(data)
|
||||
/* v8 ignore next -- malformed durable tool arguments intentionally skip the live presentation cache. */
|
||||
if (call !== undefined) openCalls.set(data.callId, call)
|
||||
} else if (item.event.type === 'turn/end') {
|
||||
openCalls.clear()
|
||||
}
|
||||
if (item.event.type === 'tool/result'
|
||||
&& !openCalls.has(item.event.data.message.source.callId)) {
|
||||
fallbackEvents = item.session.events
|
||||
}
|
||||
const liveScope: Agent | undefined = this.ctx.get('agents')?.get(target)
|
||||
const entry = entryFor(this.ctx, item.event, argsFor, liveScope ?? scope)
|
||||
yield { type: 'event', ...entry }
|
||||
yield { type: 'event', ...entryFor(item) }
|
||||
}
|
||||
} finally {
|
||||
this.closeFollowers.delete(close)
|
||||
@@ -214,25 +173,6 @@ export class SessionHistoryController {
|
||||
return { kind: 'detached', header: inspected.meta, events: inspected.events }
|
||||
}
|
||||
|
||||
private async presenterScopeFor(
|
||||
sessionId: SessionId,
|
||||
source: SessionSource,
|
||||
events: readonly SessionEvent[],
|
||||
): Promise<ScopeKey | undefined> {
|
||||
const live = this.ctx.get('agents')?.get(sessionId)
|
||||
if (live !== undefined) return live
|
||||
const presets = this.ctx.get('agentPresets')
|
||||
if (presets === undefined) return undefined
|
||||
const session = source.kind === 'attached'
|
||||
? { header: source.session.header, events }
|
||||
: { header: source.header, events }
|
||||
try {
|
||||
return await presets.standingKeyFor(resolveSessionPreset(session))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private projectionsFor(
|
||||
address: SessionAddress,
|
||||
source: SessionSource,
|
||||
@@ -368,76 +308,9 @@ function paginate(
|
||||
return { events: window.filter(event => event.seq >= cut), hasMore: cut > 0 }
|
||||
}
|
||||
|
||||
function entryFor(
|
||||
ctx: Context,
|
||||
event: SessionEvent,
|
||||
argsFor: (callId: string) => { readonly name: string; readonly args: unknown } | undefined,
|
||||
scope?: ScopeKey,
|
||||
): SessionEventEntry {
|
||||
const view = viewFor(ctx, event, argsFor, scope)
|
||||
function entryFor(event: SessionEvent): SessionEventEntry {
|
||||
return {
|
||||
// Session.append validates and freezes event data as JSON before publication.
|
||||
event: event as unknown as SessionWireEvent,
|
||||
...(view === undefined ? {} : { view }),
|
||||
}
|
||||
}
|
||||
|
||||
function viewFor(
|
||||
ctx: Context,
|
||||
event: SessionEvent,
|
||||
argsFor: (callId: string) => { readonly name: string; readonly args: unknown } | undefined,
|
||||
scope?: ScopeKey,
|
||||
): SessionToolView | undefined {
|
||||
if (event.type !== 'tool/call' && event.type !== 'tool/result') return undefined
|
||||
const tools = ctx.get('tools')
|
||||
/* v8 ignore next -- deployments without the optional Tools service omit presentation metadata. */
|
||||
if (tools === undefined) return undefined
|
||||
try {
|
||||
if (event.type === 'tool/call') {
|
||||
const data = event.data as ToolCallData
|
||||
const view: ToolCallView | undefined = tools.get(data.name, scope)?.presentCall?.(JSON.parse(data.arguments))
|
||||
return view === undefined ? undefined : { for: 'call', view: jsonView(view) }
|
||||
}
|
||||
const [result] = event.data.message.content
|
||||
const call = argsFor(event.data.message.source.callId)
|
||||
if (call === undefined) return undefined
|
||||
const view: ToolResultView | undefined = tools.get(call.name, scope)?.presentResult?.(call.args, {
|
||||
content: result.content,
|
||||
isError: result.isError === true,
|
||||
...(event.data.meta === undefined ? {} : { meta: event.data.meta }),
|
||||
})
|
||||
return view === undefined ? undefined : { for: 'result', view: jsonView(view) }
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`session: presenter failed for ${event.type}: ${String(error)}`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function backscanArgs(
|
||||
events: readonly SessionEvent[],
|
||||
callId: string,
|
||||
): { readonly name: string; readonly args: unknown } | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type !== 'tool/call') continue
|
||||
const data = event.data as ToolCallData
|
||||
if (data.callId !== callId) continue
|
||||
return parseToolCall(data)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseToolCall(data: ToolCallData): { readonly name: string; readonly args: unknown } | undefined {
|
||||
try {
|
||||
return { name: data.name, args: JSON.parse(data.arguments) }
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function jsonView(view: ToolCallView): SessionToolCallView
|
||||
function jsonView(view: ToolResultView): ToolResultView
|
||||
function jsonView(view: ToolCallView | ToolResultView): SessionToolCallView | ToolResultView {
|
||||
const encoded = JSON.stringify(view)
|
||||
return JSON.parse(encoded) as SessionToolCallView | ToolResultView
|
||||
}
|
||||
|
||||
@@ -69,7 +69,6 @@ export class SessionController extends TypertRemoteService {
|
||||
'llm',
|
||||
'sessions',
|
||||
'sessionQuery',
|
||||
'tools',
|
||||
'typert',
|
||||
'workspaceRegistry',
|
||||
]
|
||||
@@ -260,7 +259,7 @@ export class SessionController extends TypertRemoteService {
|
||||
/**
|
||||
* Read one cold-safe, message-aligned Session history page.
|
||||
* @param request - durable address, backward cursor, and page budget.
|
||||
* @param signal - cancellation for persistence and presentation reads.
|
||||
* @param signal - cancellation for persistence reads.
|
||||
* @returns one chronological page and optional latest projections.
|
||||
*/
|
||||
@Remote('page')
|
||||
|
||||
@@ -10,12 +10,6 @@ import type { JsonValue, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/t
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
import type {
|
||||
DiffCallView,
|
||||
GenericCallView,
|
||||
TerminalCallView,
|
||||
ToolResultView,
|
||||
} from '@deepseek-ai/dsh-tools/presentation'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionStateMap {
|
||||
@@ -333,21 +327,9 @@ export type SessionAddress =
|
||||
readonly mode: 'one-shot' | 'continuable'
|
||||
}
|
||||
|
||||
/** JSON-safe call render intent crossing the Session Remote boundary. */
|
||||
export type SessionToolCallView =
|
||||
| (Omit<GenericCallView, 'rawInput'> & { readonly rawInput?: JsonValue })
|
||||
| TerminalCallView
|
||||
| DiffCallView
|
||||
|
||||
/** Host-computed render intent accompanying one tool event. */
|
||||
export type SessionToolView =
|
||||
| { readonly for: 'call'; readonly view: SessionToolCallView }
|
||||
| { readonly for: 'result'; readonly view: ToolResultView }
|
||||
|
||||
/** One raw Session event plus its optional transient render intent. */
|
||||
/** One raw Session event in the Remote journal. */
|
||||
export interface SessionEventEntry {
|
||||
readonly event: SessionWireEvent
|
||||
readonly view?: SessionToolView
|
||||
}
|
||||
|
||||
/** Session event wire form; durable readers own recognition of merge-extensible event names. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import SessionController from '../src/index.ts'
|
||||
import { createSessionTestController } from './test-remote.ts'
|
||||
|
||||
const defaults = {
|
||||
@@ -13,6 +14,10 @@ const defaults = {
|
||||
}
|
||||
|
||||
describe('SessionController facade', () => {
|
||||
it('does not require the Tools service', () => {
|
||||
expect(SessionController.inject).not.toContain('tools')
|
||||
})
|
||||
|
||||
it('owns Host service methods and publishes Agent lifecycle projections', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
@@ -142,7 +142,7 @@ export function plainTurn(startSeq: number, turn: number, ask: string, answer: s
|
||||
]
|
||||
}
|
||||
|
||||
/** Wrap raw events as view-less history entries (the wire shape history returns). */
|
||||
/** Wrap raw events in the journal envelope returned by history. */
|
||||
export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
|
||||
return events.map(event => ({ event }))
|
||||
}
|
||||
|
||||
+62
-170
@@ -1,38 +1,15 @@
|
||||
/**
|
||||
* Tool-card view computation over Session Controller history and follow: three standard card types
|
||||
* arrive on the frame, a presenterless tool ships no view field, a call-only
|
||||
* presenter keeps raw result content out of the view payload, and a throwing
|
||||
* presenter soft-falls to no view (the event still ships). Result pairing
|
||||
* works for both paged and live entries.
|
||||
*/
|
||||
/** Raw Session journal transport and message-aligned pagination coverage. */
|
||||
|
||||
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 SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
|
||||
import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import { createSessionTestRemote } from './test-remote.ts'
|
||||
|
||||
const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
|
||||
|
||||
function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'presentResult'>): ToolDefinition {
|
||||
return defineContentToolFixture({
|
||||
name,
|
||||
description: `tool ${name}`,
|
||||
parameters: {},
|
||||
execute: () => reply(`ran:${name}`),
|
||||
...presenters,
|
||||
})
|
||||
}
|
||||
|
||||
/** Append a production-shaped human prompt to the session surface. */
|
||||
function appendUserText(session: Session, text: string): SessionEvent {
|
||||
return session.append('user/message', createUserMessage({
|
||||
@@ -65,27 +42,7 @@ function appendExtension(session: Session, type: string, data: unknown): Session
|
||||
async function harness(): Promise<{ ctx: Context }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
ctx.tools.register(tool('gen', {
|
||||
presentCall: () => ({ card: 'generic', title: 'gen call' }),
|
||||
presentResult: (_args, result) => ({ card: 'generic', title: result.isError ? 'gen failed' : 'gen done' }),
|
||||
}))
|
||||
ctx.tools.register(tool('term', {
|
||||
presentCall: args => ({ card: 'terminal', title: (args as { cmd?: string }).cmd ?? '' }),
|
||||
presentResult: () => ({ card: 'terminal', output: 'done' }),
|
||||
}))
|
||||
ctx.tools.register(tool('diffy', {
|
||||
presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }),
|
||||
}))
|
||||
ctx.tools.register(tool('call-only', {
|
||||
presentCall: () => ({ card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }),
|
||||
}))
|
||||
ctx.tools.register(tool('plain', {}))
|
||||
ctx.tools.register(tool('boom', {
|
||||
presentCall: () => { throw new Error('presenter exploded') },
|
||||
}))
|
||||
return { ctx }
|
||||
}
|
||||
|
||||
@@ -119,73 +76,37 @@ async function openFollow(
|
||||
return { [Symbol.asyncIterator]: () => iterator }
|
||||
}
|
||||
|
||||
describe('Session history view computation', () => {
|
||||
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
|
||||
describe('Session history raw journal', () => {
|
||||
it('follows raw tool events and preserves result metadata without a Tools service', async () => {
|
||||
const { ctx } = await harness()
|
||||
const session = ctx.sessions.create()
|
||||
const history = new SessionHistoryController(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = await openFollow(history, session.id, abort.signal)
|
||||
const collected = collect(stream, 9, abort)
|
||||
const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
|
||||
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' })
|
||||
session.append('tool/result', {
|
||||
const collected = collect(stream, 2, abort)
|
||||
const call = session.append('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('raw-call'), name: 'custom', arguments: '{malformed',
|
||||
})
|
||||
const result = session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c-call-only'),
|
||||
content: [{ type: 'text', text: rawResult }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c-gen'),
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
callId: CallId('raw-call'),
|
||||
content: [{ type: 'text', text: 'raw output' }],
|
||||
isError: false,
|
||||
}),
|
||||
meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const frames = await collected
|
||||
const events = frames.filter(f => f.type === 'event')
|
||||
const byCall = new Map(events
|
||||
.filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
|
||||
.map(f => [
|
||||
`${f.event.type}:${f.event.type === 'tool/call'
|
||||
? (f.event.data as unknown as SessionEvent<'tool/call'>['data']).callId
|
||||
: (f.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`,
|
||||
f,
|
||||
]))
|
||||
|
||||
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
|
||||
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
|
||||
expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
|
||||
expect(byCall.get('tool/call:c-call-only')?.view).toEqual({
|
||||
for: 'call',
|
||||
view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' },
|
||||
})
|
||||
const callOnlyResult = byCall.get('tool/result:c-call-only')
|
||||
expect('view' in (callOnlyResult ?? {})).toBe(false)
|
||||
const serializedResult = JSON.stringify(callOnlyResult)
|
||||
expect(serializedResult.indexOf(rawResult)).toBeGreaterThanOrEqual(0)
|
||||
expect(serializedResult.indexOf(rawResult)).toBe(serializedResult.lastIndexOf(rawResult))
|
||||
// No presenter → the frame carries no view property at all.
|
||||
expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false)
|
||||
// Throwing presenter → soft-fall: event ships, no view.
|
||||
expect(byCall.get('tool/call:c-boom')).toBeDefined()
|
||||
expect('view' in (byCall.get('tool/call:c-boom') ?? {})).toBe(false)
|
||||
// Result pairing through the live table: presentResult saw the call's args.
|
||||
expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } })
|
||||
expect(frames).toEqual([
|
||||
{ type: 'event', event: call },
|
||||
{ type: 'event', event: result },
|
||||
])
|
||||
expect((frames[1] as Extract<SessionFollowFrame, { type: 'event' }>).event.data)
|
||||
.toMatchObject({ meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] } })
|
||||
})
|
||||
|
||||
it('pairs live results from the open-call table without rescanning Session history', async () => {
|
||||
it('follows live results without rescanning Session history', async () => {
|
||||
const { ctx } = await harness()
|
||||
const session = ctx.sessions.create()
|
||||
const history = new SessionHistoryController(ctx)
|
||||
@@ -197,7 +118,7 @@ describe('Session history view computation', () => {
|
||||
turn: 1, step: 1, callId: CallId('live-fast'), name: 'term', arguments: '{"cmd":"pwd"}',
|
||||
})
|
||||
await expect(iterator.next()).resolves.toMatchObject({
|
||||
value: { type: 'event', view: { for: 'call', view: { card: 'terminal', title: 'pwd' } } },
|
||||
value: { type: 'event', event: { type: 'tool/call', data: { callId: 'live-fast' } } },
|
||||
})
|
||||
|
||||
const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
|
||||
@@ -213,7 +134,7 @@ describe('Session history view computation', () => {
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
await expect(iterator.next()).resolves.toMatchObject({
|
||||
value: { type: 'event', view: { for: 'result', view: { card: 'terminal', output: 'done' } } },
|
||||
value: { type: 'event', event: { type: 'tool/result', data: { message: { source: { callId: 'live-fast' } } } } },
|
||||
})
|
||||
} finally {
|
||||
events.mockRestore()
|
||||
@@ -223,53 +144,22 @@ describe('Session history view computation', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
|
||||
it('serves raw call and result entries without parsing tool arguments', async () => {
|
||||
const { ctx } = await harness()
|
||||
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
// history resolves the agent first; a live structural stub is enough (only
|
||||
// .session is read on this path).
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
|
||||
// meta rides through to presentResult's ToolResult (the spread arm).
|
||||
session.append('tool/result', {
|
||||
const start = session.append('turn/start', { turn: 1 })
|
||||
const call = session.append('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('history-call'), name: 'custom', arguments: '{broken',
|
||||
})
|
||||
const result = session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('h-term'),
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
}),
|
||||
meta: { n: 1 },
|
||||
}, { surfaceOp: 'append' })
|
||||
// Unpaired result: no tool/call with this id anywhere in the page.
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('h-orphan'),
|
||||
content: [{ type: 'text', text: 'x' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
// Paired, but the call's stored arguments do not parse: backscan soft-falls.
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('h-bad'),
|
||||
content: [{ type: 'text', text: 'y' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
// Presenterless tool: pairing succeeds but presentResult is absent.
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('h-plain'),
|
||||
content: [{ type: 'text', text: 'z' }],
|
||||
isError: false,
|
||||
callId: CallId('history-call'),
|
||||
content: [{ type: 'text', text: 'failed raw output' }],
|
||||
isError: true,
|
||||
}),
|
||||
meta: { persisted: true, count: 3 },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const response = await remote.page({
|
||||
@@ -278,27 +168,17 @@ describe('Session history view computation', () => {
|
||||
})
|
||||
expect(response.ok).toBe(true)
|
||||
if (!response.ok) throw new Error('unreachable')
|
||||
const entries = response.value.events
|
||||
const byKey = new Map(entries
|
||||
.filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
|
||||
.map(entry => [
|
||||
`${entry.event.type}:${entry.event.type === 'tool/call'
|
||||
? (entry.event.data as unknown as SessionEvent<'tool/call'>['data']).callId
|
||||
: (entry.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`,
|
||||
entry,
|
||||
]))
|
||||
expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
|
||||
expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
|
||||
expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
|
||||
expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false)
|
||||
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
|
||||
expect(response.value.events).toEqual([
|
||||
{ event: start },
|
||||
{ event: call },
|
||||
{ event: result },
|
||||
])
|
||||
})
|
||||
|
||||
it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
|
||||
const { ctx } = await harness()
|
||||
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const first = appendUserText(session, 'first prompt')
|
||||
appendAssistantText(session, 'first reply', 1)
|
||||
@@ -348,7 +228,6 @@ describe('Session history view computation', () => {
|
||||
const { ctx } = await harness()
|
||||
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
@@ -384,28 +263,41 @@ describe('Session history view computation', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('pairs a followed result after turn/end from the addressed Session log', async () => {
|
||||
it('follows a result after turn/end without reading the addressed Session log', async () => {
|
||||
const { ctx } = await harness()
|
||||
const session = ctx.sessions.create()
|
||||
const history = new SessionHistoryController(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = await openFollow(history, session.id, abort.signal)
|
||||
const collected = collect(stream, 4, abort)
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/start' } } })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
|
||||
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'tool/call' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c-late'),
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const frames = await collected
|
||||
const result = frames.find(f => f.type === 'event' && f.event.type === 'tool/result')
|
||||
expect(result?.type === 'event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
|
||||
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/end' } } })
|
||||
const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
|
||||
throw new Error('live result rescanned Session history')
|
||||
})
|
||||
try {
|
||||
const result = session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c-late'),
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
await expect(iterator.next()).resolves.toEqual({
|
||||
done: false,
|
||||
value: { type: 'event', event: result },
|
||||
})
|
||||
} finally {
|
||||
events.mockRestore()
|
||||
abort.abort()
|
||||
await iterator.next()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
|
||||
import { entries, ev, plainTurn } from './event-script.client.ts'
|
||||
@@ -26,12 +25,10 @@ function makeSession(
|
||||
function follow(
|
||||
api: FakeApiClient,
|
||||
event: SessionEvent,
|
||||
view?: SessionToolView,
|
||||
): Promise<void> {
|
||||
return api.pushFollow(SID, {
|
||||
type: 'event',
|
||||
event: event as never,
|
||||
...(view === undefined ? {} : { view }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,7 +41,7 @@ function eventSeqs(session: Session): number[] {
|
||||
}
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
// history returns HistoryEntry[] ({event, view?}); these tests are view-less.
|
||||
// History returns raw journal envelopes around each event.
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
|
||||
}
|
||||
|
||||
@@ -601,39 +598,30 @@ describe('remaining branches', () => {
|
||||
await expect(session.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries history-entry and follow-frame views through the event feed', async () => {
|
||||
it('carries raw history and follow events through the event feed', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } }
|
||||
const historyCall = ev.toolCall(6, 1, 'h1', 'bash', '{"cmd":"pwd"}')
|
||||
const historyResult = ev.toolResult(7, 1, 'h1', 'done')
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: [
|
||||
...entries(plainTurn(0, 0, 'a', 'b')),
|
||||
{ event: ev.toolCall(6, 1, 'h1', 'bash', '{}'), view: callView },
|
||||
{ event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
|
||||
{ event: historyCall },
|
||||
{ event: historyResult },
|
||||
] as never[],
|
||||
hasMore: false,
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await session.open()
|
||||
expect(windowEntries(session).slice(-2).map(item => item.view)).toEqual([
|
||||
callView,
|
||||
{ for: 'result', view: { card: 'generic', title: '历史果' } },
|
||||
expect(windowEntries(session).slice(-2)).toEqual([
|
||||
{ event: historyCall },
|
||||
{ event: historyResult },
|
||||
])
|
||||
await follow(
|
||||
api,
|
||||
ev.toolCall(8, 2, 'l1', 'write', '{}'),
|
||||
{ for: 'call', view: { card: 'generic', title: '直播卡' } },
|
||||
)
|
||||
expect(windowEntries(session).at(-1)?.view).toEqual({
|
||||
for: 'call', view: { card: 'generic', title: '直播卡' },
|
||||
})
|
||||
await follow(
|
||||
api,
|
||||
ev.toolResult(9, 2, 'l1', 'ok'),
|
||||
{ for: 'result', view: { card: 'generic', title: '直播果' } },
|
||||
)
|
||||
expect(windowEntries(session).at(-1)?.view).toEqual({
|
||||
for: 'result', view: { card: 'generic', title: '直播果' },
|
||||
})
|
||||
const liveCall = ev.toolCall(8, 2, 'l1', 'write', '{"file_path":"a.ts"}')
|
||||
await follow(api, liveCall)
|
||||
expect(windowEntries(session).at(-1)).toEqual({ event: liveCall })
|
||||
const liveResult = ev.toolResult(9, 2, 'l1', 'ok')
|
||||
await follow(api, liveResult)
|
||||
expect(windowEntries(session).at(-1)).toEqual({ event: liveResult })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -482,60 +482,6 @@ describe('SessionHistoryController', () => {
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('child projection failed'))
|
||||
})
|
||||
|
||||
it('resolves presenter scope from a live Agent or the durable preset and tolerates lookup failure', async () => {
|
||||
const live = await setup()
|
||||
const liveSession = live.ctx.sessions.create(SessionId('live-scope'), { meta: { cwd: '/workspace' } })
|
||||
const liveAgent = { id: liveSession.id }
|
||||
const preset = vi.fn(() => Promise.resolve('preset-scope'))
|
||||
live.ctx.provide('agents', { get: () => liveAgent } as never)
|
||||
live.ctx.provide('agentPresets', { standingKeyFor: preset } as never)
|
||||
await live.transport.page({
|
||||
address: { kind: 'session', sessionId: liveSession.id }, throughSeq: -1,
|
||||
}, signal())
|
||||
expect(preset).not.toHaveBeenCalled()
|
||||
|
||||
const attached = await setup()
|
||||
const attachedSession = attached.ctx.sessions.create(SessionId('preset-scope'), {
|
||||
meta: { cwd: '/workspace', agentPreset: 'minimal' },
|
||||
})
|
||||
const standingKeyFor = vi.fn(() => Promise.resolve('standing-scope'))
|
||||
attached.ctx.provide('agentPresets', { standingKeyFor } as never)
|
||||
await attached.transport.page({
|
||||
address: { kind: 'session', sessionId: attachedSession.id },
|
||||
throughSeq: -1,
|
||||
}, signal())
|
||||
expect(standingKeyFor).toHaveBeenCalledWith('minimal')
|
||||
|
||||
const detached = await setup()
|
||||
const detachedId = SessionId('detached-scope')
|
||||
const header = {
|
||||
version: 0, id: detachedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard',
|
||||
}
|
||||
cold(detached.ctx, header, [])
|
||||
const rejected = vi.fn(() => Promise.reject(new Error('preset unavailable')))
|
||||
detached.ctx.provide('agentPresets', { standingKeyFor: rejected } as never)
|
||||
await expect(detached.transport.page({
|
||||
address: { kind: 'session', sessionId: detachedId },
|
||||
throughSeq: -1,
|
||||
}, signal())).resolves.toMatchObject({ events: [] })
|
||||
expect(rejected).toHaveBeenCalledWith('standard')
|
||||
|
||||
const switched = await setup()
|
||||
const switchedId = SessionId('switched-scope')
|
||||
const switchedHeader = {
|
||||
version: 0, id: switchedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard',
|
||||
}
|
||||
cold(switched.ctx, switchedHeader, [
|
||||
event('agent-preset/selected', 0, { agentPreset: 'minimal' }),
|
||||
])
|
||||
const switchedKey = vi.fn(() => Promise.resolve('switched-scope'))
|
||||
switched.ctx.provide('agentPresets', { standingKeyFor: switchedKey } as never)
|
||||
await switched.transport.page({
|
||||
address: { kind: 'session', sessionId: switchedId }, throughSeq: 0,
|
||||
}, signal())
|
||||
expect(switchedKey).toHaveBeenCalledWith('minimal')
|
||||
})
|
||||
|
||||
it('keeps message-aligned pagination contiguous across replacement provenance', async () => {
|
||||
const { ctx, transport } = await setup()
|
||||
const session = ctx.sessions.create(SessionId('pagination'), { meta: { cwd: '/workspace' } })
|
||||
@@ -576,77 +522,4 @@ describe('SessionHistoryController', () => {
|
||||
expect(page.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('projects tool call and result views and contains malformed presenters', async () => {
|
||||
const { ctx, transport } = await setup()
|
||||
const sessionId = SessionId('presenters')
|
||||
const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
|
||||
const events = [
|
||||
event('fixture/start', 0),
|
||||
event('tool/call', 1, { callId: 'c1', name: 'present', arguments: '{"path":"a.ts"}' }),
|
||||
event('tool/result', 2, {
|
||||
message: {
|
||||
source: { callId: 'c1' },
|
||||
content: [{ content: [{ type: 'text', text: 'ok' }], isError: true }],
|
||||
},
|
||||
meta: { persisted: true },
|
||||
}),
|
||||
event('tool/result', 3, {
|
||||
message: {
|
||||
source: { callId: 'missing' },
|
||||
content: [{ content: [{ type: 'text', text: 'missing' }] }],
|
||||
},
|
||||
}),
|
||||
event('tool/call', 4, { callId: 'c2', name: 'present', arguments: '{' }),
|
||||
event('tool/result', 5, {
|
||||
message: {
|
||||
source: { callId: 'c2' },
|
||||
content: [{ content: [{ type: 'text', text: 'bad args' }] }],
|
||||
},
|
||||
}),
|
||||
event('tool/call', 6, { callId: 'c3', name: 'empty', arguments: '{}' }),
|
||||
event('tool/result', 7, {
|
||||
message: {
|
||||
source: { callId: 'c3' },
|
||||
content: [{ content: [{ type: 'text', text: 'no presenter' }], isError: false }],
|
||||
},
|
||||
}),
|
||||
event('tool/call', 8, { callId: 'c4', name: 'throw-call', arguments: '{}' }),
|
||||
event('tool/call', 9, { callId: 'c5', name: 'throw-result', arguments: '{}' }),
|
||||
event('tool/result', 10, {
|
||||
message: {
|
||||
source: { callId: 'c5' },
|
||||
content: [{ content: [{ type: 'text', text: 'throw' }], isError: false }],
|
||||
},
|
||||
}),
|
||||
]
|
||||
cold(ctx, header, events)
|
||||
ctx.provide('tools', {
|
||||
get: (name: string) => {
|
||||
if (name === 'present') {
|
||||
return {
|
||||
presentCall: (args: unknown) => ({ card: 'generic', title: 'Call', rawInput: args }),
|
||||
presentResult: (_args: unknown, result: unknown) => ({ card: 'generic', title: 'Result', result }),
|
||||
}
|
||||
}
|
||||
if (name === 'empty') return {}
|
||||
if (name === 'throw-call') return { presentCall: () => { throw new Error('call presenter failed') } }
|
||||
if (name === 'throw-result') return { presentResult: () => { throw new Error('result presenter failed') } }
|
||||
return undefined
|
||||
},
|
||||
} as never)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
const page = await transport.page({
|
||||
address: { kind: 'session', sessionId }, throughSeq: 10,
|
||||
}, signal())
|
||||
expect(page.events[1]?.view).toEqual({
|
||||
for: 'call', view: { card: 'generic', title: 'Call', rawInput: { path: 'a.ts' } },
|
||||
})
|
||||
expect(page.events[2]?.view).toMatchObject({ for: 'result', view: { card: 'generic', title: 'Result' } })
|
||||
for (const index of [0, 3, 4, 5, 6, 7, 8, 9, 10]) {
|
||||
expect(page.events[index]).not.toHaveProperty('view')
|
||||
}
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('call presenter failed'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('result presenter failed'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../session/session-projection" },
|
||||
{ "path": "../../session/session-title" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../util/brand" },
|
||||
{ "path": "../../util/crypto" },
|
||||
{ "path": "../../util/workspace-path" },
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
{ "path": "../../core/agent-default-model" },
|
||||
{ "path": "../../core/scope" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../attachment/attachment" },
|
||||
{ "path": "../../interaction/permission-presets" },
|
||||
{ "path": "../../jobs/jobs" },
|
||||
|
||||
@@ -52,7 +52,7 @@ Non-negotiables across the layers:
|
||||
- **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer.
|
||||
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
|
||||
- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `../api/session-controller/src/client/sessions/notifier.ts`.
|
||||
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
|
||||
- **The web layer is pure presentation.** Nothing that is only "how to draw" enters the session log. Tool cards derive in the Client from raw call/result events and persisted result metadata; process-local control state uses its own snapshots and frames. Unknown or malformed tool data falls back to the generic form. A new *model-visible* input still requires a session event (repo-wide rule).
|
||||
|
||||
## Dependency declaration
|
||||
|
||||
|
||||
@@ -55,8 +55,7 @@
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^"
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
@@ -67,7 +66,6 @@
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^"
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ export type {
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, RpcMessage,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
JsonValue,
|
||||
SessionEvent,
|
||||
SessionId,
|
||||
} from '@deepseek-ai/dsh-session/types'
|
||||
@@ -29,7 +30,6 @@ import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surfac
|
||||
import type {
|
||||
ApiProxy, ClientRequest,
|
||||
ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerResponse,
|
||||
ToolCallView, ToolResultView,
|
||||
} from './api.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { AbstractApiClient, RpcId } from './api.ts'
|
||||
@@ -71,13 +71,8 @@ interface FixtureProjectionsBlock {
|
||||
readonly values: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
type FixtureToolView =
|
||||
| { readonly for: 'call'; readonly view: ToolCallView }
|
||||
| { readonly for: 'result'; readonly view: ToolResultView }
|
||||
|
||||
interface FixtureHistoryEntry {
|
||||
readonly event: SessionEvent
|
||||
readonly view?: FixtureToolView
|
||||
}
|
||||
|
||||
type FixtureSessionAddress =
|
||||
@@ -362,11 +357,9 @@ function sgr(code: number, body: string): string {
|
||||
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
|
||||
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
|
||||
* rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the
|
||||
* height cap collapses the middle. The exit status is authored separately in
|
||||
* TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash
|
||||
* presenter CONSUMES its `[exit code: N]` marker out of the body, because a
|
||||
* terminal card shows the exit as its own pill and leaving the marker in would
|
||||
* render it twice (packages/shell/tool-bash/src/render.ts).
|
||||
* height cap collapses the middle. This constant is the visible body; the call
|
||||
* site appends the shell result's `[exit code: N]` marker so Client derivation
|
||||
* can consume it into the terminal status pill.
|
||||
*/
|
||||
const TERMINAL_OUTPUT_FIXTURE = [
|
||||
sgr(1, 'Running 4 checks'),
|
||||
@@ -393,20 +386,10 @@ const TERMINAL_OUTPUT_FIXTURE = [
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* Exit status for each terminal sample, keyed by its output text. Authored
|
||||
* alongside the sample rather than parsed back out of its trailing marker,
|
||||
* which is the bash tool's own job and not something to reimplement here.
|
||||
*/
|
||||
const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: string }> = {
|
||||
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured grep result for the search sample (turn 67): matches grouped by
|
||||
* file, authored inline because the client-side fixture cannot import the tool
|
||||
* that produces the canonical value. `truncated` with a larger `total` than the
|
||||
* retained match count exercises the search card's capped indicator; the file
|
||||
* with more than CHAT_SEARCH_MAX_LINES rows exercises its head/tail height cap.
|
||||
* Structured grep metadata for the search sample (turn 67). `truncated` with a
|
||||
* larger `total` than the retained match count exercises the search card's
|
||||
* capped indicator; the file with more than CHAT_SEARCH_MAX_LINES rows
|
||||
* exercises its head/tail height cap.
|
||||
*/
|
||||
const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; line: string }[] }[] = [
|
||||
{
|
||||
@@ -475,18 +458,23 @@ const READ_SAMPLE_SOURCE = [
|
||||
const READ_SAMPLE_LINES = READ_SAMPLE_SOURCE.map((text, index) => ({ number: READ_SAMPLE_FIRST_LINE + index, text }))
|
||||
const READ_SAMPLE_PATH = 'packages/client/ui-primitives/src/ReadBlock.tsx'
|
||||
const READ_SAMPLE_TOTAL = 180
|
||||
const READ_SAMPLE_TEXT = READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`).join('\n')
|
||||
const READ_SAMPLE_LAST_LINE = READ_SAMPLE_FIRST_LINE + READ_SAMPLE_SOURCE.length - 1
|
||||
const READ_SAMPLE_TEXT = [
|
||||
`<path>${READ_SAMPLE_PATH}</path>`,
|
||||
'<type>file</type>',
|
||||
'<content>',
|
||||
...READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`),
|
||||
'',
|
||||
`(Showing lines ${READ_SAMPLE_FIRST_LINE}-${READ_SAMPLE_LAST_LINE} of ${READ_SAMPLE_TOTAL}. Use offset=${READ_SAMPLE_LAST_LINE + 1} to continue.)`,
|
||||
'</content>',
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* The structured `web_search` result view for the web-search turn, authored inline
|
||||
* because this client-side fixture cannot import the web tool that projects it.
|
||||
* The sources exercise the citation list's features: a titled source with a
|
||||
* snippet and a date, a source with no title (its hostname labels the link) and
|
||||
* a snippet but no date, and a source with a title and a date but no snippet.
|
||||
* `truncated` marks the capped indicator. The shape is the contract's own
|
||||
* search view minus its wire discriminants.
|
||||
* The `web_search` result metadata for the web-search turn. The sources cover a
|
||||
* titled source with a snippet and date, a hostname-label fallback, and a
|
||||
* titled source without a snippet; `truncated` exercises the capped indicator.
|
||||
*/
|
||||
const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'search' }>, 'card' | 'kind'> = {
|
||||
const WEB_SEARCH_META = {
|
||||
answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.',
|
||||
sources: [
|
||||
{
|
||||
@@ -506,14 +494,14 @@ const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'sear
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
}
|
||||
} satisfies JsonValue
|
||||
|
||||
/** The `web_fetch` result view for the web-fetch turn, authored inline for the same reason. */
|
||||
const WEB_FETCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>, 'card' | 'kind'> = {
|
||||
/** The `web_fetch` result metadata for the web-fetch turn. */
|
||||
const WEB_FETCH_META = {
|
||||
url: 'https://www.deepseek.com/blog/harness-architecture',
|
||||
statusCode: 200,
|
||||
truncated: false,
|
||||
}
|
||||
} satisfies JsonValue
|
||||
|
||||
const DEEPSEEK_REASONING = {
|
||||
efforts: [
|
||||
@@ -649,10 +637,16 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
}
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
|
||||
// turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above
|
||||
// stays presenter-less as the unknown fallback.
|
||||
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
|
||||
// The structured samples use real first-party names and result metadata so
|
||||
// the fixture follows the same event-to-card path as a persisted Session.
|
||||
// `echo` above remains the unknown-tool fallback.
|
||||
const toolTurn = (
|
||||
turn: number,
|
||||
name: string,
|
||||
args: string,
|
||||
resultText: string,
|
||||
resultMeta?: JsonValue,
|
||||
): void => {
|
||||
const callId = `fx-call-${turn}`
|
||||
push({ type: 'turn/start', data: { turn } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:${name} 样本。`)) })
|
||||
@@ -662,23 +656,66 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock]) },
|
||||
})
|
||||
push({ type: 'tool/call', data: { turn, step: 0, callId, name, arguments: args } })
|
||||
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(resultText), false) } })
|
||||
push({
|
||||
type: 'tool/result',
|
||||
surfaceOp: 'append',
|
||||
data: {
|
||||
turn,
|
||||
step: 0,
|
||||
message: toolResultMessage(callId, text(resultText), false),
|
||||
...resultMeta === undefined ? {} : { meta: resultMeta },
|
||||
},
|
||||
})
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// A two-line command, so the fixture covers the terminal card's one-row-per-
|
||||
// command-line prompt (and that the card still marks the call exactly once).
|
||||
toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
|
||||
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
|
||||
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
|
||||
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
|
||||
toolTurn(
|
||||
60,
|
||||
'bash',
|
||||
'{"command":"ls -la\\necho done","description":"fixture 终端样本","workdir":"/tmp/fixture"}',
|
||||
'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt',
|
||||
)
|
||||
toolTurn(
|
||||
61,
|
||||
'write',
|
||||
'{"file_path":"notes/demo.txt","content":"hello fixture\\n"}',
|
||||
'wrote notes/demo.txt',
|
||||
{ diffs: [{ path: 'notes/demo.txt', oldText: null, newText: 'hello fixture\n' }] },
|
||||
)
|
||||
toolTurn(
|
||||
62,
|
||||
'edit',
|
||||
'{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}',
|
||||
'已编辑',
|
||||
{ diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] },
|
||||
)
|
||||
toolTurn(
|
||||
63,
|
||||
'write',
|
||||
'{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}',
|
||||
'已写入',
|
||||
{ diffs: [{ path: 'notes/new-demo.txt', oldText: null, newText: 'hello fixture\n' }] },
|
||||
)
|
||||
// Turn 64: a multi-hunk edit — two scattered replacements in one file. Named
|
||||
// `edit` so it lands on the keyed FileMutationRow (the resident diff card the
|
||||
// single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
|
||||
// the presenter reads to emit the two-hunk sample: the card draws one path
|
||||
// header, the first hunk, a `⋯` gap, then the second (the same-file
|
||||
// single-hunk turn 62 also uses). Its result metadata carries two scattered
|
||||
// hunks under one path header, so the card draws the first hunk, a `⋯` gap,
|
||||
// then the second (the same-file
|
||||
// second-hunk arm turns 62/63 cannot reach).
|
||||
toolTurn(64, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
|
||||
toolTurn(
|
||||
64,
|
||||
'edit',
|
||||
'{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}',
|
||||
'已编辑',
|
||||
{
|
||||
diffs: [
|
||||
{ path: 'src/config.ts', oldText: 'const timeout = 30', newText: 'const timeout = 60' },
|
||||
{ path: 'src/config.ts', oldText: 'retries: 1', newText: 'retries: 3' },
|
||||
],
|
||||
},
|
||||
)
|
||||
// Turn 65: one run_code turn with three logged sub-dispatches — the Code
|
||||
// Mode acceptance surface (parent code row + nested native-identical rows,
|
||||
// including an isError sub-call and a bash sub-call that must hit the same
|
||||
@@ -734,52 +771,75 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
]
|
||||
// Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover —
|
||||
// ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
|
||||
// whose prompt label is its last segment, and a non-zero exit authored beside
|
||||
// the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
|
||||
// `[exit code: N]` marker, since the real presenter consumes that one out of
|
||||
// the body. Named `bash`, so it also covers
|
||||
// the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback
|
||||
// row) — the two chat-row shapes the terminal card renders in.
|
||||
// whose prompt label is its last segment, and a non-zero exit. The raw result
|
||||
// includes an `[exit code: N]` marker below; Client
|
||||
// derivation consumes it into the status pill before rendering the body.
|
||||
//
|
||||
// Ordered BEFORE the todo turn deliberately: the standing plan retires at the
|
||||
// next `turn/start`, so a turn appended after it would leave the dock's plan
|
||||
// strip empty and take the todo surfaces' own coverage with it.
|
||||
toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
|
||||
toolTurn(
|
||||
66,
|
||||
'bash',
|
||||
'{"command":"pnpm run check","description":"fixture 终端样本","workdir":"/tmp/fixture/deep/nested"}',
|
||||
`${TERMINAL_OUTPUT_FIXTURE}\n[exit code: 1]`,
|
||||
)
|
||||
|
||||
// Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'`
|
||||
// `shape: 'matches'` result view (grouped-by-file matches, truncated with a
|
||||
// larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise
|
||||
// truncated). Both ride the keyed SearchRow registration under their own
|
||||
// names; the render-site fallback row is covered by the model derivation
|
||||
// tests, since every fixture search tool has a keyed row. Ordered before the
|
||||
// todo turn for the same standing-plan reason the bash turn is.
|
||||
toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
|
||||
toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
|
||||
// Turns 67-68 carry the search card's two metadata variants: grouped matches
|
||||
// and a flat path list, both truncated with a larger pre-cap total. Both use
|
||||
// the keyed SearchRow registration. They stay before the todo turn for the
|
||||
// same standing-plan reason as the bash turn.
|
||||
toolTurn(
|
||||
67,
|
||||
'grep',
|
||||
'{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}',
|
||||
SEARCH_MATCHES_TEXT,
|
||||
{ shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 },
|
||||
)
|
||||
toolTurn(
|
||||
68,
|
||||
'glob',
|
||||
'{"pattern":"**/SearchBlock*","path":"packages/client"}',
|
||||
SEARCH_PATHS_TEXT,
|
||||
{ shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 },
|
||||
)
|
||||
|
||||
// Turn 69: the read sample — a WINDOW past an offset so the card draws file
|
||||
// line numbers starting above 1 and a "showing N of M" note (the window is
|
||||
// shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
|
||||
// highlights. Named `read`, so it exercises the keyed ReadRow registration.
|
||||
// The render-site fallback ROW SHAPE (a read call on the generic flattened
|
||||
// path) is covered by the turn 65 run_code read sub-dispatches, which
|
||||
// session.ts folds with resultView: null; the fallback-row + read-CARD
|
||||
// combination is pinned by the web_fetch case in read-card.spec.tsx, not by
|
||||
// this fixture. The read render intent is result-side only, so its pending
|
||||
// call stays a generic `kind: 'read'` card; presentResult carries the
|
||||
// structured window.
|
||||
toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
|
||||
// The run_code sub-dispatches above cover nested read calls without result
|
||||
// metadata; this top-level result carries the structured window.
|
||||
toolTurn(
|
||||
69,
|
||||
'read',
|
||||
`{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`,
|
||||
READ_SAMPLE_TEXT,
|
||||
{
|
||||
path: READ_SAMPLE_PATH,
|
||||
offset: READ_SAMPLE_FIRST_LINE,
|
||||
lines: READ_SAMPLE_LINES,
|
||||
totalLines: READ_SAMPLE_TOTAL,
|
||||
lang: 'ts',
|
||||
},
|
||||
)
|
||||
|
||||
// Turns 70-71: the web render intent — a web_search whose result view carries
|
||||
// structured sources plus an answer (the citation list, one source lacking a
|
||||
// title so its hostname labels the link, the capped indicator on), and a
|
||||
// web_fetch whose result view carries the fetched URL and its HTTP status.
|
||||
// Both keep a generic pending call view and add the `web` card only at
|
||||
// result time, which is the contract's result-only web shape. Named after
|
||||
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
|
||||
// the todo turn for the same reason turn 66 is: the standing plan retires at
|
||||
// the next turn/start, so a turn after it would empty the dock's plan strip.
|
||||
toolTurn(70, 'web_search', '{"queries":["deepseek harness architecture"]}', 'Search results for deepseek harness architecture.')
|
||||
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
// Turns 70-71 carry the web tools' result metadata. They stay before the todo
|
||||
// turn because a later turn/start retires the standing plan projection.
|
||||
toolTurn(
|
||||
70,
|
||||
'web_search',
|
||||
'{"queries":["deepseek harness architecture"]}',
|
||||
'Search results for deepseek harness architecture.',
|
||||
WEB_SEARCH_META,
|
||||
)
|
||||
toolTurn(
|
||||
71,
|
||||
'web_fetch',
|
||||
'{"url":"https://www.deepseek.com/blog/harness-architecture"}',
|
||||
'# Harness architecture\n\nEverything is a plugin.',
|
||||
WEB_FETCH_META,
|
||||
)
|
||||
|
||||
// Turn 72: max-tokens sample — the provider ends the turn at its output cap
|
||||
// mid-sentence, so the chat flow must render the turn-max-tokens notice
|
||||
@@ -832,151 +892,6 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
return events as unknown as SessionEvent[]
|
||||
}
|
||||
|
||||
/** Narrows a parsed-JSON field to string; fixture args are authored in-file, so non-strings only mean a typo here. */
|
||||
/* v8 ignore next -- the fallback arm is the same in-file-typo guard as the JSON.parse catch above. */
|
||||
const str = (value: unknown, fallback = ''): string => typeof value === 'string' ? value : fallback
|
||||
|
||||
/** Fixture presenter registry (mirrors host viewFor): pure derivation, undefined = no view. */
|
||||
function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
let args: Record<string, unknown>
|
||||
try {
|
||||
args = JSON.parse(argsRaw) as Record<string, unknown>
|
||||
} catch {
|
||||
/* v8 ignore next 2 -- defensive: fixture args are authored in-file as valid JSON; only an in-file typo could reach the catch. */
|
||||
return undefined
|
||||
}
|
||||
switch (name) {
|
||||
// Both names present the same terminal card: `fx-bash` lands on the
|
||||
// render-site fallback row, `bash` on the keyed BashRow registration.
|
||||
case 'fx-bash':
|
||||
case 'bash':
|
||||
return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
|
||||
case 'fx-write':
|
||||
return {
|
||||
card: 'diff', title: `Write ${str(args.path)}`,
|
||||
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
|
||||
}
|
||||
// A read pending call is a GENERIC card (kind: 'read', a follow-along
|
||||
// location): the read render intent is result-side only, because a call
|
||||
// carries no file content until execute returns. The rich read card arrives
|
||||
// in presentResult.
|
||||
case 'read':
|
||||
return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] }
|
||||
case 'edit':
|
||||
// The multi-hunk sample (turn 64) is keyed on its file_path, so the two
|
||||
// scattered hunks share one path header and the card draws the `⋯` gap.
|
||||
if (str(args.file_path) === 'src/config.ts') {
|
||||
return {
|
||||
card: 'diff', title: `Edit ${str(args.file_path)}`,
|
||||
diffs: [
|
||||
{ path: str(args.file_path), oldText: 'const timeout = 30', newText: 'const timeout = 60' },
|
||||
{ path: str(args.file_path), oldText: 'retries: 1', newText: 'retries: 3' },
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
card: 'diff', title: `Edit ${str(args.file_path)}`,
|
||||
diffs: [{ path: str(args.file_path), oldText: str(args.old_string), newText: str(args.new_string) }],
|
||||
}
|
||||
case 'write':
|
||||
return {
|
||||
card: 'diff', title: `Write ${str(args.file_path)}`,
|
||||
diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }],
|
||||
}
|
||||
// A search call stays a generic card (kind: 'search'): the structured
|
||||
// matches/paths exist only after execute, so the search card is result-time
|
||||
// only (presentResult builds it). This mirrors the real grep/glob presenters.
|
||||
case 'grep':
|
||||
return { card: 'generic', title: `Grep ${str(args.pattern)}`, kind: 'search', rawInput: args }
|
||||
case 'glob':
|
||||
return { card: 'generic', title: `Glob ${str(args.pattern)}`, kind: 'search', rawInput: args }
|
||||
// The web tools keep a GENERIC pending card and add the `web` result card
|
||||
// only at result time (the contract's result-only web shape); their pending
|
||||
// kind matches the result kind so a call and its result read as one category.
|
||||
case 'web_search': {
|
||||
const queries = Array.isArray(args.queries) ? args.queries.filter((query): query is string => typeof query === 'string' && query !== '') : []
|
||||
const title = queries.join(', ')
|
||||
return { card: 'generic', title: `Search ${title}`, kind: 'search', rawInput: args }
|
||||
}
|
||||
case 'web_fetch':
|
||||
return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args }
|
||||
default:
|
||||
return undefined // echo et al: the documented no-view fallback path
|
||||
}
|
||||
}
|
||||
|
||||
function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
|
||||
const call = presentCall(name, argsRaw)
|
||||
if (call === undefined) return undefined
|
||||
// Search is result-time only: the call stays a generic search card, and the
|
||||
// result view carries the structured shape the card renders. The view holds no
|
||||
// result text — a UI without a search card falls back to the raw tool/result
|
||||
// content — so the truncation recovery footer rides that raw content (the
|
||||
// `toolTurn` message text), not the view. `total` exceeds the retained count so
|
||||
// the card shows its capped indicator.
|
||||
if (name === 'grep') {
|
||||
return { card: 'search', shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 }
|
||||
}
|
||||
if (name === 'glob') {
|
||||
return { card: 'search', shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 }
|
||||
}
|
||||
// The read result is the structured window the tool projects through
|
||||
// `presentationMeta`; the fixture authors it inline (it cannot import the
|
||||
// tool). Keyed on the name because the read pending call is a generic card,
|
||||
// so `call.card` alone does not distinguish it from edit/write.
|
||||
if (name === 'read') {
|
||||
return {
|
||||
card: 'read', path: READ_SAMPLE_PATH, offset: READ_SAMPLE_FIRST_LINE, lines: READ_SAMPLE_LINES,
|
||||
totalLines: READ_SAMPLE_TOTAL, lang: 'ts', content: text(resultText),
|
||||
}
|
||||
}
|
||||
// The web tools keep a generic pending card, so their result card is chosen
|
||||
// by tool name rather than by the pending card tag: the structured `web` card
|
||||
// the frontend consumes. The view carries no `content` copy (per the contract
|
||||
// and the web-result-card note); a capability-less UI falls back to the raw
|
||||
// `tool/result` content, which this fixture emits from `resultText`.
|
||||
if (name === 'web_search') {
|
||||
return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT }
|
||||
}
|
||||
if (name === 'web_fetch') {
|
||||
return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT }
|
||||
}
|
||||
switch (call.card) {
|
||||
case 'terminal':
|
||||
// The sample's own exit status, authored beside it: re-parsing the
|
||||
// trailing marker here would duplicate the bash tool's `parseExitStatus`,
|
||||
// which this client-side fixture cannot import.
|
||||
return { card: 'terminal', output: resultText, ...(TERMINAL_EXIT_STATUS[resultText] ?? { exitCode: 0 }) }
|
||||
case 'diff':
|
||||
return { card: 'diff', diffs: call.diffs }
|
||||
case 'generic':
|
||||
return { card: 'generic', content: text(resultText) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */
|
||||
function viewFor(event: SessionEvent, log: readonly SessionEvent[]): FixtureToolView | undefined {
|
||||
if (event.type === 'tool/call') {
|
||||
const view = presentCall(event.data.name, event.data.arguments)
|
||||
return view === undefined ? undefined : { for: 'call', view }
|
||||
}
|
||||
if (event.type === 'tool/result') {
|
||||
const callId = String(event.data.message.source.callId)
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const candidate = log[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within [0, log.length),
|
||||
so the undefined arm needs a sparse log no code path builds. */
|
||||
if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) {
|
||||
const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
const view = presentResult(candidate.data.name, candidate.data.arguments, resultText)
|
||||
return view === undefined ? undefined : { for: 'result', view }
|
||||
}
|
||||
}
|
||||
return undefined // cross-page unpaired: documented default
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture parallel of the plan unit's lifecycle fold. The paired
|
||||
* `command/done` retains successful plan selections and drops failures;
|
||||
@@ -1422,11 +1337,9 @@ function projectionFramesOf(
|
||||
}
|
||||
|
||||
/**
|
||||
* Message-boundary paging (mirrors the host's paging contract): count
|
||||
* maxMessages messages
|
||||
* backwards from end, cut at a turn/start boundary.
|
||||
Entries carry pagination-time views
|
||||
* (the host analogue computes viewFor per entry at page time). */
|
||||
* Message-boundary paging mirrors the Host contract: count `maxMessages`
|
||||
* backwards from the end and cut at a turn/start boundary.
|
||||
*/
|
||||
function pageOf(
|
||||
log: readonly SessionEvent[],
|
||||
beforeSeq: number | undefined,
|
||||
@@ -1445,10 +1358,7 @@ function pageOf(
|
||||
break
|
||||
}
|
||||
}
|
||||
const events = log.slice(start, end).map((event): FixtureHistoryEntry => {
|
||||
const view = viewFor(event, log)
|
||||
return view === undefined ? { event } : { event, view }
|
||||
})
|
||||
const events = log.slice(start, end).map((event): FixtureHistoryEntry => ({ event }))
|
||||
return { events, hasMore: start > 0 }
|
||||
}
|
||||
|
||||
@@ -1974,12 +1884,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
const log = logOf(id)
|
||||
const event = { seq: log.length, time: Date.now(), ...e } as unknown as SessionEvent
|
||||
log.push(event)
|
||||
// Emission-time view derivation (mirrors the host's live path).
|
||||
const view = viewFor(event, log)
|
||||
/* v8 ignore next 2 -- the view-present arm needs a live tool/call emission,
|
||||
but the fixture replay produces text-only turns; view vocabulary is
|
||||
exercised through the history samples (turns 60-62). */
|
||||
emitFollow(id, view === undefined ? { event } : { event, view })
|
||||
emitFollow(id, { event })
|
||||
// Host eager-drive parallel: a unit-advancing event pushes its finished value.
|
||||
for (const frame of projectionFramesOf(id, log, event)) emitControl(frame)
|
||||
if (event.type === 'user/message' && event.data.source.kind === 'user') {
|
||||
@@ -3007,8 +2912,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
throw new Error(`fixture: session event replay skipped seq ${String(nextSeq)}`)
|
||||
}
|
||||
nextSeq++
|
||||
const view = viewFor(event, snapshot)
|
||||
yield view === undefined ? { type: 'event', event } : { type: 'event', event, view }
|
||||
yield { type: 'event', event }
|
||||
}
|
||||
}
|
||||
for await (const frame of conn.drain(signal)) {
|
||||
|
||||
@@ -32,7 +32,6 @@ declare module '@deepseek-ai/cordis' {
|
||||
export type {
|
||||
ApiProxy, HostApi,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
ToolCallView, ToolResultView,
|
||||
SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
MessageId, ModelReasoningEffort, ModelSelection,
|
||||
|
||||
@@ -37,7 +37,6 @@ interface FixtureSessionSummary {
|
||||
|
||||
interface FixtureHistoryEntry {
|
||||
readonly event: SessionEvent
|
||||
readonly view?: unknown
|
||||
}
|
||||
|
||||
interface FixturePage {
|
||||
@@ -648,6 +647,49 @@ describe('createFixtureApi', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('serves raw history entries with replayable tool-result metadata', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 200 }))
|
||||
if (!response.result.ok) throw new Error('history failed')
|
||||
|
||||
const entries = response.result.value.events
|
||||
expect(entries.every(entry => !Object.hasOwn(entry, 'view'))).toBe(true)
|
||||
const results = entries
|
||||
.map(entry => entry.event)
|
||||
.filter(event => event.type === 'tool/result')
|
||||
|
||||
expect(results.find(event => event.data.turn === 64)).toMatchObject({
|
||||
data: {
|
||||
meta: {
|
||||
diffs: [
|
||||
{ path: 'src/config.ts', oldText: 'const timeout = 30', newText: 'const timeout = 60' },
|
||||
{ path: 'src/config.ts', oldText: 'retries: 1', newText: 'retries: 3' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(results.find(event => event.data.turn === 67)).toMatchObject({
|
||||
data: { meta: { shape: 'matches', truncated: true, total: 42 } },
|
||||
})
|
||||
expect(results.find(event => event.data.turn === 69)).toMatchObject({
|
||||
data: { meta: { path: 'packages/client/ui-primitives/src/ReadBlock.tsx', offset: 41, totalLines: 180 } },
|
||||
})
|
||||
const webSearch = results.find(event => event.data.turn === 70)
|
||||
expect(webSearch).toHaveProperty('data.meta.truncated', true)
|
||||
expect(webSearch).toHaveProperty('data.meta.sources', expect.arrayContaining([
|
||||
expect.objectContaining({ url: 'https://github.com/deepseek-ai/deepseek-harness' }),
|
||||
]))
|
||||
expect(results.find(event => event.data.turn === 71)).toMatchObject({
|
||||
data: { meta: { url: 'https://www.deepseek.com/blog/harness-architecture', statusCode: 200 } },
|
||||
})
|
||||
const terminal = results.find(event => event.data.turn === 66)
|
||||
expect(terminal).toHaveProperty('data.message.content.0.content.0.type', 'text')
|
||||
expect(terminal).toHaveProperty(
|
||||
'data.message.content.0.content.0.text',
|
||||
expect.stringContaining('\n[exit code: 1]'),
|
||||
)
|
||||
})
|
||||
|
||||
it('serves grouped models and keeps a selection for later history and fixture requests', async () => {
|
||||
const api = createFixtureApi()
|
||||
const sessionId = sid('fx-alpha')
|
||||
|
||||
@@ -45,7 +45,6 @@ function rootCall(match: ConversationMatch): RunningToolCall {
|
||||
turn: match.event.data.turn,
|
||||
step: match.event.data.step,
|
||||
time: match.event.time,
|
||||
callView: match.view?.for === 'call' ? match.view.view : null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
@@ -64,8 +63,6 @@ function rootResult(match: ConversationMatch, previous?: RunningToolCall): ToolR
|
||||
isError: result.isError === true,
|
||||
...match.event.data.error === undefined ? {} : { error: match.event.data.error },
|
||||
meta: match.event.data.meta,
|
||||
callView: previous?.callView ?? null,
|
||||
resultView: match.view?.for === 'result' ? match.view.view : null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
@@ -82,12 +79,12 @@ interface DispatchData {
|
||||
function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall {
|
||||
return {
|
||||
callId: data.subCallId,
|
||||
parentCallId: data.parentCallId,
|
||||
name: data.name,
|
||||
argsRaw: jsonArguments(data.arguments),
|
||||
turn: locationTurn(match),
|
||||
step: locationStep(match),
|
||||
time: match.event.time,
|
||||
callView: null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
@@ -98,12 +95,11 @@ function childResult(match: ConversationMatch, data: DispatchData, previous?: To
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
callId: data.subCallId,
|
||||
parentCallId: data.parentCallId,
|
||||
call: { name: data.name, argsRaw: jsonArguments(data.arguments) },
|
||||
callTime: previous?.time ?? null,
|
||||
content: data.content ?? [],
|
||||
isError: data.isError === true,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
@@ -197,13 +193,12 @@ function projectBlock(
|
||||
seq: interruptedAt.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup,
|
||||
time: interruptedAt.time,
|
||||
callId: block.callId,
|
||||
...block.parentCallId === undefined ? {} : { parentCallId: block.parentCallId },
|
||||
call: { name: block.name, argsRaw: block.argsRaw },
|
||||
callTime: block.time,
|
||||
content: [],
|
||||
isError: true,
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: block.callView,
|
||||
resultView: null,
|
||||
subCalls: children,
|
||||
}
|
||||
projectedBlocks.set(block, { children, interruptionSeq, interruptionTime, value: projected })
|
||||
|
||||
@@ -47,8 +47,8 @@ function rawResultText(block: ToolCallBlock): string {
|
||||
|
||||
export function DetailsPanel({ useChat, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) {
|
||||
const selection = useStore(s => s.selection)
|
||||
// Session workspace root: an omitted or relative terminal cwd resolves
|
||||
// against it, which the pure presenter cannot see.
|
||||
// Session workspace root: a card model resolves omitted or relative
|
||||
// tool paths against it without reading Session services.
|
||||
const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd)
|
||||
const callId = selection?.callId
|
||||
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
|
||||
@@ -56,7 +56,6 @@ export function DetailsPanel({ useChat, useSessions, sessionId, useStore, render
|
||||
const material = useChat(
|
||||
s => (callId === undefined ? null : materialFor(s, callId)),
|
||||
(a, b) => shallowEqual(a, b))
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div className={css.header}>
|
||||
|
||||
@@ -59,12 +59,12 @@ export class ToolCallTree {
|
||||
const data = event.data
|
||||
const running: RunningToolCall = {
|
||||
callId: data.subCallId,
|
||||
parentCallId: data.parentCallId,
|
||||
name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0,
|
||||
step: 0,
|
||||
time: event.time,
|
||||
callView: null,
|
||||
subCalls: [],
|
||||
}
|
||||
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
|
||||
@@ -84,12 +84,11 @@ export class ToolCallTree {
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
callId: data.subCallId,
|
||||
parentCallId: data.parentCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
callTime: started?.time ?? null,
|
||||
content: data.content,
|
||||
isError: data.isError,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
subCalls: [],
|
||||
}
|
||||
this.childrenByParent.set(
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('deriveStats', () => {
|
||||
it('ignores tool results with no call time', () => {
|
||||
const tool: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
|
||||
isError: false, callView: null, resultView: null, subCalls: [],
|
||||
isError: false, subCalls: [],
|
||||
}
|
||||
const stats = deriveStats([tool, assistant(1, 1)])
|
||||
expect(stats.steps).toBe(1)
|
||||
@@ -96,7 +96,7 @@ describe('deriveStats', () => {
|
||||
}
|
||||
const tool: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [],
|
||||
isError: false, callView: null, resultView: null, subCalls: [],
|
||||
isError: false, subCalls: [],
|
||||
}
|
||||
const stats = deriveStats([timed, untimed, tool])
|
||||
expect(stats.llmMs).toBe(2_500)
|
||||
|
||||
@@ -137,10 +137,10 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [],
|
||||
content: [], isError: false, subCalls: [],
|
||||
})
|
||||
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
|
||||
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, subCalls: [],
|
||||
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, subCalls: [],
|
||||
})
|
||||
const command = (over: Partial<CommandNode> = {}): CommandNode => ({
|
||||
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
|
||||
@@ -361,17 +361,14 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien
|
||||
describe('Chat node rendering', () => {
|
||||
|
||||
it('threads the injected file-mention vocabulary into the closing prose only', () => {
|
||||
const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({
|
||||
const wrote = (seq: number, callId: string): ToolResultNode => ({
|
||||
...toolResult(seq, callId, 'write'),
|
||||
callView: {
|
||||
card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }],
|
||||
},
|
||||
})
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'build it'),
|
||||
assistant(2, 'writing `report.html` now', 1),
|
||||
wrote(3, 'w', 'site/report.html'),
|
||||
wrote(3, 'w'),
|
||||
assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1),
|
||||
],
|
||||
turnEnds: new Map([[1, 4]]),
|
||||
|
||||
@@ -106,7 +106,7 @@ function assistantMessage(id: string, text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function toolResult(callId: string, text: string) {
|
||||
function toolResult(callId: string, text: string, isError = false) {
|
||||
return {
|
||||
id: `result-${callId}`,
|
||||
role: 'user',
|
||||
@@ -115,7 +115,7 @@ function toolResult(callId: string, text: string) {
|
||||
type: 'tool-result',
|
||||
toolCallId: callId,
|
||||
content: [{ type: 'text', text }],
|
||||
isError: false,
|
||||
isError,
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -310,7 +310,9 @@ describe('built-in conversation node Definitions', () => {
|
||||
value.append(at(4, 'tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: toolResult('root', 'done'),
|
||||
message: toolResult('root', 'done', true),
|
||||
error: { name: 'ToolError', code: 'failed' },
|
||||
meta: { presentation: 'raw' },
|
||||
}, { surfaceOp: 'append' }))
|
||||
value.flush()
|
||||
|
||||
@@ -318,7 +320,15 @@ describe('built-in conversation node Definitions', () => {
|
||||
const settled = node(settledSnapshot, 'tool-call')
|
||||
expect(settled?.key).toBe(running?.key)
|
||||
expect(settledSnapshot.order).toBe(order)
|
||||
expect((settled?.data as ToolChatData).root).toMatchObject({ kind: 'tool-result', callId: 'root' })
|
||||
expect((settled?.data as ToolChatData).root).toMatchObject({
|
||||
kind: 'tool-result',
|
||||
callId: 'root',
|
||||
call: { name: 'code', argsRaw: '{}' },
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
isError: true,
|
||||
error: { name: 'ToolError', code: 'failed' },
|
||||
meta: { presentation: 'raw' },
|
||||
})
|
||||
|
||||
const history = assembler([
|
||||
at(14, 'tool/code-dispatch-start', {
|
||||
@@ -345,7 +355,7 @@ describe('built-in conversation node Definitions', () => {
|
||||
], true)
|
||||
const before = node(snapshot(history), 'tool-call')
|
||||
expect((before?.data as ToolChatData).root.subCalls).toMatchObject([
|
||||
{ kind: 'tool-result', callId: 'child', call: { name: 'read' } },
|
||||
{ kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' } },
|
||||
])
|
||||
|
||||
history.prepend([
|
||||
@@ -364,7 +374,7 @@ describe('built-in conversation node Definitions', () => {
|
||||
const after = node(snapshot(history), 'tool-call')
|
||||
expect(after?.key).toBe(before?.key)
|
||||
expect((after?.data as ToolChatData).root.subCalls).toMatchObject([
|
||||
{ kind: 'tool-result', callId: 'child', call: { name: 'read' } },
|
||||
{ kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' } },
|
||||
])
|
||||
|
||||
const firstChild = (after?.data as ToolChatData).root.subCalls[0]
|
||||
@@ -1011,7 +1021,6 @@ describe('built-in conversation node Definitions', () => {
|
||||
// behavior of both required Definition members anyway.
|
||||
const match = (seq: number, type: string, data: unknown) => ({
|
||||
event: { seq, time: seq * 1_000, type, data },
|
||||
view: undefined,
|
||||
role: 'start',
|
||||
location: undefined,
|
||||
}) as unknown as Parameters<typeof turnMaxTokensDefinition.start>[1]
|
||||
|
||||
@@ -168,22 +168,24 @@ describe('render branch tails', () => {
|
||||
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('DetailsPanel resolves a nested run_code leaf to its full logged args and output', () => {
|
||||
it('DetailsPanel passes the existing parentCallId through to the Tool details seat', () => {
|
||||
localStorage.clear()
|
||||
const session = sessionSnapshot()
|
||||
const longText = 'x'.repeat(1_000)
|
||||
const runningCalls: readonly RunningToolCall[] = [{
|
||||
callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
|
||||
time: 7_000, callView: null, subCalls: [{
|
||||
time: 7_000, subCalls: [{
|
||||
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
|
||||
parentCallId: 'p1',
|
||||
call: { name: 'run_code', argsRaw: '{"code":"return 1"}' },
|
||||
callTime: 8_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
content: [], isError: false,
|
||||
subCalls: [{
|
||||
kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1',
|
||||
parentCallId: 'p1:code:1',
|
||||
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
|
||||
callTime: 8_500,
|
||||
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
|
||||
content: [{ type: 'text', text: longText }], isError: false,
|
||||
subCalls: [],
|
||||
}],
|
||||
}],
|
||||
@@ -224,13 +226,14 @@ describe('render branch tails', () => {
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
// Chat resolves the selected sub-call and hands its complete
|
||||
// frozen block to the Tool-owned details seat.
|
||||
// Chat resolves the selected sub-call and keeps its Code Dispatch parent
|
||||
// identity on the block handed to the Tool-owned details seat.
|
||||
expect(view.getByText('read')).toBeTruthy()
|
||||
expect(view.getByTestId('tool-details-seat')).toBeTruthy()
|
||||
expect(owners).toHaveLength(1)
|
||||
expect(owners[0]?.block).toMatchObject({
|
||||
callId: 'p1:code:1:code:1',
|
||||
parentCallId: 'p1:code:1',
|
||||
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
|
||||
content: [{ type: 'text', text: longText }],
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ const settle = (seq: number, parentCallId: string, subCallId: string): SessionEv
|
||||
|
||||
const root = (callId: string): RunningToolCall => ({
|
||||
callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
|
||||
time: 1_700_000_000_000, callView: null, subCalls: [],
|
||||
time: 1_700_000_000_000, subCalls: [],
|
||||
})
|
||||
|
||||
describe('ToolCallTree', () => {
|
||||
@@ -42,8 +42,8 @@ describe('ToolCallTree', () => {
|
||||
expect(tree.projectRunningCalls([root('a')])).toMatchObject([{
|
||||
callId: 'a',
|
||||
subCalls: [{
|
||||
callId: 'b',
|
||||
subCalls: [{ callId: 'c', subCalls: [] }],
|
||||
callId: 'b', parentCallId: 'a',
|
||||
subCalls: [{ callId: 'c', parentCallId: 'b', subCalls: [] }],
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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: 6f47cd87af46cc270d3160482ad047b249aa5053
|
||||
README.zh.md: 38e3c626073e9e90a16eebdb91af1e89d4da7c22
|
||||
README.md: 4c9665b680fe1922770403d88a04dffc755481ad
|
||||
README.zh.md: 8bf3db2cb1401427f29016f8dbddcd9d27ec9635
|
||||
|
||||
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
`UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source.
|
||||
|
||||
The adapter converts each `SessionEventEntry` to `ConversationEventInput` as `{ event, view? }`: the raw Session event is preserved and the envelope-level tool view is included only when present. Contiguous append and prepend revisions use incremental assembly; replacement windows and revision gaps rebuild from the complete loaded window. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
|
||||
The adapter converts each `SessionEventEntry` to a `{ event }` `ConversationEventInput` and preserves the raw Session event, including tool-result metadata. Contiguous append and prepend revisions use incremental assembly; replacement windows and revision gaps rebuild from the complete loaded window. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
|
||||
|
||||
Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开 event source。
|
||||
|
||||
adapter 将每个 `SessionEventEntry` 转换成 `{ event, view? }` 形式的 `ConversationEventInput`:原始 Session event 保持不变,仅在 envelope-level tool view 存在时携带 `view`。连续 revision 的 append 和 prepend 使用增量组装;replace window 或 revision 断档从完整已加载窗口重建。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
|
||||
adapter 将每个 `SessionEventEntry` 转换成 `{ event }` 形式的 `ConversationEventInput`,并保留原始 Session event,包括工具结果 metadata。连续 revision 的 append 和 prepend 使用增量组装;replace window 或 revision 断档从完整已加载窗口重建。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
|
||||
|
||||
target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
|
||||
/* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents --
|
||||
* The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program;
|
||||
* installed business packages supply their concrete keys in consuming Client programs. */
|
||||
|
||||
/** One raw log event plus its optional envelope-level presentation view. */
|
||||
/** One raw Session log event consumed by Conversation assembly. */
|
||||
export interface ConversationEventInput {
|
||||
readonly event: SessionEvent
|
||||
readonly view?: SessionToolView
|
||||
}
|
||||
|
||||
/** Definition-local identity and lifecycle role extracted from one event. */
|
||||
|
||||
@@ -8,9 +8,6 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
|
||||
import type {
|
||||
ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
|
||||
export type { TodoItem }
|
||||
|
||||
@@ -161,6 +158,8 @@ export interface ToolResultNode {
|
||||
/** Unix epoch ms from the tool/result session event. */
|
||||
time: number
|
||||
callId: string
|
||||
/** Parent Tool call for a Code Dispatch result; absent on a root Session result. */
|
||||
parentCallId?: string
|
||||
/** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */
|
||||
call: { name: string; argsRaw: string } | null
|
||||
/** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */
|
||||
@@ -169,10 +168,6 @@ export interface ToolResultNode {
|
||||
isError: boolean
|
||||
error?: { name: string; code: string }
|
||||
meta?: unknown
|
||||
/** Host-computed render intent from the paired tool/call's wire view; null = generic JSON card (documented default). */
|
||||
callView: ToolCallView | null
|
||||
/** Host-computed render intent from this tool/result's wire view; null = same default. */
|
||||
resultView: ToolResultView | null
|
||||
/** Child calls owned by this call, in dispatch order. */
|
||||
subCalls: readonly ToolCallBlock[]
|
||||
}
|
||||
@@ -268,14 +263,14 @@ export type ConversationNode =
|
||||
/** In-flight tool card material: tool/call seen, tool/result not yet. */
|
||||
export interface RunningToolCall {
|
||||
callId: string
|
||||
/** Parent Tool call for a Code Dispatch start; absent on a root Session call. */
|
||||
parentCallId?: string
|
||||
name: string
|
||||
argsRaw: string
|
||||
turn: number
|
||||
step: number
|
||||
/** Unix epoch ms when the tool/call event was logged. */
|
||||
time: number
|
||||
/** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
|
||||
callView: ToolCallView | null
|
||||
/** Child calls owned by this call, in dispatch order. */
|
||||
subCalls: readonly ToolCallBlock[]
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
|
||||
/**
|
||||
* Add one contiguous live tail event without scanning existing Contexts.
|
||||
* @param input - appended Event and optional wire view.
|
||||
* @param input - appended Session event.
|
||||
* @returns highest requested publication cadence.
|
||||
*/
|
||||
append(input: ConversationEventInput): ConversationPublication {
|
||||
|
||||
@@ -128,10 +128,7 @@ class BoundConversation implements ConversationBinding {
|
||||
}
|
||||
|
||||
function conversationInput(entry: SessionEventEntry): ConversationEventInput {
|
||||
return {
|
||||
event: entry.event as unknown as SessionEvent,
|
||||
...(entry.view === undefined ? {} : { view: entry.view }),
|
||||
}
|
||||
return { event: entry.event as unknown as SessionEvent }
|
||||
}
|
||||
|
||||
interface BindingRecord {
|
||||
|
||||
@@ -145,6 +145,8 @@ export const zh = {
|
||||
'terminal.collapseAria': '收起输出',
|
||||
'terminal.expandAria': '展开其余 {n} 行输出',
|
||||
'terminal.expandRest': '… 其余 {n} 行',
|
||||
'terminal.sendInput': '(发送输入)',
|
||||
'terminal.session': '终端 {sessionId}',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The conversation namespace key union. */
|
||||
@@ -288,4 +290,8 @@ export const en = {
|
||||
'terminal.collapseAria': 'Collapse output',
|
||||
'terminal.expandAria': 'Expand the remaining {n} output lines',
|
||||
'terminal.expandRest': '… {n} more lines',
|
||||
// The Host terminal_send presenter has no locale seat; keep its fallbacks
|
||||
// aligned with these English values.
|
||||
'terminal.sendInput': '(send input)',
|
||||
'terminal.session': 'Terminal {sessionId}',
|
||||
} satisfies Record<ConversationKey, string>
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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: e7118eac75f31b3ffc3f2434371dba06f029d241
|
||||
README.zh.md: 62aa568bb7a14b5f703e60a0c263e851d01734ed
|
||||
README.md: ace08fae3a001080918973c23aa362080cd69066
|
||||
README.zh.md: 9df1664bc42c39012c2eebf397cb368b3b5c6260
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Produced-files and clickable-reference feature owner. The Node half registers final-response guidance with the system-prompt registry; the browser half registers the deliverables row a finished turn ends with into the chat view's `conversation.chat.turnTail` hole and links matching inline-code references in the closing prose. The shipped Web patch is the only composition that loads this package. Removing its one cordis.yml entry removes the guidance, row, and prose links together.
|
||||
|
||||
`deliverablesDefinition` folds each Turn's successful mutation calls into engine-published `DeliverablesTurnData`; `producedForClosing` reads that data with the closing Assistant seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per Turn in first-seen order. The Conversation Location index owns Turn membership, so a Turn that mutates and then ends without content text cannot spill into the next Turn's row.
|
||||
`deliverablesDefinition` folds each Turn's successful first-party mutation calls into engine-published `DeliverablesTurnData`; `producedForClosing` reads that data with the closing Assistant seq. The source is the validated raw arguments of `write`, `edit`, and the mutating `str_replace_editor` commands (`create`, `str_replace`, and `insert`), never presentation data or closing prose: a produced file is listed whether or not the model remembered to name it. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing; a path appears once per Turn in first-seen order. A new mutation tool needs an explicit Client contribution before it joins this list. The Conversation Location index owns Turn membership, so a Turn that mutates and then ends without content text cannot spill into the next Turn's row.
|
||||
|
||||
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label and one measured file lane. It shows the largest leading prefix that fits (up to six chips; basename text, full path as the `title`) while reserving the exact localized `+ N files` width, so the remainder stays visible without wrapping or horizontal scrolling. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. When files are hidden, a second-line **Show in folder** action opens the session workspace through that same owner path only while the page is loopback and the current Host handshake reports `canOpenPath`; direct remote Web and headless/container Linux Hosts omit the action by default. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
产出文件与可点击文件引用功能的属主。Node 侧向系统提示词 registry 注册最终回复指引;浏览器侧把已完成轮次末尾的产出文件行注册到 chat 视图的 `conversation.chat.turnTail` slot,并将收尾正文中匹配的行内代码引用转换为链接。正式提供的组合中只有 Web patch 加载本包;从 cordis.yml 中删去这一项会同时移除提示词、文件行与正文链接。
|
||||
|
||||
`deliverablesDefinition` 把每个轮次中成功的修改调用折叠进引擎发布的 `DeliverablesTurnData`;`producedForClosing` 结合收尾 Assistant 的 seq 读取这份数据。依据的是修改工具自身附带的 `locations`,而不是收尾正文:无论模型是否记得点名,产出文件都会被列出。修改操作按渲染意图而非工具名识别:diff 卡片,或 `kind` 为 `edit` 的通用卡片(即 `str_replace_editor` 的 insert 操作所呈现的形态);因此新的修改工具只需声明自身行为即可加入。读取、删除和失败的调用不贡献任何条目;同一路径在一个轮次内按首见顺序只出现一次。Conversation Location 索引负责维护轮次归属关系,因此一个轮次即使先修改文件、随后没有正文内容就结束,也不会溢进下一个轮次的行里。
|
||||
`deliverablesDefinition` 把每个轮次中成功的第一方修改调用折叠进引擎发布的 `DeliverablesTurnData`;`producedForClosing` 结合收尾 Assistant 的 seq 读取这份数据。依据的是 `write`、`edit` 和 `str_replace_editor` 修改命令(`create`、`str_replace`、`insert`)经过校验的原始参数,而不是展示数据或收尾正文:无论模型是否记得点名,产出文件都会被列出。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目;同一路径在一个轮次内按首见顺序只出现一次。新的修改工具必须先增加显式 Client contribution,才能加入该列表。Conversation Location 索引负责维护轮次归属关系,因此一个轮次即使先修改文件、随后没有正文内容就结束,也不会溢进下一个轮次的行里。
|
||||
|
||||
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签和一条经过测量的单行文件 lane。它展示能够放下的最大前缀(至多六个标签项;文本为文件名,完整路径作为 `title`),并为本地化后的精确 `+ N 个文件` 宽度预留空间,因此剩余计数始终可见,既不换行也不横向滚动。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。存在隐藏文件时,第二行的**在文件夹中显示**也经由同一属主路径打开会话 workspace;它只在页面使用 loopback 且当前 Host 握手报告 `canOpenPath` 时出现,直接远程 Web 与 headless/容器 Linux Host 默认均省略该操作。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md)。
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Deliverables plugin, browser half: registers the produced-files row into
|
||||
* the chat view's turn-tail chain, and provides the `chatFileMentions`
|
||||
* service that links inline-code mentions of produced files in the closing
|
||||
* prose. All policy lives here — the derivation from the mutation tools'
|
||||
* `locations`, the mention matching, the chip cap, and the copy — so
|
||||
* prose. All policy lives here — the supported mutation calls, mention
|
||||
* matching, chip cap, and copy — so
|
||||
* composing this plugin out of cordis.yml removes both surfaces entirely;
|
||||
* the owning view renders an empty chain and inert prose at zero cost.
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Turn-scoped produced-file Definition and readers. Client-only and
|
||||
* model-free: the vocabulary is the mutation tools' own follow-along
|
||||
* `locations`, never the closing prose.
|
||||
* model-free: the vocabulary comes from successful first-party mutation
|
||||
* calls, never presentation data or the closing prose.
|
||||
*/
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
|
||||
import type { ToolResultNode, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
@@ -27,38 +27,90 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
|
||||
interface DeliverablesState extends DeliverablesTurnData {
|
||||
readonly turn: number
|
||||
readonly calls: ReadonlyMap<string, ToolResultNode['callView']>
|
||||
readonly calls: ReadonlyMap<string, string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths a call view reports having created or changed, by render intent rather
|
||||
* than tool name: a diff card, or a generic card whose kind is `edit` (the
|
||||
* shape `str_replace_editor`'s insert presents). Every other card produces
|
||||
* nothing to open — a read looked, a delete removed, a terminal ran. Only
|
||||
* root call views enter this Turn accumulator; nested Code Mode dispatches
|
||||
* preserve the pre-assembly behavior and do not contribute independently.
|
||||
* Extract the path from a supported first-party mutation call. Session
|
||||
* `tool/call` events are root calls; Code Dispatch children do not enter this
|
||||
* Definition independently.
|
||||
* @param name - wire tool name.
|
||||
* @param argsRaw - model-produced JSON arguments.
|
||||
* @returns the mutation path, or null when the call is not a supported mutation.
|
||||
*/
|
||||
function producedPaths(view: ToolResultNode['callView']): readonly string[] {
|
||||
if (view === null) return []
|
||||
if (view.card === 'diff') return (view.locations ?? []).map(location => location.path)
|
||||
if (view.card === 'generic' && view.kind === 'edit') {
|
||||
return (view.locations ?? []).map(location => location.path)
|
||||
function mutationPath(name: string, argsRaw: string): string | null {
|
||||
let args: unknown
|
||||
try {
|
||||
args = JSON.parse(argsRaw) as unknown
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return []
|
||||
if (!isRecord(args)) return null
|
||||
switch (name) {
|
||||
case 'write':
|
||||
return typeof args.content === 'string' ? pathValue(args.file_path) : null
|
||||
case 'edit':
|
||||
return validEditArgs(args) ? pathValue(args.file_path) : null
|
||||
case 'str_replace_editor':
|
||||
return editorMutationPath(args)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the fields that an `edit` execution requires. */
|
||||
function validEditArgs(args: Readonly<Record<string, unknown>>): boolean {
|
||||
return typeof args.old_string === 'string'
|
||||
&& args.old_string.length > 0
|
||||
&& typeof args.new_string === 'string'
|
||||
&& args.old_string !== args.new_string
|
||||
&& (args.replace_all === undefined || typeof args.replace_all === 'boolean')
|
||||
}
|
||||
|
||||
/** Extract a path only from a complete mutating editor command. */
|
||||
function editorMutationPath(args: Readonly<Record<string, unknown>>): string | null {
|
||||
const path = pathValue(args.path)
|
||||
if (path === null) return null
|
||||
switch (args.command) {
|
||||
case 'create':
|
||||
return typeof args.file_text === 'string' ? path : null
|
||||
case 'str_replace':
|
||||
return typeof args.old_str === 'string'
|
||||
&& args.old_str.length > 0
|
||||
&& (args.new_str === undefined || typeof args.new_str === 'string')
|
||||
? path
|
||||
: null
|
||||
case 'insert':
|
||||
return typeof args.insert_line === 'number'
|
||||
&& Number.isInteger(args.insert_line)
|
||||
&& args.insert_line >= 0
|
||||
&& typeof args.new_str === 'string'
|
||||
? path
|
||||
: null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** A non-blank path preserves the exact spelling supplied to the tool. */
|
||||
function pathValue(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null
|
||||
}
|
||||
|
||||
/** Narrow parsed JSON to an argument object. */
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Files produced by one Turn data value.
|
||||
*
|
||||
* The source is the mutation tools' own follow-along `locations`, not the
|
||||
* closing prose: a produced file must be listed whether or not the model
|
||||
* remembered to name it. A mutation is recognized by render intent, not by
|
||||
* tool name — a diff card, or a generic card whose `kind` is `edit` (the shape
|
||||
* `str_replace_editor`'s insert presents) — so a new mutation tool joins by
|
||||
* declaring what it does. Reads contribute nothing (looking at a file does not
|
||||
* produce it), and neither do deletes (there is nothing left to open) or
|
||||
* failed calls. Paths keep first-seen order and appear once, so a file written
|
||||
* and then edited in the same turn is one entry.
|
||||
* The source is the arguments of successful `write`, `edit`, and mutating
|
||||
* `str_replace_editor` calls, not the closing prose: a produced file must be
|
||||
* listed whether or not the model remembered to name it. Reads, unsupported
|
||||
* tools, malformed calls, and failed results contribute nothing. Paths keep
|
||||
* first-seen order and appear once, so a file written and then edited in the
|
||||
* same turn is one entry.
|
||||
*
|
||||
* The Conversation Location index owns turn membership before this function
|
||||
* runs, so paths cannot spill across turns and this derivation does not infer
|
||||
@@ -112,7 +164,7 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
|
||||
const calls = new Map(context.state.calls)
|
||||
calls.set(
|
||||
String(match.event.data.callId),
|
||||
match.view?.for === 'call' ? match.view.view : null,
|
||||
mutationPath(match.event.data.name, match.event.data.arguments),
|
||||
)
|
||||
return { ...context.state, calls }
|
||||
}
|
||||
@@ -120,11 +172,10 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
|
||||
const result = match.event.data.message.content[0]
|
||||
if (result.isError === true) return context.state
|
||||
const callId = String(match.event.data.message.source.callId)
|
||||
const additions = producedPaths(context.state.calls.get(callId) ?? null)
|
||||
.map(path => ({ seq: match.event.seq, path }))
|
||||
return additions.length === 0
|
||||
const path = context.state.calls.get(callId)
|
||||
return path === null || path === undefined
|
||||
? context.state
|
||||
: { ...context.state, produced: [...context.state.produced, ...additions] }
|
||||
: { ...context.state, produced: [...context.state.produced, { seq: match.event.seq, path }] }
|
||||
},
|
||||
buildLocationData: (context, scope) => scope !== 'turn' || context.state === undefined
|
||||
? null
|
||||
|
||||
@@ -109,14 +109,12 @@ function at(
|
||||
seq: number,
|
||||
type: string,
|
||||
data: unknown,
|
||||
view?: ConversationEventInput['view'],
|
||||
): ConversationEventInput {
|
||||
return {
|
||||
event: {
|
||||
seq, time: seq * 1_000, type, data,
|
||||
...(type === 'tool/result' ? { surfaceOp: 'append' } : {}),
|
||||
} as ConversationEventInput['event'],
|
||||
...(view === undefined ? {} : { view }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,19 +122,27 @@ function matched(input: ConversationEventInput, role: ConversationMatch['role'])
|
||||
return { ...input, role, location: { kind: 'unresolved' } }
|
||||
}
|
||||
|
||||
type WireCallView = Extract<NonNullable<ConversationEventInput['view']>, { for: 'call' }>['view']
|
||||
|
||||
function call(
|
||||
seq: number,
|
||||
callId: string,
|
||||
view: WireCallView | null,
|
||||
name: string,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
turn = 1,
|
||||
): ConversationEventInput {
|
||||
return rawCall(seq, callId, name, JSON.stringify(args), turn)
|
||||
}
|
||||
|
||||
function rawCall(
|
||||
seq: number,
|
||||
callId: string,
|
||||
name: string,
|
||||
argsRaw: string,
|
||||
turn = 1,
|
||||
): ConversationEventInput {
|
||||
return at(
|
||||
seq,
|
||||
'tool/call',
|
||||
{ turn, step: 1, callId, name: 'fixture', arguments: '{}' },
|
||||
{ for: 'call', view: view ?? { card: 'generic', title: 'fixture' } },
|
||||
{ turn, step: 1, callId, name, arguments: argsRaw },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -151,18 +157,6 @@ function result(seq: number, callId: string, isError = false, turn = 1): Convers
|
||||
})
|
||||
}
|
||||
|
||||
function diff(...paths: string[]): WireCallView {
|
||||
return {
|
||||
card: 'diff', title: `Write ${paths[0] ?? ''}`,
|
||||
diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })),
|
||||
locations: paths.map(path => ({ path })),
|
||||
}
|
||||
}
|
||||
|
||||
function edit(path: string): WireCallView {
|
||||
return { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }
|
||||
}
|
||||
|
||||
function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler {
|
||||
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
||||
value.replaceWindow(entries, hasMore)
|
||||
@@ -189,36 +183,148 @@ describe('produced-file Turn data', () => {
|
||||
expect(selectProducedFiles(tailOwner(undefined, 9, () => {}, 2))).toBeNull()
|
||||
})
|
||||
|
||||
it('folds successful diff and generic-edit calls while ignoring reads, failures, and missing locations', () => {
|
||||
it('folds successful first-party mutation paths from their raw arguments', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
call(2, 'write', diff('out/index.html', 'out/app.css')),
|
||||
call(2, 'write', 'write', {
|
||||
file_path: 'out/index.html', path: 'wrong-write.txt', content: '<html></html>',
|
||||
}),
|
||||
result(3, 'write'),
|
||||
call(4, 'edit', edit('notes.md')),
|
||||
call(4, 'edit', 'edit', {
|
||||
file_path: 'out/app.css', path: 'wrong-edit.txt', old_string: 'red', new_string: 'blue',
|
||||
replace_all: false,
|
||||
}),
|
||||
result(5, 'edit'),
|
||||
call(6, 'read', { card: 'generic', title: 'Read', locations: [{ path: 'input.txt' }] }),
|
||||
result(7, 'read'),
|
||||
call(8, 'failed', diff('broken.txt')),
|
||||
result(9, 'failed', true),
|
||||
call(10, 'locationless', { card: 'diff', title: 'Write', diffs: [] }),
|
||||
result(11, 'locationless'),
|
||||
call(6, 'create', 'str_replace_editor', {
|
||||
command: 'create', path: 'notes/new.md', file_path: 'wrong-create.txt', file_text: 'new',
|
||||
}),
|
||||
result(7, 'create'),
|
||||
call(8, 'replace', 'str_replace_editor', {
|
||||
command: 'str_replace', path: 'notes/existing.md', old_str: 'old', new_str: 'new',
|
||||
}),
|
||||
result(9, 'replace'),
|
||||
call(10, 'delete-text', 'str_replace_editor', {
|
||||
command: 'str_replace', path: 'notes/deleted-text.md', old_str: 'remove me',
|
||||
}),
|
||||
result(11, 'delete-text'),
|
||||
call(12, 'insert', 'str_replace_editor', {
|
||||
command: 'insert', path: 'notes/inserted.md', insert_line: 1, new_str: 'line',
|
||||
}),
|
||||
result(13, 'insert'),
|
||||
])
|
||||
|
||||
expect(producedForClosing(deliverablesOf(value))).toEqual([
|
||||
'out/index.html', 'out/app.css', 'notes.md',
|
||||
'out/index.html',
|
||||
'out/app.css',
|
||||
'notes/new.md',
|
||||
'notes/existing.md',
|
||||
'notes/deleted-text.md',
|
||||
'notes/inserted.md',
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores calls without mutation locations, orphan results, and replacement results', () => {
|
||||
const replacement = result(8, 'replacement')
|
||||
it.each([
|
||||
{ caseName: 'write omits content', name: 'write', args: { file_path: 'write.txt' } },
|
||||
{ caseName: 'write has non-string content', name: 'write', args: { file_path: 'write.txt', content: 1 } },
|
||||
{
|
||||
caseName: 'edit omits old_string', name: 'edit',
|
||||
args: { file_path: 'edit.txt', new_string: 'new' },
|
||||
},
|
||||
{
|
||||
caseName: 'edit has an empty old_string', name: 'edit',
|
||||
args: { file_path: 'edit.txt', old_string: '', new_string: 'new' },
|
||||
},
|
||||
{
|
||||
caseName: 'edit omits new_string', name: 'edit',
|
||||
args: { file_path: 'edit.txt', old_string: 'old' },
|
||||
},
|
||||
{
|
||||
caseName: 'edit does not change the string', name: 'edit',
|
||||
args: { file_path: 'edit.txt', old_string: 'same', new_string: 'same' },
|
||||
},
|
||||
{
|
||||
caseName: 'edit has a non-boolean replace_all', name: 'edit',
|
||||
args: { file_path: 'edit.txt', old_string: 'old', new_string: 'new', replace_all: 'yes' },
|
||||
},
|
||||
{
|
||||
caseName: 'editor create omits file_text', name: 'str_replace_editor',
|
||||
args: { command: 'create', path: 'create.txt' },
|
||||
},
|
||||
{
|
||||
caseName: 'editor create has non-string file_text', name: 'str_replace_editor',
|
||||
args: { command: 'create', path: 'create.txt', file_text: 1 },
|
||||
},
|
||||
{
|
||||
caseName: 'editor replace omits old_str', name: 'str_replace_editor',
|
||||
args: { command: 'str_replace', path: 'replace.txt', new_str: 'new' },
|
||||
},
|
||||
{
|
||||
caseName: 'editor replace has an empty old_str', name: 'str_replace_editor',
|
||||
args: { command: 'str_replace', path: 'replace.txt', old_str: '' },
|
||||
},
|
||||
{
|
||||
caseName: 'editor replace has non-string new_str', name: 'str_replace_editor',
|
||||
args: { command: 'str_replace', path: 'replace.txt', old_str: 'old', new_str: 1 },
|
||||
},
|
||||
{
|
||||
caseName: 'editor insert omits insert_line', name: 'str_replace_editor',
|
||||
args: { command: 'insert', path: 'insert.txt', new_str: 'new' },
|
||||
},
|
||||
{
|
||||
caseName: 'editor insert has a fractional insert_line', name: 'str_replace_editor',
|
||||
args: { command: 'insert', path: 'insert.txt', insert_line: 1.5, new_str: 'new' },
|
||||
},
|
||||
{
|
||||
caseName: 'editor insert has a negative insert_line', name: 'str_replace_editor',
|
||||
args: { command: 'insert', path: 'insert.txt', insert_line: -1, new_str: 'new' },
|
||||
},
|
||||
{
|
||||
caseName: 'editor insert omits new_str', name: 'str_replace_editor',
|
||||
args: { command: 'insert', path: 'insert.txt', insert_line: 1 },
|
||||
},
|
||||
])('ignores a successful result when $caseName', ({ name, args }) => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'tool/call', { turn: 1, step: 1, callId: 'no-view', name: 'fixture', arguments: '{}' }),
|
||||
result(3, 'no-view'),
|
||||
call(4, 'locationless-edit', { card: 'generic', title: 'Edit', kind: 'edit' }),
|
||||
result(5, 'locationless-edit'),
|
||||
result(6, 'orphan'),
|
||||
call(7, 'replacement', diff('replaced.txt')),
|
||||
call(2, 'malformed', name, args),
|
||||
result(3, 'malformed'),
|
||||
])
|
||||
|
||||
expect(producedForClosing(deliverablesOf(value))).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores editor views, unsupported tools, failures, interruptions, malformed calls, and orphan results', () => {
|
||||
const replacement = result(25, 'replacement')
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
call(2, 'view', 'str_replace_editor', { command: 'view', path: 'viewed.txt' }),
|
||||
result(3, 'view'),
|
||||
call(4, 'read', 'read', { file_path: 'input.txt' }),
|
||||
result(5, 'read'),
|
||||
call(6, 'unknown', 'custom_edit', { file_path: 'custom.txt', path: 'custom.txt' }),
|
||||
result(7, 'unknown'),
|
||||
call(8, 'failed', 'write', { file_path: 'failed.txt', content: 'x' }),
|
||||
result(9, 'failed', true),
|
||||
call(10, 'interrupted', 'edit', {
|
||||
file_path: 'interrupted.txt', old_string: 'old', new_string: 'new',
|
||||
}),
|
||||
rawCall(11, 'invalid-json', 'write', '{'),
|
||||
result(12, 'invalid-json'),
|
||||
rawCall(13, 'null-args', 'write', 'null'),
|
||||
result(14, 'null-args'),
|
||||
rawCall(15, 'array-args', 'edit', '[]'),
|
||||
result(16, 'array-args'),
|
||||
call(17, 'missing-path', 'write', { content: 'x' }),
|
||||
result(18, 'missing-path'),
|
||||
call(19, 'blank-path', 'edit', {
|
||||
file_path: ' ', old_string: 'old', new_string: 'new',
|
||||
}),
|
||||
result(20, 'blank-path'),
|
||||
call(21, 'missing-editor-path', 'str_replace_editor', { command: 'create', file_text: 'x' }),
|
||||
result(22, 'missing-editor-path'),
|
||||
result(23, 'orphan'),
|
||||
call(24, 'replacement', 'str_replace_editor', {
|
||||
command: 'insert', path: 'replaced.txt', insert_line: 0, new_str: 'new',
|
||||
}),
|
||||
{
|
||||
...replacement,
|
||||
event: {
|
||||
@@ -226,7 +332,7 @@ describe('produced-file Turn data', () => {
|
||||
surfaceOp: { op: 'replace', start: 1, end: 1 },
|
||||
} as ConversationEventInput['event'],
|
||||
},
|
||||
at(9, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
at(26, 'turn/end', { turn: 1, reason: { kind: 'interrupted' } }),
|
||||
])
|
||||
|
||||
expect(producedForClosing(deliverablesOf(value))).toEqual([])
|
||||
@@ -255,7 +361,7 @@ describe('produced-file Turn data', () => {
|
||||
|
||||
it('replays a tail page once prepend supplies its missing Turn start', () => {
|
||||
const value = assembler([
|
||||
call(10, 'late', diff('history.txt')),
|
||||
call(10, 'late', 'write', { file_path: 'history.txt', content: 'history' }),
|
||||
result(11, 'late'),
|
||||
], true)
|
||||
expect(deliverablesOf(value)).toBeUndefined()
|
||||
@@ -268,13 +374,15 @@ describe('produced-file Turn data', () => {
|
||||
it('extends the same Turn data incrementally on live append', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
call(2, 'first', diff('first.txt')),
|
||||
call(2, 'first', 'write', { file_path: 'first.txt', content: 'first' }),
|
||||
result(3, 'first'),
|
||||
])
|
||||
const first = deliverablesOf(value)
|
||||
expect(producedForClosing(first)).toEqual(['first.txt'])
|
||||
|
||||
value.append(call(4, 'second', diff('second.txt')))
|
||||
value.append(call(4, 'second', 'edit', {
|
||||
file_path: 'second.txt', old_string: 'before', new_string: 'after',
|
||||
}))
|
||||
value.append(result(5, 'second'))
|
||||
value.flush()
|
||||
expect(producedForClosing(deliverablesOf(value))).toEqual(['first.txt', 'second.txt'])
|
||||
|
||||
@@ -24,8 +24,6 @@ function settled(over: Partial<ToolResultNode> = {}): ToolResultNode {
|
||||
callTime: 2_000,
|
||||
content: [{ type: 'text', text: 'Follow the issue workflow.\nKeep project fields in sync.' }],
|
||||
isError: false,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
subCalls: [],
|
||||
...over,
|
||||
}
|
||||
@@ -33,7 +31,7 @@ function settled(over: Partial<ToolResultNode> = {}): ToolResultNode {
|
||||
|
||||
function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall {
|
||||
return {
|
||||
callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null, subCalls: [],
|
||||
callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, subCalls: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +40,7 @@ function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowPro
|
||||
callId: block.callId,
|
||||
toolName: 'skill',
|
||||
block,
|
||||
|
||||
openFile: vi.fn(),
|
||||
inspect,
|
||||
t,
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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: 79b1bf27d848f015f132e38a635dd98c05a5dd87
|
||||
README.zh.md: 89469445b346dcb58a192a51cacdf9e66af50647
|
||||
README.md: 2db7d716dc80fbf40a953b217810fb8674e2e98f
|
||||
README.zh.md: 79ed5befe751b329984c1320144921339fdf3d3f
|
||||
|
||||
@@ -12,7 +12,7 @@ Business UI packages register only their wire Tool names and atomic views. They
|
||||
|
||||
Each root and child wrapper preserves the `data-chat-anchor-key="call:<id>"` and `data-chat-call-id` DOM contract used for paging and selection.
|
||||
|
||||
The package also fills `conversation.details.tool` with `ToolDetails`. The row and details renderers share the same pure card models for `terminal`, `read`, `diff`, `search`, and `web` render intents. Unknown intent tags and malformed wire card data fall back to flattened Tool result text.
|
||||
The package also fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, the existing Code Dispatch `parentCallId`, and Session path facts; unsupported or malformed inputs fall back to flattened Tool result text.
|
||||
|
||||
Generic rows classify known Tool names into search, read, shell, write, edit, code, or generic variants. Running, successful, failed, and interrupted lifecycle states come only from the frozen call/result slice. File paths resolve against the session `cwd` only when the user invokes the Host open-file callback; presentation code does not read Session services.
|
||||
|
||||
@@ -28,9 +28,9 @@ ctx.slots.inject('tool.call.toolview', () =>
|
||||
}, BusinessToolRow))
|
||||
```
|
||||
|
||||
The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. Path summaries relativize to the session cwd first, then replace a leftover POSIX host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge.
|
||||
The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. A Code Dispatch block retains its event's `parentCallId`; the field is absent on a root Session call, so row and Details card models preserve the generic flattened form for descendants without another placement flag. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service.
|
||||
|
||||
This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. `ui-skill` demonstrates a business-owned registration for `skill`.
|
||||
This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall`/`presentResult` values never enter the Client. `ui-skill` demonstrates a business-owned registration for `skill`.
|
||||
|
||||
Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), and [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) notes.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Client 工具展示插件。`ui-conversation` 通过 `conversation.chat.node`
|
||||
|
||||
每个 root 和 child 包装层都保留 `data-chat-anchor-key="call:<id>"` 与 `data-chat-call-id` DOM 约定,供分页和 selection 使用。
|
||||
|
||||
本包还通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与详情 renderer 共用同一组面向 `terminal`、`read`、`diff`、`search` 和 `web` render intent 的纯 card model。未知的 intent 标签和格式错误的 wire card 数据都会回退为压平的工具结果文本。
|
||||
本包还通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、现有 Code Dispatch `parentCallId` 与 Session 路径事实;不受支持或格式错误的输入回退为压平的工具结果文本。
|
||||
|
||||
通用行把已知工具名称归类为 search、read、shell、write、edit、code 或 generic 变体。运行中、成功、失败和中断状态只来自冻结的 call/result slice。只有用户调用 Host 打开文件回调时,文件路径才相对会话 `cwd` 解析;展示代码不读取会话服务。
|
||||
|
||||
@@ -28,9 +28,9 @@ ctx.slots.inject('tool.call.toolview', () =>
|
||||
}, BusinessToolRow))
|
||||
```
|
||||
|
||||
owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX 宿主家目录写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React node、运行时服务或 root/subcall 知识。
|
||||
owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。Code Dispatch block 保留其事件已有的 `parentCallId`;root Session call 没有该字段,因此 row 与 Details card model 无需另一项 placement 标志即可让 descendant 保持 generic 压平形态。路径摘要先相对 Session cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规 Session slot runtime share,但不会收到 React node 或 runtime service。
|
||||
|
||||
本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
|
||||
本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall`/`presentResult` 值不会进入 Client。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
|
||||
|
||||
各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md) 和 [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) Agent Note 负责。
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { ToolDetailsProps } from '../contract/slots.ts'
|
||||
import { diffCardModel } from './models/diff-card-model.ts'
|
||||
import { readCardModel } from './models/read-card-model.ts'
|
||||
import { searchCardModel } from './models/search-card-model.ts'
|
||||
import { terminalBlockLabels, terminalCardModel } from './models/terminal-card-model.ts'
|
||||
import {
|
||||
localizeTerminalCardModel, terminalBlockLabels, terminalCardModel,
|
||||
} from './models/terminal-card-model.ts'
|
||||
import {
|
||||
diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
|
||||
} from './models/primitive-labels.ts'
|
||||
@@ -13,8 +15,8 @@ import { webCardModel } from './models/web-card-model.ts'
|
||||
import css from './ToolDetails.module.css'
|
||||
|
||||
/**
|
||||
* Render the selected Tool call's structured output when its presentation
|
||||
* intent is known, otherwise preserve the flattened result text.
|
||||
* Render the selected Tool call's structured output when its raw fields form a
|
||||
* supported root card, otherwise preserve the flattened result text.
|
||||
* @param props - selected call slice, workspace root, host home, and locale seat.
|
||||
* @returns the details output body.
|
||||
*/
|
||||
@@ -22,8 +24,9 @@ export function ToolDetails({
|
||||
block, cwd, useHostDescription, t,
|
||||
}: Pick<ToolDetailsProps, 'block' | 'cwd' | 'useHostDescription' | 't'>) {
|
||||
const home = useHostDescription(description => description?.home)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
if (terminal !== null) {
|
||||
const terminalModel = terminalCardModel(block, cwd)
|
||||
if (terminalModel !== null) {
|
||||
const terminal = localizeTerminalCardModel(terminalModel, t)
|
||||
return (
|
||||
<>
|
||||
{terminal.description !== undefined ? (
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts'
|
||||
import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../models/read-card-model.ts'
|
||||
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../models/search-card-model.ts'
|
||||
import { terminalBlockLabels, type TerminalCardModel } from '../models/terminal-card-model.ts'
|
||||
import {
|
||||
localizeTerminalCardModel, terminalBlockLabels, type TerminalCardModel,
|
||||
} from '../models/terminal-card-model.ts'
|
||||
import {
|
||||
diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
|
||||
} from '../models/primitive-labels.ts'
|
||||
@@ -106,7 +108,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 ?? null
|
||||
const terminalBody = terminal === undefined || terminal === null
|
||||
? null
|
||||
: localizeTerminalCardModel(terminal, t)
|
||||
const diffBody = diff ?? null
|
||||
const readBody = read ?? null
|
||||
const searchBody = search ?? null
|
||||
@@ -118,7 +122,7 @@ export function ToolRow({
|
||||
const status = stateStatus(state, t)
|
||||
// A failure must replace, not supplement, the normal summary.
|
||||
const failureLine = state === 'error' ? errorSummary ?? null : null
|
||||
const summaryText = failureLine ?? summary
|
||||
const summaryText = failureLine ?? terminalBody?.description ?? summary
|
||||
const suffix = failureLine === null ? summarySuffix ?? null : null
|
||||
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
|
||||
const toggleExpand = () => {
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
/**
|
||||
* Pure derivation of the diff-card props from a frozen call slice: the
|
||||
* `card:'diff'` render intent the write/edit tools declare arrives on the
|
||||
* snapshot as `callView`/`resultView`, and this is the one place that turns
|
||||
* that pair into what {@link DiffBlock} draws. Both conversation render sites
|
||||
* (the chat tool row's expanded body and the details panel's Output section)
|
||||
* call this, so the hunks they show are derived once.
|
||||
* @module
|
||||
*/
|
||||
/** Pure diff-card derivation from raw file-mutation calls and result metadata. @module */
|
||||
import type { DiffBlockProps, DiffHunk } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolCallBlock } from './tool-call-model.ts'
|
||||
import { parsedToolCall, validEscalationFields } from './raw-tool-call.ts'
|
||||
|
||||
/**
|
||||
* Diff-body lines the chat row shows before collapsing the middle — half the
|
||||
@@ -36,13 +29,8 @@ export interface DiffCardModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a wire `card:'diff'` view's `diffs` to well-formed hunks. The event
|
||||
* view crosses the wire and `toolEventViewSchema` validates only the `card`
|
||||
* string, so a version mismatch or an anomalous plugin can deliver a `diff` card
|
||||
* whose `diffs` is absent, not an array, or carries malformed hunks. Returning
|
||||
* null for any of those routes the block to the generic path instead of letting
|
||||
* DiffBlock's `for...of`/`split` throw and crash the row or the details panel.
|
||||
* @param diffs - the view's `diffs` field, unverified.
|
||||
* Narrow opaque result metadata's `diffs` to well-formed hunks.
|
||||
* @param diffs - the metadata field to validate.
|
||||
* @returns the validated hunks, or null when the payload is not usable.
|
||||
*/
|
||||
function narrowDiffs(diffs: unknown): DiffHunk[] | null {
|
||||
@@ -59,39 +47,74 @@ function narrowDiffs(diffs: unknown): DiffHunk[] | null {
|
||||
return out
|
||||
}
|
||||
|
||||
type IntendedDiff = { tool: 'write' | 'edit' | 'str_replace_editor'; diff: DiffHunk }
|
||||
|
||||
function intendedDiff(block: ToolCallBlock): IntendedDiff | null {
|
||||
const parsed = parsedToolCall(block)
|
||||
if (parsed === null) return null
|
||||
if (parsed.name === 'str_replace_editor') {
|
||||
const { command, path, file_text: fileText, old_str: oldText, new_str: newText } = parsed.args
|
||||
if (typeof path !== 'string' || path.trim() === '') return null
|
||||
if (command === 'create') {
|
||||
if (fileText !== undefined && typeof fileText !== 'string') return null
|
||||
return {
|
||||
tool: 'str_replace_editor',
|
||||
diff: { path, oldText: null, newText: fileText ?? '' },
|
||||
}
|
||||
}
|
||||
if (command === 'str_replace') {
|
||||
if (oldText !== undefined && typeof oldText !== 'string') return null
|
||||
if (newText !== undefined && typeof newText !== 'string') return null
|
||||
return {
|
||||
tool: 'str_replace_editor',
|
||||
diff: { path, oldText: oldText ?? null, newText: newText ?? '' },
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
const { file_path: path } = parsed.args
|
||||
if (typeof path !== 'string' || path.trim() === '') return null
|
||||
if (!validEscalationFields(parsed.args)) return null
|
||||
if (parsed.name === 'write') {
|
||||
const { content } = parsed.args
|
||||
return typeof content === 'string'
|
||||
? { tool: 'write', diff: { path, oldText: null, newText: content } }
|
||||
: null
|
||||
}
|
||||
if (parsed.name !== 'edit') return null
|
||||
const { old_string: oldText, new_string: newText, replace_all: replaceAll } = parsed.args
|
||||
if (typeof oldText !== 'string' || typeof newText !== 'string') return null
|
||||
if (replaceAll !== undefined && typeof replaceAll !== 'boolean') return null
|
||||
return { tool: 'edit', diff: { path, oldText: oldText || null, newText } }
|
||||
}
|
||||
|
||||
function appliedDiffs(meta: unknown): DiffHunk[] | 'empty' | null {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return null
|
||||
const diffs = (meta as Record<string, unknown>).diffs
|
||||
if (!Array.isArray(diffs)) return null
|
||||
if (diffs.length === 0) return 'empty'
|
||||
return narrowDiffs(diffs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the diff-card props for a tool call, or null when this call is not a
|
||||
* diff card and belongs on the generic path.
|
||||
*
|
||||
* The result side is authoritative once the call settles: the write/edit tools
|
||||
* return the applied contextual hunks there (an edit's real before/after, a
|
||||
* create's whole-file diff), which replace the call-time diff derived from the
|
||||
* arguments alone. While the call is still running only the call side exists,
|
||||
* so a running write/edit shows its intended change. Null is the documented
|
||||
* generic-card default and covers every non-diff card — including a `card`
|
||||
* value this UI version does not know, which arrives over the wire and cannot
|
||||
* be trusted to be one of the compiled variants — and a settled call whose
|
||||
* result view is generic (how write/edit keep their execution errors on the
|
||||
* generic path).
|
||||
*
|
||||
* This derivation consumes only `diffs`; the render intent's `title` field is
|
||||
* deliberately dropped. The row supplies its own title (`Edit`/`Write · path`
|
||||
* from the args), which outranks the view's `title`. A tool that names its own
|
||||
* diff header therefore does not surface that text on the Web row.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* Derive running diffs for root write/edit and `str_replace_editor`
|
||||
* create/replace calls, plus applied settled diffs for root write/edit calls.
|
||||
* A successful write with valid empty metadata uses its argument-derived
|
||||
* whole-file diff, matching create and identical-overwrite presentation;
|
||||
* `str_replace_editor` settles through Generic because it has no result view.
|
||||
* @param block - running or settled Tool block.
|
||||
* @returns the diff-card props, or null for the generic path.
|
||||
*/
|
||||
export function diffCardModel(block: ToolCallBlock): DiffCardModel | null {
|
||||
if (!('kind' in block)) {
|
||||
// Running: the call view may carry the intended diff; the result is absent.
|
||||
const call = block.callView?.card === 'diff' ? block.callView : null
|
||||
const diffs = call === null ? null : narrowDiffs(call.diffs)
|
||||
return diffs === null ? null : { card: { diffs } }
|
||||
if (block.parentCallId !== undefined) return null
|
||||
const intended = intendedDiff(block)
|
||||
if (intended === null) return null
|
||||
if (!('kind' in block)) return { card: { diffs: [intended.diff] } }
|
||||
if (intended.tool === 'str_replace_editor') return null
|
||||
if (block.isError) return null
|
||||
const applied = appliedDiffs(block.meta)
|
||||
if (applied === null || applied === 'empty') {
|
||||
return intended.tool === 'write' ? { card: { diffs: [intended.diff] } } : null
|
||||
}
|
||||
// Settled: the result view's applied hunks replace the call-time diff. A
|
||||
// window that dropped the call head leaves only the result, which still
|
||||
// renders — the result view carries the whole change.
|
||||
const result = block.resultView?.card === 'diff' ? block.resultView : null
|
||||
const diffs = result === null ? null : narrowDiffs(result.diffs)
|
||||
return diffs === null ? null : { card: { diffs } }
|
||||
return { card: { diffs: applied } }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/** Shared narrowing for raw Tool call and result fields consumed by card models. */
|
||||
import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
|
||||
/** A parsed, in-window Tool call whose arguments are a JSON object. */
|
||||
export interface ParsedToolCall {
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
}
|
||||
|
||||
const parsedCalls = new WeakMap<ToolCallBlock, ParsedToolCall | null>()
|
||||
|
||||
/**
|
||||
* Parse the call head paired with one immutable Tool block.
|
||||
* @param block - running or settled Tool block.
|
||||
* @returns the Tool name and object arguments, or null when the call head or valid JSON object is unavailable.
|
||||
*/
|
||||
export function parsedToolCall(block: ToolCallBlock): ParsedToolCall | null {
|
||||
const cached = parsedCalls.get(block)
|
||||
if (cached !== undefined || parsedCalls.has(block)) return cached ?? null
|
||||
const call = 'kind' in block ? block.call : block
|
||||
if (call === null) {
|
||||
parsedCalls.set(block, null)
|
||||
return null
|
||||
}
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(call.argsRaw)
|
||||
} catch {
|
||||
parsedCalls.set(block, null)
|
||||
return null
|
||||
}
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
parsedCalls.set(block, null)
|
||||
return null
|
||||
}
|
||||
const parsed = { name: call.name, args: value as Record<string, unknown> }
|
||||
parsedCalls.set(block, parsed)
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the exact single text block consumed by first-party card derivations.
|
||||
* @param block - settled Tool result.
|
||||
* @returns its text, or undefined for any other content layout.
|
||||
*/
|
||||
export function singleResultText(block: ToolResultNode): string | undefined {
|
||||
if (block.content.length !== 1) return undefined
|
||||
const only = block.content[0]
|
||||
return only?.type === 'text' ? only.text : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the optional escalation pair shared by first-party shell and file
|
||||
* mutation tools.
|
||||
* @param args - parsed open-root Tool arguments.
|
||||
* @returns whether the declared escalation fields form a valid pair.
|
||||
*/
|
||||
export function validEscalationFields(args: Record<string, unknown>): boolean {
|
||||
const permission = args.sandbox_permissions
|
||||
const justification = args.justification
|
||||
if (permission === undefined && justification === undefined) return true
|
||||
if (permission !== 'workspace-write' && permission !== 'danger-full-access') return false
|
||||
return typeof justification === 'string' && justification.trim() !== ''
|
||||
}
|
||||
@@ -1,21 +1,8 @@
|
||||
/**
|
||||
* Pure derivation of the read-card props from a frozen call slice: the
|
||||
* `card:'read'` render intent the read tool declares arrives on the snapshot as
|
||||
* the settled result node's `resultView`, and this is the one place that turns
|
||||
* it into what {@link ReadBlock} draws. Both conversation render sites (the chat
|
||||
* tool row's resident body and the details panel's Output section) call this, so
|
||||
* the path, lines, total, and language they show are derived once.
|
||||
*
|
||||
* The read card is result-side only ([read card note](../../../../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md)):
|
||||
* a call carries no file content until `execute` returns, so the pending call
|
||||
* stays a generic card (`kind: 'read'`). A running read therefore has no read
|
||||
* card, and this returns null for it — the row keeps its args-derived summary
|
||||
* until the result arrives.
|
||||
* @module
|
||||
*/
|
||||
/** Pure read-card derivation from raw result content and metadata. @module */
|
||||
import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts'
|
||||
import { parsedToolCall, singleResultText } from './raw-tool-call.ts'
|
||||
|
||||
/**
|
||||
* Content lines the chat row's resident read body shows before collapsing the
|
||||
@@ -36,43 +23,75 @@ export const CHAT_READ_MAX_LINES = 8
|
||||
*/
|
||||
export type ReadCardModel = Pick<ReadBlockProps, 'label' | 'lines' | 'totalLines' | 'lang'>
|
||||
|
||||
interface ReadMeta {
|
||||
path: string
|
||||
offset: number
|
||||
lines: ReadBlockLine[]
|
||||
totalLines: number
|
||||
lang?: string
|
||||
}
|
||||
|
||||
function validReadCall(block: ToolCallBlock): boolean {
|
||||
const call = parsedToolCall(block)
|
||||
if (call?.name !== 'read') return false
|
||||
const { file_path: path, offset, limit } = call.args
|
||||
if (typeof path !== 'string' || path.trim() === '') return false
|
||||
if (offset !== undefined && (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 1)) return false
|
||||
if (limit !== undefined && (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function readMeta(meta: unknown): ReadMeta | null {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return null
|
||||
const { path, offset, lines, totalLines, lang } = meta as Record<string, unknown>
|
||||
if (typeof path !== 'string' || typeof offset !== 'number' || !Number.isInteger(offset) || offset < 1) return null
|
||||
if (typeof totalLines !== 'number' || !Number.isInteger(totalLines) || totalLines < 0 || !Array.isArray(lines)) return null
|
||||
if (lang !== undefined && typeof lang !== 'string') return null
|
||||
const narrowed: ReadBlockLine[] = []
|
||||
let previous = offset - 1
|
||||
for (const line of lines) {
|
||||
if (typeof line !== 'object' || line === null || Array.isArray(line)) return null
|
||||
const { number, text } = line as Record<string, unknown>
|
||||
if (typeof number !== 'number' || !Number.isInteger(number) || number < 1 || number <= previous) return null
|
||||
if (number > totalLines || typeof text !== 'string') return null
|
||||
previous = number
|
||||
narrowed.push({ number, text })
|
||||
}
|
||||
return {
|
||||
path,
|
||||
offset,
|
||||
lines: narrowed,
|
||||
totalLines,
|
||||
...lang === undefined ? {} : { lang },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the read-card props for a tool call, or null when this call is not a
|
||||
* read card and belongs on the generic path.
|
||||
*
|
||||
* The read card is result-side only, so only a settled call whose result view
|
||||
* declares `card:'read'` produces one. Every other case is null — the
|
||||
* documented generic-card default:
|
||||
*
|
||||
* - A running call: it has no result view yet, and a read carries no content at
|
||||
* call time.
|
||||
* - A settled call whose result view is not a read card — including a `card`
|
||||
* value this UI version does not know, which arrives over the wire and cannot
|
||||
* be trusted to be one of the compiled variants, and the read tool's own
|
||||
* generic fallback for an error result or a non-envelope body.
|
||||
*
|
||||
* The label is the read view's `title` when the tool supplied one (the
|
||||
* presentation contract's replacement-title rule), otherwise the file path
|
||||
* shortened the same way the row summary is: workspace-relative first, then
|
||||
* POSIX `~` for a leftover host-home path.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* Derive a settled root read card after validating its persisted metadata and
|
||||
* model-facing read envelope.
|
||||
* @param block - running or settled Tool block.
|
||||
* @param sessionCwd - the session workspace root; a workspace-rooted absolute
|
||||
* path label displays relative to it. Absent leaves the path as authored.
|
||||
* @param home - host account home; a leftover POSIX home path displays as `~`.
|
||||
* @returns the read-card props, or null for the generic path.
|
||||
*/
|
||||
export function readCardModel(block: ToolCallBlock, sessionCwd?: string, home?: string): ReadCardModel | null {
|
||||
// Running has no result view; a read carries no content until execute returns.
|
||||
if (!('kind' in block)) return null
|
||||
const result = block.resultView?.card === 'read' ? block.resultView : null
|
||||
if (result === null) return null
|
||||
// Lines arrive frozen off the snapshot; copy into the primitive's own line
|
||||
// shape so the card never holds a reference into the runtime's cache.
|
||||
const lines: ReadBlockLine[] = result.lines.map(line => ({ number: line.number, text: line.text }))
|
||||
export function readCardModel(
|
||||
block: ToolCallBlock,
|
||||
sessionCwd?: string,
|
||||
home?: string,
|
||||
): ReadCardModel | null {
|
||||
if (block.parentCallId !== undefined || !('kind' in block) || block.isError) return null
|
||||
if (!validReadCall(block)) return null
|
||||
const meta = readMeta(block.meta)
|
||||
if (meta === null) return null
|
||||
const text = singleResultText(block)
|
||||
if (text === undefined) return null
|
||||
const body = /^<path>[^\n]*<\/path>\n<type>file<\/type>\n<content>\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1]
|
||||
if (body === undefined) return null
|
||||
return {
|
||||
label: result.title ?? abbreviateHomePath(relativizeToCwd(result.path, sessionCwd), home),
|
||||
lines,
|
||||
totalLines: result.totalLines,
|
||||
lang: result.lang,
|
||||
label: abbreviateHomePath(relativizeToCwd(meta.path, sessionCwd), home),
|
||||
lines: meta.lines,
|
||||
totalLines: meta.totalLines,
|
||||
lang: meta.lang,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +1,71 @@
|
||||
/**
|
||||
* Pure derivation of the search-card props from a frozen call slice: the
|
||||
* `card:'search'` render intent the `grep` and `glob` tools declare arrives on
|
||||
* the snapshot as `resultView`, and this is the one place that turns it into
|
||||
* what {@link SearchBlock} draws. Both conversation render sites (the chat tool
|
||||
* row's resident body and the details panel's Output section) call this, so the
|
||||
* grouped matches or the path list they show are derived once.
|
||||
*
|
||||
* The search card is result-time only: a search call has no matches or paths
|
||||
* before `execute`, so its pending state stays a `GenericCallView`
|
||||
* ({@link module:@deepseek-ai/dsh-tools/src/presentation}). This derivation
|
||||
* therefore reads only `resultView` and returns null for a still-running call,
|
||||
* unlike the terminal card whose call view carries the command before
|
||||
* execution.
|
||||
*
|
||||
* A capped result also carries a recovery locator (grep/glob's `Full … stored
|
||||
* at …` footer) in the raw `tool/result` content, not in the structured
|
||||
* matches/paths the view carries. Since both render sites replace that raw
|
||||
* result with the card, this derivation surfaces the block's own result text as
|
||||
* {@link SearchCardModel.recovery} so the one path to the dropped rows is not
|
||||
* lost.
|
||||
* @module
|
||||
*/
|
||||
/** Pure search-card derivation from raw grep/glob result metadata. @module */
|
||||
import type { SearchBlockProps, SearchFileGroup } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolCallBlock } from './tool-call-model.ts'
|
||||
import { parsedToolCall } from './raw-tool-call.ts'
|
||||
|
||||
/**
|
||||
* Distributive `Omit`: a plain `Omit<A | B, K>` keeps only the keys common to
|
||||
* both members, which would drop the `files`/`paths` discriminated fields.
|
||||
* Distributing over the naked type parameter `T` preserves each shape.
|
||||
*/
|
||||
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never
|
||||
|
||||
/** The {@link SearchBlockProps} union minus each render site's own fields. */
|
||||
type SearchBlockModelProps = DistributiveOmit<SearchBlockProps, 'labels' | 'maxLines' | 'className'>
|
||||
|
||||
/**
|
||||
* Result rows the chat row's resident search body shows before collapsing the
|
||||
* middle — half the primitive's own default, which the details panel keeps. A
|
||||
* chat row is a summary surface inside the message flow: the flow must stay
|
||||
* scannable across many calls, while the details panel is the single-call
|
||||
* reading surface. A design constant of this UI's row geometry, not a
|
||||
* deployment choice, so it is fixed here rather than a plugin Config field.
|
||||
*/
|
||||
/** Result rows retained in a Chat card before its middle collapses. */
|
||||
export const CHAT_SEARCH_MAX_LINES = 8
|
||||
|
||||
/**
|
||||
* The {@link SearchBlock} props this derivation owns. Held as a nested object
|
||||
* (`card`) so a render site spreads exactly the primitive's own surface and can
|
||||
* never leak a neighbouring field into it. `maxLines`/`className` belong to each
|
||||
* render site.
|
||||
*/
|
||||
/** Search-card props plus an optional locator for a capped full result. */
|
||||
export interface SearchCardModel {
|
||||
/**
|
||||
* The props {@link SearchBlock} draws, minus each render site's own
|
||||
* `maxLines`/`className`.
|
||||
*/
|
||||
/** Props consumed by {@link SearchBlock}. */
|
||||
card: SearchBlockModelProps
|
||||
/**
|
||||
* The result view's replacement title, which the presentation contract lets a
|
||||
* search tool set at settle time. Absent when the presenter supplied none; a
|
||||
* row then keeps its args-derived summary.
|
||||
*/
|
||||
title: string | undefined
|
||||
/**
|
||||
* The raw `tool/result` text, flattened, surfaced only when the search was
|
||||
* capped. The card renders the retained matches or paths, but the recovery
|
||||
* locator a capped result carries — grep/glob's `Full … stored at: <locator>`
|
||||
* footer, the one way to reach the rows the cap dropped — lives only in the raw
|
||||
* result text, which the card replaces. A UI that shows the card would
|
||||
* otherwise lose it. Absent when the result was not capped (the card holds
|
||||
* every result) or the block carries no text.
|
||||
*/
|
||||
/** Raw result text containing the full-result locator for a capped search. */
|
||||
recovery: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every file group in a matches view is structurally valid: the wire
|
||||
* frame carries `shape` and `card` as strings the host schema checks, but not the
|
||||
* grouped `files` fields, so a version mismatch or loose producer could deliver
|
||||
* `shape: 'matches'` with a missing or malformed `files`. Rendering that would
|
||||
* crash {@link SearchBlock} at `.reduce`/`.map`; invalid fields select the
|
||||
* generic path instead.
|
||||
* @param files - the candidate `files` field off the untrusted result view.
|
||||
* @returns whether `files` is a valid {@link SearchFileGroup} array.
|
||||
*/
|
||||
function isValidFiles(files: unknown): files is SearchFileGroup[] {
|
||||
return Array.isArray(files) && files.every(file =>
|
||||
typeof file === 'object' && file !== null
|
||||
&& typeof (file as { path?: unknown }).path === 'string'
|
||||
&& Array.isArray((file as { matches?: unknown }).matches)
|
||||
&& (file as { matches: unknown[] }).matches.every(match =>
|
||||
typeof match === 'object' && match !== null
|
||||
&& typeof (match as { lineNumber?: unknown }).lineNumber === 'number'
|
||||
&& typeof (match as { line?: unknown }).line === 'string'))
|
||||
function validSearchCall(block: ToolCallBlock): 'grep' | 'glob' | null {
|
||||
const call = parsedToolCall(block)
|
||||
if (call === null) return null
|
||||
const { pattern, path } = call.args
|
||||
if (typeof pattern !== 'string') return null
|
||||
if (call.name === 'grep' && pattern === '') return null
|
||||
if (call.name === 'glob' && pattern.trim() === '') return null
|
||||
if (call.name !== 'grep' && call.name !== 'glob') return null
|
||||
if (path !== undefined && (typeof path !== 'string' || path.trim() === '')) return null
|
||||
if (call.name === 'grep') {
|
||||
const { include } = call.args
|
||||
if (include !== undefined && (typeof include !== 'string' || !validInclude(include))) return null
|
||||
}
|
||||
return call.name
|
||||
}
|
||||
|
||||
function validInclude(include: string): boolean {
|
||||
if (include.trim() === '' || include.startsWith('!')) return false
|
||||
let braceDepth = 0
|
||||
for (const character of include) {
|
||||
if (character === '{') braceDepth += 1
|
||||
else if (character === '}') braceDepth = Math.max(0, braceDepth - 1)
|
||||
else if (character === ',' && braceDepth === 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function searchFiles(value: unknown): SearchFileGroup[] | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
const files: SearchFileGroup[] = []
|
||||
for (const file of value) {
|
||||
if (typeof file !== 'object' || file === null || Array.isArray(file)) return null
|
||||
const { path, matches } = file as Record<string, unknown>
|
||||
if (typeof path !== 'string' || !Array.isArray(matches)) return null
|
||||
const narrowed: { lineNumber: number; line: string }[] = []
|
||||
for (const match of matches) {
|
||||
if (typeof match !== 'object' || match === null || Array.isArray(match)) return null
|
||||
const { lineNumber, line } = match as Record<string, unknown>
|
||||
if (typeof lineNumber !== 'number' || !Number.isInteger(lineNumber) || lineNumber < 1) return null
|
||||
if (typeof line !== 'string') return null
|
||||
narrowed.push({ lineNumber, line })
|
||||
}
|
||||
files.push({ path, matches: narrowed })
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a settled tool result's content blocks to their text, joined by
|
||||
* newlines. The search view carries no result text — a UI without a card falls
|
||||
* back to the raw `tool/result` content — so the truncation recovery footer is
|
||||
* read from the block's own content here. Non-text blocks (a search result
|
||||
* carries none) are skipped.
|
||||
* @param content - the result node's content blocks.
|
||||
* @returns the joined text, or undefined when empty.
|
||||
*/
|
||||
function flattenContent(content: readonly { type: string; text?: string }[]): string | undefined {
|
||||
const text = content
|
||||
.filter((block): block is { type: 'text'; text: string } => block.type === 'text' && typeof block.text === 'string')
|
||||
@@ -113,48 +75,26 @@ function flattenContent(content: readonly { type: string; text?: string }[]): st
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the search-card props for a tool call, or null when this call is not a
|
||||
* search card and belongs on the generic path.
|
||||
*
|
||||
* Only the result side matters: the search card carries no call-time state, so
|
||||
* a still-running call (no result view) is null, as is a settled call whose
|
||||
* result view is not a search card — including a `card` value this UI version
|
||||
* does not know, which arrives over the wire and cannot be trusted to be one of
|
||||
* the compiled variants, a `card: 'search'` view whose `shape` is neither
|
||||
* `matches` nor `paths` (equally untrusted wire data), and a generic result a
|
||||
* `grep`/`glob` failure or nested `run_code` dispatch produces (its text keeps
|
||||
* the generic path).
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @returns the search-card props, or null for the generic path.
|
||||
* Derive a settled root grep/glob card from persisted metadata.
|
||||
* @param block - running or settled Tool block.
|
||||
* @returns search-card props, or null for the generic path.
|
||||
*/
|
||||
export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
|
||||
// Running: no result view exists yet, and a search card is result-only.
|
||||
if (!('kind' in block)) return null
|
||||
const result = block.resultView?.card === 'search' ? block.resultView : null
|
||||
if (result === null) return null
|
||||
const common = { truncated: result.truncated, total: result.total }
|
||||
// The recovery footer only matters when the tool capped the result: an
|
||||
// uncapped card holds every match/path, so the raw text adds nothing the card
|
||||
// does not already show. When capped, the raw result's `Full … stored at …`
|
||||
// locator is the only way to retrieve the omitted rows, so include it.
|
||||
const recovery = result.truncated ? flattenContent(block.content) : undefined
|
||||
if (result.shape === 'matches') {
|
||||
// `files` rides the untrusted wire frame: the host schema checks `card`/`shape`
|
||||
// strings but not the grouped `files` fields, so validate them before
|
||||
// SearchBlock, which would crash on a missing or malformed `files`.
|
||||
// Invalid fields select the generic view.
|
||||
if (!isValidFiles(result.files)) return null
|
||||
return { title: result.title, recovery, card: { kind: 'matches', files: result.files, ...common } }
|
||||
if (block.parentCallId !== undefined || !('kind' in block) || block.isError) return null
|
||||
const tool = validSearchCall(block)
|
||||
if (tool === null) return null
|
||||
if (typeof block.meta !== 'object' || block.meta === null || Array.isArray(block.meta)) return null
|
||||
const meta = block.meta as Record<string, unknown>
|
||||
if (typeof meta.truncated !== 'boolean') return null
|
||||
if (typeof meta.total !== 'number' || !Number.isInteger(meta.total) || meta.total < 0) return null
|
||||
const common = { truncated: meta.truncated, total: meta.total }
|
||||
const recovery = meta.truncated ? flattenContent(block.content) : undefined
|
||||
if (tool === 'grep') {
|
||||
if (meta.shape !== 'matches') return null
|
||||
const files = searchFiles(meta.files)
|
||||
return files === null ? null : { recovery, card: { kind: 'matches', files, ...common } }
|
||||
}
|
||||
// `shape` rides the same untrusted wire frame as `card`, so a version mismatch
|
||||
// or a loose protocol producer could deliver a `card: 'search'` subtype this
|
||||
// client does not compile. Guard the paths shape explicitly: an unknown shape
|
||||
// falls to the generic path rather than being rendered as a paths card, which
|
||||
// would leave SearchBlock calling `.length`/`.map` on an absent `paths`.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- shape is wire data; the compiled union cannot prove this exhaustive.
|
||||
if (result.shape !== 'paths') return null
|
||||
// `paths` is likewise unchecked by the wire schema; a known shape with a
|
||||
// missing/malformed array would crash the paths card at `.map`.
|
||||
if (!Array.isArray(result.paths) || !result.paths.every((path): path is string => typeof path === 'string')) return null
|
||||
return { title: result.title, recovery, card: { kind: 'paths', paths: result.paths, ...common } }
|
||||
if (meta.shape !== 'paths' || !Array.isArray(meta.paths)) return null
|
||||
if (!meta.paths.every((path): path is string => typeof path === 'string')) return null
|
||||
return { recovery, card: { kind: 'paths', paths: [...meta.paths], ...common } }
|
||||
}
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
/**
|
||||
* Pure derivation of the terminal-card props from a frozen call slice: the
|
||||
* `card:'terminal'` render intent the shell tools declare arrives on the
|
||||
* snapshot as `callView`/`resultView`, and this is the one place that turns
|
||||
* that pair into what {@link TerminalBlock} draws. Both conversation render
|
||||
* sites (the chat tool row's expanded body and the details panel's Output
|
||||
* section) call this, so the command, cwd, output and exit status they show
|
||||
* are derived once.
|
||||
* @module
|
||||
*/
|
||||
/** Pure terminal-card derivation from raw Tool call and result fields. @module */
|
||||
import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type { ToolCallBlock } from './tool-call-model.ts'
|
||||
import { parsedToolCall, singleResultText, validEscalationFields } from './raw-tool-call.ts'
|
||||
|
||||
/**
|
||||
* Build the TerminalBlock display copy from the conversation locale seat —
|
||||
@@ -40,24 +32,53 @@ export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlo
|
||||
|
||||
/**
|
||||
* The {@link TerminalBlock} props this derivation owns. Picked off the
|
||||
* primitive's props so the two stay in step; `home` is absent because the web
|
||||
* client has no home path for the session host (a cwd renders as its last
|
||||
* path segment), and `maxLines`/`className` belong to each render site.
|
||||
* primitive's props so the two stay in step; `maxLines`/`className` belong to
|
||||
* each render site.
|
||||
*/
|
||||
export interface TerminalCardModel {
|
||||
/**
|
||||
* The props {@link TerminalBlock} draws. Held as a nested object so a render
|
||||
* site spreads exactly the primitive's own surface and can never leak a
|
||||
* neighbouring field into it.
|
||||
* The locale-neutral props {@link TerminalBlock} draws. The render site adds
|
||||
* `command` after resolving {@link copy} through its locale seat.
|
||||
*/
|
||||
card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
|
||||
card: Pick<TerminalBlockProps, 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
|
||||
/**
|
||||
* The call view's model-authored description, which the contract defines as
|
||||
* rendering ABOVE the card (the card itself has no description slot). Absent
|
||||
* when the presenter supplied none, or when the window dropped the call side;
|
||||
* a row then keeps its args-derived summary.
|
||||
* Verbatim Tool data or semantic `terminal_send` data. Product copy stays
|
||||
* unresolved until a render site supplies its locale seat.
|
||||
*/
|
||||
description: string | undefined
|
||||
copy:
|
||||
| { readonly kind: 'shell'; readonly command: string; readonly description: string | undefined }
|
||||
| { readonly kind: 'terminal-send'; readonly text: string; readonly sessionId: string }
|
||||
}
|
||||
|
||||
interface LocalizedTerminalCardModel {
|
||||
readonly card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
|
||||
readonly description: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve locale-owned `terminal_send` copy while preserving Tool-authored
|
||||
* shell commands and descriptions verbatim.
|
||||
* @param model - locale-neutral terminal card data.
|
||||
* @param t - the render site's conversation locale seat.
|
||||
* @returns terminal props and description ready for rendering.
|
||||
*/
|
||||
export function localizeTerminalCardModel(
|
||||
model: TerminalCardModel,
|
||||
t: TranslateNS<'conversation'>,
|
||||
): LocalizedTerminalCardModel {
|
||||
if (model.copy.kind === 'shell') {
|
||||
return {
|
||||
card: { command: model.copy.command, ...model.card },
|
||||
description: model.copy.description,
|
||||
}
|
||||
}
|
||||
return {
|
||||
card: {
|
||||
command: model.copy.text === '' ? t('terminal.sendInput') : model.copy.text,
|
||||
...model.card,
|
||||
},
|
||||
description: t('terminal.session', { sessionId: model.copy.sessionId }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,21 +96,18 @@ export function terminalFailed(model: TerminalCardModel): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a terminal view's working directory the way the render-intent
|
||||
* contract assigns to the UI bridge: an absolute path is used as-is, a relative
|
||||
* one joins under the session workspace, and an omitted one IS the session
|
||||
* workspace. A pure presenter cannot see the session cwd, which is why this
|
||||
* resolution belongs here rather than in the tool. Without a session cwd there
|
||||
* is nothing to resolve against, so a relative path stays as authored and an
|
||||
* omitted one stays absent (the prompt row then draws a bare `$`).
|
||||
* @param viewCwd - the cwd the terminal call view carries, if any.
|
||||
* Resolve a shell call's workdir for display: an absolute path is used as-is,
|
||||
* a relative one joins under the session workspace, and an omitted one is the
|
||||
* session workspace. Without a session cwd, a relative path stays as authored
|
||||
* and an omitted one stays absent.
|
||||
* @param workdir - the raw call's workdir, if any.
|
||||
* @param sessionCwd - the session workspace root, if the caller knows it.
|
||||
* @returns the working directory for the prompt label, or undefined.
|
||||
*/
|
||||
function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
|
||||
if (viewCwd === undefined || viewCwd === '') return sessionCwd
|
||||
if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(viewCwd)
|
||||
return normalizeSegments(resolveWorkspacePath(sessionCwd, viewCwd))
|
||||
function resolveTerminalCwd(workdir: string | undefined, sessionCwd: string | undefined): string | undefined {
|
||||
if (workdir === undefined || workdir === '') return sessionCwd
|
||||
if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(workdir)
|
||||
return normalizeSegments(resolveWorkspacePath(sessionCwd, workdir))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,40 +171,103 @@ function collapse(body: string, rooted: boolean, separator = '/'): string {
|
||||
return kept.join(separator)
|
||||
}
|
||||
|
||||
interface ShellCall {
|
||||
kind: 'shell'
|
||||
command: string
|
||||
description: string | undefined
|
||||
workdir: string | undefined
|
||||
persistent: boolean
|
||||
background: boolean
|
||||
}
|
||||
|
||||
function shellCall(name: string, args: Record<string, unknown>): ShellCall | null {
|
||||
if (name !== 'bash' && name !== 'pwsh') return null
|
||||
const { command, description, timeoutMs, workdir, run_in_background: background } = args
|
||||
if (typeof command !== 'string' || command.trim() === '') return null
|
||||
if (timeoutMs !== undefined && (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0)) return null
|
||||
if (workdir !== undefined && typeof workdir !== 'string') return null
|
||||
if (background !== undefined && typeof background !== 'boolean') return null
|
||||
if (!validEscalationFields(args)) return null
|
||||
if (description === undefined) {
|
||||
// Standard dsh-tool-bash and dsh-tool-pwsh schemas require `description`;
|
||||
// persistent shell providers omit it. Their parameter roots stay open, so
|
||||
// unrelated fields do not change their running-card behavior.
|
||||
return { kind: 'shell', command, description: undefined, workdir: undefined, persistent: true, background: false }
|
||||
}
|
||||
if (typeof description !== 'string' || description.trim() === '') return null
|
||||
return {
|
||||
kind: 'shell',
|
||||
command,
|
||||
description,
|
||||
workdir,
|
||||
persistent: false,
|
||||
background: background === true,
|
||||
}
|
||||
}
|
||||
|
||||
interface TerminalSendCall {
|
||||
kind: 'terminal-send'
|
||||
text: string
|
||||
sessionId: string
|
||||
background: boolean
|
||||
}
|
||||
|
||||
function terminalSendCall(name: string, args: Record<string, unknown>): TerminalSendCall | null {
|
||||
if (name !== 'terminal_send') return null
|
||||
const { sessionId, text, submit, run_in_background: background } = args
|
||||
if (typeof sessionId !== 'string' || sessionId === '' || typeof text !== 'string') return null
|
||||
if (submit !== undefined && typeof submit !== 'boolean') return null
|
||||
if (background !== undefined && typeof background !== 'boolean') return null
|
||||
return {
|
||||
kind: 'terminal-send',
|
||||
text,
|
||||
sessionId,
|
||||
background: background === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the terminal-card props for a tool call, or null when this call is
|
||||
* not a terminal card and belongs on the generic path.
|
||||
*
|
||||
* The call side supplies the command and its working directory; the result
|
||||
* side supplies the captured output and exit status. Three cases produce
|
||||
* null, all of them the documented generic-card default:
|
||||
*
|
||||
* - Neither side declares `card:'terminal'` — including a `card` value this
|
||||
* UI version does not know, which arrives over the wire and therefore
|
||||
* cannot be trusted to be one of the compiled variants.
|
||||
* - A settled call whose result view is not a terminal card: the result
|
||||
* presentation decides how the settled call renders, and the bash tool
|
||||
* returns a generic fenced card for an execution error or a background
|
||||
* start, whose text and error styling the generic path preserves.
|
||||
*
|
||||
* Window truncation can drop the call head from a settled `ToolResultNode`,
|
||||
* leaving a terminal result with no call side. That still renders: the command
|
||||
* falls back to the result view's replacement title, then to an empty command (the prompt line
|
||||
* draws bare), and the prompt shows no cwd.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @param sessionCwd - the session workspace root, which resolves an omitted or
|
||||
* relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved.
|
||||
* @returns the terminal-card props, or null for the generic path.
|
||||
* Parse the marker literals owned by `@deepseek-ai/dsh-shell/render` without
|
||||
* importing that Host-only package into the Client dependency graph.
|
||||
* @param text - rendered shell result text.
|
||||
* @returns output with a trailing exit-code or signal marker extracted.
|
||||
*/
|
||||
export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): TerminalCardModel | null {
|
||||
const call = block.callView?.card === 'terminal' ? block.callView : null
|
||||
function parseExitStatus(text: string): { output: string; exitCode?: number; signal?: string } {
|
||||
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
|
||||
if (signal?.[1] !== undefined) return { output: text.slice(0, signal.index), signal: signal[1] }
|
||||
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
|
||||
if (exit?.[1] !== undefined) return { output: text.slice(0, exit.index), exitCode: Number(exit[1]) }
|
||||
return { output: text, exitCode: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive terminal props for supported root shell and terminal-send calls.
|
||||
* Standard shell results parse their final status marker; persistent shell
|
||||
* results, background calls, errors, malformed input, or child dispatches use
|
||||
* the generic path.
|
||||
* @param block - running or settled Tool block.
|
||||
* @param sessionCwd - session workspace root used to resolve workdir.
|
||||
* @returns locale-neutral terminal-card data, or null for the generic path.
|
||||
*/
|
||||
export function terminalCardModel(
|
||||
block: ToolCallBlock,
|
||||
sessionCwd?: string,
|
||||
): TerminalCardModel | null {
|
||||
if (block.parentCallId !== undefined) return null
|
||||
const parsed = parsedToolCall(block)
|
||||
if (parsed === null) return null
|
||||
const call = shellCall(parsed.name, parsed.args) ?? terminalSendCall(parsed.name, parsed.args)
|
||||
if (call === null || call.background) return null
|
||||
|
||||
const copy: TerminalCardModel['copy'] = call.kind === 'shell'
|
||||
? { kind: 'shell', command: call.command, description: call.description }
|
||||
: { kind: 'terminal-send', text: call.text, sessionId: call.sessionId }
|
||||
const cwd = resolveTerminalCwd(call.kind === 'shell' ? call.workdir : undefined, sessionCwd)
|
||||
if (!('kind' in block)) {
|
||||
// Running: the call view exists, the result view does not yet.
|
||||
return call === null ? null : {
|
||||
description: call.description,
|
||||
return {
|
||||
copy,
|
||||
card: {
|
||||
command: call.title,
|
||||
cwd: resolveTerminalCwd(call.cwd, sessionCwd),
|
||||
cwd,
|
||||
output: undefined,
|
||||
exitCode: undefined,
|
||||
signal: undefined,
|
||||
@@ -194,24 +275,17 @@ export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): Te
|
||||
},
|
||||
}
|
||||
}
|
||||
const result = block.resultView?.card === 'terminal' ? block.resultView : null
|
||||
if (result === null) return null
|
||||
if (block.isError || (call.kind === 'shell' && call.persistent)) return null
|
||||
const output = singleResultText(block)
|
||||
if (output === undefined) return null
|
||||
const status = call.kind === 'terminal-send' ? { output } : parseExitStatus(output)
|
||||
return {
|
||||
description: call?.description,
|
||||
copy,
|
||||
card: {
|
||||
// The result's title REPLACES the pending one when the tool supplies it
|
||||
// (the presentation contract's replacement-title rule); the call title is
|
||||
// what a result without one keeps.
|
||||
command: result.title ?? call?.title ?? '',
|
||||
// Only a PRESENT call view can mean "omitted the cwd, so use the
|
||||
// workspace". When the window dropped the call head there is no cwd
|
||||
// anywhere — the result view carries none — and the original call may
|
||||
// well have used an explicit workdir, so the prompt draws a bare `$`
|
||||
// rather than naming a directory this card cannot know.
|
||||
cwd: call === null ? undefined : resolveTerminalCwd(call.cwd, sessionCwd),
|
||||
output: result.output,
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
cwd,
|
||||
output: status.output,
|
||||
exitCode: status.exitCode,
|
||||
signal: status.signal,
|
||||
running: false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
* Pure row-model derivation for tool summary rows: variant classification,
|
||||
* one-line summary, expanded-body text, and flattened result output from the
|
||||
* frozen call slice. Input material comes from the call ARGUMENTS; output and
|
||||
* error material from the settled result node. A call whose render intent is
|
||||
* a terminal card gets its expanded body from the views instead, through
|
||||
* `terminalCardModel` in terminal-card-model.ts.
|
||||
* error material from the settled result node. A supported terminal call gets
|
||||
* its expanded body from `terminalCardModel` instead.
|
||||
*/
|
||||
// The block union's defining home is runtime (fold-product types); this
|
||||
// contract only forwards it (type-definition authority stays with the layer
|
||||
|
||||
@@ -1,84 +1,82 @@
|
||||
/**
|
||||
* Pure derivation of the web-card props from a frozen call slice: the
|
||||
* `card:'web'` render intent the `web_search`/`web_fetch` tools declare at
|
||||
* result time arrives on the snapshot as `resultView`, and this is the one
|
||||
* place that turns it into what {@link WebBlock} draws. Both conversation
|
||||
* render sites (the chat tool row's resident/expanded body and the details
|
||||
* panel's Output section) call this, so the sources and fetch summary they
|
||||
* show are derived once.
|
||||
*
|
||||
* The web card is result-only by contract: those tools keep a generic pending
|
||||
* call view, so there is nothing to derive while the call is still running and
|
||||
* a running call always takes the generic path.
|
||||
* @module
|
||||
*/
|
||||
/** Pure web-card derivation from raw web result metadata. @module */
|
||||
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolCallBlock } from './tool-call-model.ts'
|
||||
import { parsedToolCall } from './raw-tool-call.ts'
|
||||
|
||||
/**
|
||||
* Derive the web-card props for a tool call, or null when this call is not a
|
||||
* web card and belongs on the generic path.
|
||||
*
|
||||
* The result side supplies the whole card: the sources and answer for a
|
||||
* `search`, the URL and status for a `fetch`. Cases producing null, all of
|
||||
* them the documented generic-card default:
|
||||
*
|
||||
* - A running call (no `resultView` yet): the web tools keep a generic pending
|
||||
* card, so nothing web-shaped exists until the call settles.
|
||||
* - A settled call whose result view is not a web card — including a `card`
|
||||
* value this UI version does not know, which arrives over the wire and so
|
||||
* cannot be trusted to be one of the compiled variants, and a generic result
|
||||
* view (a web tool's error path returns the generic card, whose text the
|
||||
* generic path preserves).
|
||||
* - A web card whose `kind` this UI version does not know (a newer host's
|
||||
* value): the wire cannot be trusted to be `search` or `fetch`, so it takes
|
||||
* the generic path rather than rendering as a malformed fetch.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @returns the web-card props, or null for the generic path.
|
||||
*/
|
||||
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never
|
||||
|
||||
/** Web-card data owned by the presenter; render sites add localized labels and classes. */
|
||||
export type WebCardModelProps = DistributiveOmit<WebBlockProps, 'labels' | 'className'>
|
||||
|
||||
/**
|
||||
* Derive locale-independent web-card data from a frozen tool-call slice.
|
||||
* @param block - Running or settled tool call from the conversation snapshot.
|
||||
* @returns Web-card data, or null when the generic presenter owns the call.
|
||||
*/
|
||||
export function webCardModel(block: ToolCallBlock): WebCardModelProps | null {
|
||||
// Running calls have no result view; the web card is result-only.
|
||||
if (!('kind' in block)) return null
|
||||
const result = block.resultView
|
||||
if (result?.card !== 'web') return null
|
||||
if (result.kind === 'search') {
|
||||
return {
|
||||
kind: 'search',
|
||||
answer: result.answer,
|
||||
sources: result.sources.map(source => ({
|
||||
url: source.url,
|
||||
title: source.title,
|
||||
snippet: source.snippet,
|
||||
publishedAt: source.publishedAt,
|
||||
})),
|
||||
truncated: result.truncated,
|
||||
}
|
||||
function validWebCall(block: ToolCallBlock): 'web_search' | 'web_fetch' | null {
|
||||
const call = parsedToolCall(block)
|
||||
if (call === null) return null
|
||||
if (call.name === 'web_search') {
|
||||
const { queries } = call.args
|
||||
if (!Array.isArray(queries) || queries.length === 0) return null
|
||||
return queries.every(query => typeof query === 'string' && query.trim() !== '') ? call.name : null
|
||||
}
|
||||
// Discriminate `fetch` explicitly rather than treating it as the else of
|
||||
// `search`: a `kind` this UI version does not know arrives over the wire from
|
||||
// a newer host, and reading it as a fetch would draw an empty URL and
|
||||
// `HTTP undefined`. It takes the generic path, the same wire-boundary default
|
||||
// an unknown `card` tag takes above. The static union narrows `kind` to
|
||||
// `'fetch'` here, but the runtime value is off the wire, so the guard and its
|
||||
// null fallthrough are load-bearing despite the type.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
if (result.kind === 'fetch') {
|
||||
return {
|
||||
kind: 'fetch',
|
||||
url: result.url,
|
||||
statusCode: result.statusCode,
|
||||
truncated: result.truncated,
|
||||
}
|
||||
if (call.name === 'web_fetch') {
|
||||
const { url } = call.args
|
||||
return typeof url === 'string' && url.trim() !== '' ? call.name : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface WebSource {
|
||||
url: string
|
||||
title?: string
|
||||
snippet?: string
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
function webSources(value: unknown): WebSource[] | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
const sources: WebSource[] = []
|
||||
for (const source of value) {
|
||||
if (typeof source !== 'object' || source === null || Array.isArray(source)) return null
|
||||
const { url, title, snippet, publishedAt } = source as Record<string, unknown>
|
||||
if (typeof url !== 'string') return null
|
||||
if (title !== undefined && typeof title !== 'string') return null
|
||||
if (snippet !== undefined && typeof snippet !== 'string') return null
|
||||
if (publishedAt !== undefined && typeof publishedAt !== 'string') return null
|
||||
sources.push({
|
||||
url,
|
||||
...title === undefined ? {} : { title },
|
||||
...snippet === undefined ? {} : { snippet },
|
||||
...publishedAt === undefined ? {} : { publishedAt },
|
||||
})
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a settled root web-search or web-fetch card from persisted metadata.
|
||||
* @param block - running or settled Tool block.
|
||||
* @returns web-card props, or null for the generic path.
|
||||
*/
|
||||
export function webCardModel(block: ToolCallBlock): WebCardModelProps | null {
|
||||
if (block.parentCallId !== undefined || !('kind' in block) || block.isError) return null
|
||||
const tool = validWebCall(block)
|
||||
if (tool === null || typeof block.meta !== 'object' || block.meta === null || Array.isArray(block.meta)) return null
|
||||
const meta = block.meta as Record<string, unknown>
|
||||
if (typeof meta.truncated !== 'boolean') return null
|
||||
if (tool === 'web_search') {
|
||||
const sources = webSources(meta.sources)
|
||||
if (sources === null || (meta.answer !== undefined && typeof meta.answer !== 'string')) return null
|
||||
return {
|
||||
kind: 'search',
|
||||
answer: meta.answer,
|
||||
sources,
|
||||
truncated: meta.truncated,
|
||||
}
|
||||
}
|
||||
if (typeof meta.url !== 'string') return null
|
||||
if (typeof meta.statusCode !== 'number' || !Number.isInteger(meta.statusCode)) return null
|
||||
return {
|
||||
kind: 'fetch',
|
||||
url: meta.url,
|
||||
statusCode: meta.statusCode,
|
||||
truncated: meta.truncated,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect,
|
||||
toolName={toolName}
|
||||
icon={VARIANT_ICONS[model.variant]}
|
||||
title={t(model.titleKey)}
|
||||
summary={terminal?.description ?? search?.title ?? model.summary}
|
||||
summary={model.summary}
|
||||
// Single-file tools never expose an args body — the path link is the only
|
||||
// args interaction. A card is not an args body: a read/write/edit row is
|
||||
// single-file AND carries a card, so the card expands under the path link.
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallViewProps } from '../../contract/slots.ts'
|
||||
import { terminalBlockLabels, terminalCardModel, terminalFailed } from '../models/terminal-card-model.ts'
|
||||
import {
|
||||
localizeTerminalCardModel, terminalBlockLabels, terminalCardModel, terminalFailed,
|
||||
} from '../models/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../models/tool-call-model.ts'
|
||||
import { CONVERSATION_NS as NS } from '../../locale.ts'
|
||||
import css from './bash-sample.module.css'
|
||||
@@ -35,19 +37,20 @@ 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)
|
||||
// Session workspace root: the terminal view's cwd resolves against it (an
|
||||
// omitted workdir IS the workspace), which the pure presenter cannot do.
|
||||
// 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 terminal = terminalCardModel(block, cwd)
|
||||
const terminalModel = terminalCardModel(block, cwd)
|
||||
const terminal = terminalModel === null ? null : localizeTerminalCardModel(terminalModel, t)
|
||||
// A failing exit status is the terminal card's own error signal (the call
|
||||
// itself settles isError:false), surfaced as the row's red state dot.
|
||||
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
|
||||
const state = model.state === 'ok' && terminalModel !== null && terminalFailed(terminalModel)
|
||||
? 'error'
|
||||
: model.state
|
||||
const status = stateStatus(state, t)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
// Execution failures (for example cancellation before the process reports a
|
||||
// terminal result) use the generic presenter. Keep their recorded args and
|
||||
// terminal result) use the generic body. Keep their recorded args and
|
||||
// full error reachable instead of collapsing the row to the first line.
|
||||
const genericError = terminal === null
|
||||
&& model.state === 'error'
|
||||
|
||||
@@ -27,7 +27,7 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
||||
title={t(toolName === 'grep'
|
||||
? SEARCH_TITLE_KEYS.grep
|
||||
: toolName === 'glob' ? SEARCH_TITLE_KEYS.glob : model.titleKey)}
|
||||
summary={search?.title ?? model.summary}
|
||||
summary={model.summary}
|
||||
body={null}
|
||||
// ToolRow ignores output when a structured card is present; otherwise it
|
||||
// preserves the generic fallback for errors and legacy results.
|
||||
|
||||
@@ -24,11 +24,11 @@ const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<T
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
|
||||
call: { name: 'ask_user_question', argsRaw },
|
||||
content: resultText === null ? [] : [{ type: 'text', text: resultText }],
|
||||
isError: false, callView: null, resultView: null, subCalls: [], ...over,
|
||||
isError: false, subCalls: [], ...over,
|
||||
})
|
||||
|
||||
const runningCall = (argsRaw: string) =>
|
||||
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [] })
|
||||
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, subCalls: [] })
|
||||
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ const todoResult = (seq: number): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
|
||||
call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [],
|
||||
content: [], isError: false, subCalls: [],
|
||||
})
|
||||
|
||||
const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
@@ -54,8 +54,6 @@ const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>)
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
|
||||
callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
|
||||
resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
|
||||
subCalls: [],
|
||||
...over,
|
||||
})
|
||||
@@ -142,8 +140,10 @@ describe('terminal card assembly', () => {
|
||||
it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => {
|
||||
const runtime = await bench([
|
||||
bashResult(3, 'c-keyed'),
|
||||
// An unregistered tool with terminal views: GenericToolCard fallback.
|
||||
bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }),
|
||||
// pwsh has no package-local keyed row, so GenericToolCard owns its raw terminal card.
|
||||
bashResult(4, 'c-fallback', {
|
||||
call: { name: 'pwsh', argsRaw: '{"command":"ls -la","description":"List files"}' },
|
||||
}),
|
||||
])
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
@@ -157,7 +157,7 @@ describe('terminal card assembly', () => {
|
||||
})
|
||||
|
||||
// Fallback row: same unified expand interaction.
|
||||
const fallback = view.container.querySelector('[data-tool="fx-bash"]')
|
||||
const fallback = view.container.querySelector('[data-tool="pwsh"]')
|
||||
expect(fallback).not.toBeNull()
|
||||
expect(fallback!.querySelector('[data-terminal]')).toBeNull()
|
||||
fireEvent.click(fallback!.querySelector('[data-expandable]')!)
|
||||
|
||||
@@ -46,12 +46,12 @@ const codeResult = (seq: number, callId: string): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name: 'run_code', argsRaw: RUN_CODE_ARGS },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [{ type: 'text', text: 'demo.txt' }], isError: false, callView: null, resultView: null,
|
||||
content: [{ type: 'text', text: 'demo.txt' }], isError: false,
|
||||
subCalls: [],
|
||||
})
|
||||
|
||||
const runningCode = (callId: string): RunningToolCall => ({
|
||||
callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, callView: null,
|
||||
callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000,
|
||||
subCalls: [],
|
||||
})
|
||||
|
||||
@@ -60,9 +60,10 @@ const subCall = (
|
||||
): ToolCallBlock => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000,
|
||||
callId: `${parent}:code:${n}`,
|
||||
parentCallId: parent,
|
||||
call: { name, argsRaw: JSON.stringify(args) },
|
||||
callTime: seq * 1_000,
|
||||
content: [{ type: 'text', text: resultText }], isError, callView: null, resultView: null,
|
||||
content: [{ type: 'text', text: resultText }], isError,
|
||||
subCalls: [],
|
||||
})
|
||||
|
||||
@@ -244,7 +245,8 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
const parent = 'call-live'
|
||||
const runningSub: ToolCallBlock = {
|
||||
callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
|
||||
turn: 0, step: 0, time: 21_000, callView: null, subCalls: [],
|
||||
parentCallId: parent,
|
||||
turn: 0, step: 0, time: 21_000, subCalls: [],
|
||||
}
|
||||
const b = await bench(snapshotWith([], [runningSub], [runningCode(parent)]))
|
||||
const view = mountApp(b.runtime)
|
||||
@@ -260,7 +262,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
kind: 'tool-result', seq: 10, time: 10_000, callId: parent,
|
||||
call: { name: 'mystery', argsRaw: '{"n":1}' },
|
||||
callTime: 9_500,
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [],
|
||||
content: [], isError: false, subCalls: [],
|
||||
}
|
||||
const b = await bench(snapshotWith([plain], []))
|
||||
const view = mountApp(b.runtime)
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('Tool presentation tails', () => {
|
||||
kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
|
||||
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
|
||||
callTime: 1_000,
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [],
|
||||
content: [], isError: false, subCalls: [],
|
||||
}
|
||||
const props: GenericToolCardProps = {
|
||||
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t,
|
||||
@@ -72,7 +72,7 @@ describe('Tool presentation tails', () => {
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
|
||||
callTime: 2_000,
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [],
|
||||
content: [], isError: false, subCalls: [],
|
||||
}
|
||||
const view = render(<BashRow {...bashProps(settled)} />)
|
||||
const row = view.container.querySelector('[data-sample="bash"]')!
|
||||
@@ -84,13 +84,13 @@ describe('Tool presentation tails', () => {
|
||||
it('BashRow carries data-state for running and StateDots for error/stopped', () => {
|
||||
const running: RunningToolCall = {
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
|
||||
turn: 1, step: 1, time: 1_000, callView: null, subCalls: [],
|
||||
turn: 1, step: 1, time: 1_000, subCalls: [],
|
||||
}
|
||||
const errorResult: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
|
||||
callTime: 500,
|
||||
content: [], isError: true, callView: null, resultView: null, subCalls: [],
|
||||
content: [], isError: true, subCalls: [],
|
||||
}
|
||||
const stoppedResult: ToolResultNode = {
|
||||
...errorResult,
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/tool/models/diff-card-model.ts'
|
||||
@@ -34,21 +33,11 @@ const chatT = makeTranslate(chatZh, commonZh)
|
||||
|
||||
const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}'
|
||||
|
||||
/** The edit tool's own call view (a call-time diff derived from the arguments). */
|
||||
const callDiff = (over?: Partial<Extract<ToolCallView, { card: 'diff' }>>): ToolCallView => ({
|
||||
card: 'diff', title: 'Edit notes/demo.txt',
|
||||
diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over,
|
||||
})
|
||||
|
||||
/** The edit tool's own result view (the applied hunk diff). */
|
||||
const resultDiff = (over?: Partial<Extract<ToolResultView, { card: 'diff' }>>): ToolResultView => ({
|
||||
card: 'diff', title: 'Edit notes/demo.txt',
|
||||
diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over,
|
||||
})
|
||||
const DIFFS = [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }]
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'edit', argsRaw: ARGS,
|
||||
turn: 1, step: 1, time: 1_000, callView: callDiff(), subCalls: [], ...over,
|
||||
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
|
||||
})
|
||||
|
||||
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
@@ -56,60 +45,124 @@ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
call: { name: 'edit', argsRaw: ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false,
|
||||
callView: callDiff(), resultView: resultDiff(), subCalls: [], ...over,
|
||||
meta: { diffs: DIFFS }, subCalls: [], ...over,
|
||||
})
|
||||
|
||||
describe('diffCardModel', () => {
|
||||
it('derives a running card from the call view alone', () => {
|
||||
it('derives a running card from raw edit arguments', () => {
|
||||
expect(diffCardModel(running())).toEqual({
|
||||
card: { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] },
|
||||
})
|
||||
})
|
||||
|
||||
it('derives a settled card from the result view, which replaces the call-time diff', () => {
|
||||
// The applied hunks (result) win over the args-derived call diff.
|
||||
it('preserves the Host presenter\'s whole-file diff for an empty old_string', () => {
|
||||
expect(diffCardModel(running({
|
||||
argsRaw: '{"file_path":"notes/demo.txt","old_string":"","new_string":"replacement"}',
|
||||
}))).toEqual({
|
||||
card: { diffs: [{ path: 'notes/demo.txt', oldText: null, newText: 'replacement' }] },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
command: 'create',
|
||||
args: { command: 'create', path: 'notes/new.txt', file_text: 'new file\n' },
|
||||
diff: { path: 'notes/new.txt', oldText: null, newText: 'new file\n' },
|
||||
},
|
||||
{
|
||||
command: 'str_replace',
|
||||
args: { command: 'str_replace', path: 'notes/demo.txt', old_str: 'old', new_str: 'new' },
|
||||
diff: { path: 'notes/demo.txt', oldText: 'old', newText: 'new' },
|
||||
},
|
||||
])('preserves the running str_replace_editor $command diff', ({ args, diff }) => {
|
||||
expect(diffCardModel(running({
|
||||
name: 'str_replace_editor',
|
||||
argsRaw: JSON.stringify(args),
|
||||
}))).toEqual({ card: { diffs: [diff] } })
|
||||
})
|
||||
|
||||
it('preserves str_replace_editor defaults and its settled Generic result', () => {
|
||||
const argsRaw = JSON.stringify({ command: 'str_replace', path: 'notes/demo.txt' })
|
||||
expect(diffCardModel(running({ name: 'str_replace_editor', argsRaw }))).toEqual({
|
||||
card: { diffs: [{ path: 'notes/demo.txt', oldText: null, newText: '' }] },
|
||||
})
|
||||
expect(diffCardModel(settled({
|
||||
resultView: resultDiff({ diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }),
|
||||
call: { name: 'str_replace_editor', argsRaw },
|
||||
meta: { diffs: [{ path: 'notes/demo.txt', oldText: 'old', newText: 'new' }] },
|
||||
}))).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps unsupported or malformed str_replace_editor calls generic', () => {
|
||||
const editor = (args: Record<string, unknown>) => running({
|
||||
name: 'str_replace_editor', argsRaw: JSON.stringify(args),
|
||||
})
|
||||
expect(diffCardModel(editor({ command: 'view', path: 'notes/demo.txt' }))).toBeNull()
|
||||
expect(diffCardModel(editor({ command: 'insert', path: 'notes/demo.txt', new_str: 'x' }))).toBeNull()
|
||||
expect(diffCardModel(editor({ command: 'create', path: '', file_text: 'x' }))).toBeNull()
|
||||
expect(diffCardModel(editor({ command: 'create', path: 'notes/demo.txt', file_text: 1 }))).toBeNull()
|
||||
expect(diffCardModel(editor({ command: 'str_replace', path: 'notes/demo.txt', old_str: 1 }))).toBeNull()
|
||||
expect(diffCardModel(editor({ command: 'str_replace', path: 'notes/demo.txt', new_str: 1 }))).toBeNull()
|
||||
})
|
||||
|
||||
it('derives a settled card from result metadata, which replaces the intended diff', () => {
|
||||
expect(diffCardModel(settled({
|
||||
meta: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] },
|
||||
}))).toEqual({
|
||||
card: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] },
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a settled diff even when the window dropped the call head', () => {
|
||||
// A truncated call carries only the result view, which holds the whole change.
|
||||
expect(diffCardModel(settled({ call: null, callView: null }))?.card.diffs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns null for every non-diff call: no views, generic views, unknown cards', () => {
|
||||
expect(diffCardModel(running({ callView: null }))).toBeNull()
|
||||
expect(diffCardModel(settled({ callView: null, resultView: null }))).toBeNull()
|
||||
expect(diffCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull()
|
||||
// A generic result settles a diff call on the generic path (write/edit's
|
||||
// own execution-error arm).
|
||||
expect(diffCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
|
||||
// A card tag this UI version does not know arrives over the wire; the
|
||||
// documented generic-card default takes it, not a crash.
|
||||
const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView
|
||||
expect(diffCardModel(running({ callView: future }))).toBeNull()
|
||||
it('uses the intended write diff when successful metadata reports no applied hunk', () => {
|
||||
const writeArgs = JSON.stringify({ file_path: 'notes/new.txt', content: 'hello fixture\n' })
|
||||
expect(diffCardModel(settled({
|
||||
callView: future, resultView: { card: 'chart' } as unknown as ToolResultView,
|
||||
}))).toBeNull()
|
||||
call: { name: 'write', argsRaw: writeArgs },
|
||||
meta: { diffs: [] },
|
||||
}))).toEqual({
|
||||
card: { diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture\n' }] },
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to null for a malformed diff payload off the wire', () => {
|
||||
// toolEventViewSchema validates only the `card` string, so a version
|
||||
// mismatch can deliver a diff card with an unusable diffs field. Each shape
|
||||
// routes to the generic path instead of throwing inside DiffBlock.
|
||||
const bad = (diffs: unknown): ToolResultView => ({ card: 'diff', diffs } as unknown as ToolResultView)
|
||||
expect(diffCardModel(settled({ resultView: bad(undefined) }))).toBeNull()
|
||||
expect(diffCardModel(settled({ resultView: bad([]) }))).toBeNull()
|
||||
expect(diffCardModel(settled({ resultView: bad('nope') }))).toBeNull()
|
||||
expect(diffCardModel(settled({ resultView: bad([null]) }))).toBeNull()
|
||||
expect(diffCardModel(settled({ resultView: bad([{ path: 1, oldText: null, newText: 'x' }]) }))).toBeNull()
|
||||
expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: 5, newText: 'x' }]) }))).toBeNull()
|
||||
expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: null, newText: 9 }]) }))).toBeNull()
|
||||
// The running side narrows identically.
|
||||
expect(diffCardModel(running({ callView: { card: 'diff', diffs: 'nope' } as unknown as ToolCallView }))).toBeNull()
|
||||
it('returns null for missing calls, errors, malformed args, unrelated tools, and child dispatches', () => {
|
||||
expect(diffCardModel(settled({ call: null }))).toBeNull()
|
||||
expect(diffCardModel(settled({ isError: true }))).toBeNull()
|
||||
expect(diffCardModel(running({ argsRaw: '{' }))).toBeNull()
|
||||
expect(diffCardModel(running({ name: 'read' }))).toBeNull()
|
||||
expect(diffCardModel(running({ parentCallId: 'parent' }))).toBeNull()
|
||||
expect(diffCardModel(settled({ parentCallId: 'parent' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps edit generic for missing or malformed applied metadata', () => {
|
||||
expect(diffCardModel(settled({ meta: undefined }))).toBeNull()
|
||||
expect(diffCardModel(settled({ meta: null }))).toBeNull()
|
||||
expect(diffCardModel(settled({ meta: { diffs: 'nope' } }))).toBeNull()
|
||||
expect(diffCardModel(settled({ meta: { diffs: [null] } }))).toBeNull()
|
||||
expect(diffCardModel(settled({ meta: { diffs: [{ path: 1, oldText: null, newText: 'x' }] } }))).toBeNull()
|
||||
expect(diffCardModel(settled({ meta: { diffs: [{ path: 'a', oldText: 5, newText: 'x' }] } }))).toBeNull()
|
||||
expect(diffCardModel(settled({ meta: { diffs: [{ path: 'a', oldText: null, newText: 9 }] } }))).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
{ diffs: 'nope' },
|
||||
{ diffs: [null] },
|
||||
])('uses the intended write diff when applied metadata is absent or malformed: %j', (meta) => {
|
||||
const writeArgs = JSON.stringify({ file_path: 'notes/new.txt', content: 'hello fixture\n' })
|
||||
expect(diffCardModel(settled({
|
||||
call: { name: 'write', argsRaw: writeArgs },
|
||||
meta,
|
||||
}))).toEqual({
|
||||
card: { diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture\n' }] },
|
||||
})
|
||||
})
|
||||
|
||||
it('validates mutation escalation fields but accepts unrelated open-root fields', () => {
|
||||
const args = (fields: Record<string, unknown>) => JSON.stringify({
|
||||
file_path: 'notes/demo.txt', old_string: 'hello', new_string: 'hello fixture', ...fields,
|
||||
})
|
||||
expect(diffCardModel(running({ argsRaw: args({ sandbox_permissions: 7, justification: 'Need access' }) }))).toBeNull()
|
||||
expect(diffCardModel(running({ argsRaw: args({ sandbox_permissions: 'workspace-write' }) }))).toBeNull()
|
||||
expect(diffCardModel(running({ argsRaw: args({ extension: { version: 1 } }) }))).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,7 +195,7 @@ describe('chat row diff body', () => {
|
||||
callId: 'c1', toolName: 'some_tool', openFile: vi.fn(), t,
|
||||
block: settled({
|
||||
call: { name: 'some_tool', argsRaw: '{"foo":"bar"}' },
|
||||
callView: null, resultView: null,
|
||||
meta: undefined,
|
||||
}),
|
||||
}} />)
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
@@ -197,8 +250,7 @@ describe('FileMutationRow diff card', () => {
|
||||
const writeArgs = '{"file_path":"notes/new.txt","content":"hello fixture\\n"}'
|
||||
const view = render(<FileMutationRow {...rowProps(settled({
|
||||
call: { name: 'write', argsRaw: writeArgs },
|
||||
callView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
|
||||
resultView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
|
||||
meta: { diffs: [] },
|
||||
}), 'write')} />)
|
||||
// The footer counts live inside the collapsed diff card.
|
||||
toggleRow(view)
|
||||
@@ -209,12 +261,12 @@ describe('FileMutationRow diff card', () => {
|
||||
const runningView = render(<FileMutationRow {...rowProps(running())} />)
|
||||
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
cleanup()
|
||||
const errorView = render(<FileMutationRow {...rowProps(settled({ isError: true, resultView: null, callView: null }))} />)
|
||||
const errorView = render(<FileMutationRow {...rowProps(settled({ isError: true }))} />)
|
||||
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a mutation call with no diff view renders the summary row alone', () => {
|
||||
const view = render(<FileMutationRow {...rowProps(settled({ callView: null, resultView: null }))} />)
|
||||
it('a mutation result with no metadata renders the summary row alone', () => {
|
||||
const view = render(<FileMutationRow {...rowProps(settled({ meta: undefined }))} />)
|
||||
// No diff material: expanding shows the args-JSON body, never a diff card.
|
||||
expect(view.container.querySelector('[data-diff]')).toBeNull()
|
||||
toggleRow(view)
|
||||
@@ -222,11 +274,9 @@ describe('FileMutationRow diff card', () => {
|
||||
})
|
||||
|
||||
it('surfaces the result text when an errored mutation has no diff card', () => {
|
||||
// write/edit return undefined from presentResult on isError, so the failure
|
||||
// has no diff — ToolRow shows the model-facing error text as the collapsed
|
||||
// summary's first line (errorSummary) instead of a bare red dot.
|
||||
// Failed mutations have no diff; ToolRow keeps the model-facing error text.
|
||||
const view = render(<FileMutationRow {...rowProps(settled({
|
||||
isError: true, callView: null, resultView: null,
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'old_string not found in notes/demo.txt' }],
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-diff]')).toBeNull()
|
||||
@@ -235,7 +285,7 @@ describe('FileMutationRow diff card', () => {
|
||||
|
||||
it('falls back to the error name/code when an errored result has no text block', () => {
|
||||
const view = render(<FileMutationRow {...rowProps(settled({
|
||||
isError: true, callView: null, resultView: null, content: [],
|
||||
isError: true, content: [],
|
||||
error: { name: 'ToolError', code: 'sandbox_denied' },
|
||||
}))} />)
|
||||
expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy()
|
||||
@@ -252,7 +302,7 @@ describe('FileMutationRow diff card', () => {
|
||||
|
||||
it('shows the stopped state when the call was interrupted', () => {
|
||||
const view = render(<FileMutationRow {...rowProps(settled({
|
||||
callView: null, resultView: null, isError: true,
|
||||
isError: true,
|
||||
error: { name: 'ToolError', code: 'interrupted' },
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
@@ -264,7 +314,7 @@ describe('FileMutationRow diff card', () => {
|
||||
it('renders a plain summary span when the call carries no file path', () => {
|
||||
// Empty args leave deriveFilePath undefined, so the summary is not a link.
|
||||
const view = render(<FileMutationRow {...rowProps(settled({
|
||||
call: { name: 'edit', argsRaw: '' }, callView: null, resultView: null,
|
||||
call: { name: 'edit', argsRaw: '' },
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[class*="_fileLink_"]')).toBeNull()
|
||||
expect(view.container.querySelector('[class*="_summary_"]')).not.toBeNull()
|
||||
@@ -378,7 +428,7 @@ describe('DetailsPanel diff Output section', () => {
|
||||
it('a non-diff result keeps the flattened pre', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null,
|
||||
meta: undefined,
|
||||
content: [{ type: 'text', text: 'permission denied' }],
|
||||
})],
|
||||
}), target)
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/tool/models/read-card-model.ts'
|
||||
import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
|
||||
@@ -35,7 +34,6 @@ const chatT = makeTranslate(chatZh, commonZh)
|
||||
// use it so the row exercises a production-shaped call. `web_fetch` (below) has
|
||||
// its own schema whose key is not `file_path`, so it keeps a `url`-less `path`.
|
||||
const ARGS = '{"file_path":"src/a.ts","offset":41}'
|
||||
const WEB_FETCH_ARGS = '{"path":"src/a.ts","offset":41}'
|
||||
|
||||
/** The read block's rendered content cells, one string per row (highlighting
|
||||
* breaks a line across token spans, so match on the row's textContent). */
|
||||
@@ -50,26 +48,35 @@ const sampleLines = [
|
||||
{ number: 43, text: 'export const c = 3' },
|
||||
]
|
||||
|
||||
/** The read tool's own result view for a settled file read. */
|
||||
const resultRead = (over?: Partial<Extract<ToolResultView, { card: 'read' }>>): ToolResultView => ({
|
||||
card: 'read', path: 'src/a.ts', offset: 41, lines: sampleLines, totalLines: 180, lang: 'ts', ...over,
|
||||
interface ReadMetaFixture {
|
||||
path: string
|
||||
offset: number
|
||||
lines: { number: number; text: string }[]
|
||||
totalLines: number
|
||||
lang?: string
|
||||
}
|
||||
|
||||
const readMeta = (over?: Partial<ReadMetaFixture>): ReadMetaFixture => ({
|
||||
path: 'src/a.ts', offset: 41, lines: sampleLines, totalLines: 180, lang: 'ts', ...over,
|
||||
})
|
||||
|
||||
const readContent = (body = 'export const a = 1'): string => `<path>src/a.ts</path>\n<type>file</type>\n<content>\n${body}\n</content>`
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'read', argsRaw: ARGS,
|
||||
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, subCalls: [], ...over,
|
||||
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
|
||||
})
|
||||
|
||||
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
|
||||
call: { name: 'read', argsRaw: ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: '41: export const a = 1' }], isError: false,
|
||||
callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), subCalls: [], ...over,
|
||||
content: [{ type: 'text', text: readContent() }], isError: false,
|
||||
meta: readMeta(), subCalls: [], ...over,
|
||||
})
|
||||
|
||||
describe('readCardModel', () => {
|
||||
it('derives the card from a settled read result view', () => {
|
||||
it('derives the card from settled read metadata and its raw envelope', () => {
|
||||
expect(readCardModel(settled())).toEqual({
|
||||
label: 'src/a.ts', lines: sampleLines, totalLines: 180, lang: 'ts',
|
||||
})
|
||||
@@ -82,38 +89,31 @@ describe('readCardModel', () => {
|
||||
expect(model?.lines[0]).not.toBe(sampleLines[0])
|
||||
})
|
||||
|
||||
it('takes the result view\'s replacement title over the relativized path', () => {
|
||||
// The presentation contract defines a result title as REPLACING the pending
|
||||
// one, so a tool that supplies a label wins over the path here.
|
||||
expect(readCardModel(settled({ resultView: resultRead({ title: 'Read (head) src/a.ts' }) }))?.label)
|
||||
.toBe('Read (head) src/a.ts')
|
||||
})
|
||||
|
||||
it('relativizes a workspace-rooted path label, and leaves others as authored', () => {
|
||||
// A workspace-rooted absolute path shows its short form.
|
||||
expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }), '/w/app')?.label)
|
||||
expect(readCardModel(settled({ meta: readMeta({ path: '/w/app/src/a.ts' }) }), '/w/app')?.label)
|
||||
.toBe('src/a.ts')
|
||||
// A path outside the workspace stays as authored.
|
||||
expect(readCardModel(settled({ resultView: resultRead({ path: '/srv/other.ts' }) }), '/w/app')?.label)
|
||||
expect(readCardModel(settled({ meta: readMeta({ path: '/srv/other.ts' }) }), '/w/app')?.label)
|
||||
.toBe('/srv/other.ts')
|
||||
// With no session cwd there is nothing to relativize against.
|
||||
expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }))?.label)
|
||||
expect(readCardModel(settled({ meta: readMeta({ path: '/w/app/src/a.ts' }) }))?.label)
|
||||
.toBe('/w/app/src/a.ts')
|
||||
})
|
||||
|
||||
it('abbreviates a leftover POSIX home path label', () => {
|
||||
expect(readCardModel(settled({ resultView: resultRead({ path: '/Users/u/notes.md' }) }), '/tmp/ws', '/Users/u')?.label)
|
||||
expect(readCardModel(settled({ meta: readMeta({ path: '/Users/u/notes.md' }) }), '/tmp/ws', '/Users/u')?.label)
|
||||
.toBe('~/notes.md')
|
||||
expect(readCardModel(settled({ resultView: resultRead({ path: '/Users/u/app/src/a.ts' }) }), '/Users/u/app', '/Users/u')?.label)
|
||||
expect(readCardModel(settled({ meta: readMeta({ path: '/Users/u/app/src/a.ts' }) }), '/Users/u/app', '/Users/u')?.label)
|
||||
.toBe('src/a.ts')
|
||||
expect(readCardModel(settled({ resultView: resultRead({ path: 'C:\\Users\\u\\a.ts' }) }), '/tmp/ws', '/Users/u')?.label)
|
||||
expect(readCardModel(settled({ meta: readMeta({ path: 'C:\\Users\\u\\a.ts' }) }), '/tmp/ws', '/Users/u')?.label)
|
||||
.toBe('C:\\Users\\u\\a.ts')
|
||||
})
|
||||
|
||||
it('carries an omitted language through as undefined', () => {
|
||||
const noLang = resultRead()
|
||||
const noLang = readMeta()
|
||||
delete (noLang as { lang?: string }).lang
|
||||
expect(readCardModel(settled({ resultView: noLang }))?.lang).toBeUndefined()
|
||||
expect(readCardModel(settled({ meta: noLang }))?.lang).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns null for a running read: the read intent is result-side only', () => {
|
||||
@@ -122,19 +122,36 @@ describe('readCardModel', () => {
|
||||
expect(readCardModel(running())).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for every non-read settled call: no view, generic view, unknown card', () => {
|
||||
expect(readCardModel(settled({ resultView: null }))).toBeNull()
|
||||
expect(readCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
|
||||
// A card tag this UI version does not know arrives over the wire; the
|
||||
// documented generic-card default takes it, not a crash.
|
||||
const future = { card: 'chart' } as unknown as ToolResultView
|
||||
expect(readCardModel(settled({ resultView: future }))).toBeNull()
|
||||
it('returns null for missing calls, errors, malformed metadata/envelopes, unrelated tools, and children', () => {
|
||||
expect(readCardModel(settled({ call: null }))).toBeNull()
|
||||
expect(readCardModel(settled({ isError: true }))).toBeNull()
|
||||
expect(readCardModel(settled({ meta: undefined }))).toBeNull()
|
||||
expect(readCardModel(settled({ meta: { ...readMeta(), lines: [{ number: 0, text: 'bad' }] } }))).toBeNull()
|
||||
expect(readCardModel(settled({ content: [{ type: 'text', text: 'plain result' }] }))).toBeNull()
|
||||
expect(readCardModel(settled({ call: { name: 'echo', argsRaw: '{}' } }))).toBeNull()
|
||||
expect(readCardModel(settled({ parentCallId: 'parent' }))).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing file_path', '{}'],
|
||||
['non-string file_path', '{"file_path":7}'],
|
||||
['blank file_path', '{"file_path":" "}'],
|
||||
['non-number offset', '{"file_path":"src/a.ts","offset":"41"}'],
|
||||
['non-positive offset', '{"file_path":"src/a.ts","offset":0}'],
|
||||
['fractional limit', '{"file_path":"src/a.ts","limit":1.5}'],
|
||||
])('keeps malformed recognized read args generic: %s', (_label, argsRaw) => {
|
||||
expect(readCardModel(settled({ call: { name: 'read', argsRaw } }))).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts unknown fields because first-party parameter roots are open', () => {
|
||||
const argsRaw = JSON.stringify({ file_path: 'src/a.ts', offset: 41, extension: { version: 1 } })
|
||||
expect(readCardModel(settled({ call: { name: 'read', argsRaw } }))).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GenericToolCard read body', () => {
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
|
||||
callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), t,
|
||||
callId: 'c1', toolName: 'read', block, openFile: vi.fn(), t,
|
||||
})
|
||||
|
||||
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
|
||||
@@ -144,9 +161,7 @@ describe('GenericToolCard read body', () => {
|
||||
|
||||
it('expands to the read card, capped tighter than the panel', () => {
|
||||
expect(CHAT_READ_MAX_LINES).toBeLessThan(16)
|
||||
// web_fetch lands on the read variant without its own keyed row, so the
|
||||
// fallback card owns the read block once expanded.
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({ call: { name: 'web_fetch', argsRaw: WEB_FETCH_ARGS } }))} />)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled())} />)
|
||||
// Collapsed: no read card in the DOM yet.
|
||||
expect(view.container.querySelector('[data-read]')).toBeNull()
|
||||
toggleRow(view)
|
||||
@@ -159,15 +174,15 @@ describe('GenericToolCard read body', () => {
|
||||
it('a non-read tool renders the bare row with no read card', () => {
|
||||
const view = render(<GenericToolCard {...({
|
||||
callId: 'c1', toolName: 'echo', block: settled({
|
||||
call: { name: 'echo', argsRaw: '{"text":"x"}' }, callView: null, resultView: null,
|
||||
call: { name: 'echo', argsRaw: '{"text":"x"}' }, meta: undefined,
|
||||
}), openFile: vi.fn(), t,
|
||||
})} />)
|
||||
toggleRow(view)
|
||||
expect(view.container.querySelector('[data-read]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a running read renders the summary row alone (no result view yet)', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(running({ name: 'web_fetch' }))} />)
|
||||
it('a running read renders the summary row alone (no result metadata yet)', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(running())} />)
|
||||
expect(view.container.querySelector('[data-read]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -229,7 +244,7 @@ describe('ReadRow keyed toolview', () => {
|
||||
|
||||
it('an error read result shows the error state and no read card', () => {
|
||||
const view = render(<ReadRow {...rowProps(settled({
|
||||
resultView: { card: 'generic' }, isError: true,
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'ENOENT' }],
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('error')
|
||||
@@ -238,7 +253,7 @@ describe('ReadRow keyed toolview', () => {
|
||||
|
||||
it('an interrupted read shows the stopped state', () => {
|
||||
const view = render(<ReadRow {...rowProps(settled({
|
||||
resultView: null, isError: true, error: { name: 'ToolError', code: 'interrupted' },
|
||||
isError: true, error: { name: 'ToolError', code: 'interrupted' },
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('stopped')
|
||||
})
|
||||
@@ -323,7 +338,7 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
it('renders the read card at full height, keeping the JSON Input section', () => {
|
||||
const long = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, text: `row-${i}` }))
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ resultView: resultRead({ lines: long, totalLines: 20 }) })],
|
||||
nodes: [settled({ meta: readMeta({ offset: 1, lines: long, totalLines: 20 }) })],
|
||||
}), target)
|
||||
expect(view.getByText(/"file_path"/)).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-read]')).not.toBeNull()
|
||||
@@ -335,7 +350,7 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
it('a non-read result keeps the flattened pre form', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null,
|
||||
meta: undefined,
|
||||
content: [{ type: 'text', text: 'plain result' }],
|
||||
})],
|
||||
}), target)
|
||||
@@ -345,14 +360,14 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
|
||||
it('abbreviates a leftover POSIX home path on the read card label', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ resultView: resultRead({ path: '/Users/u/notes.md' }) })],
|
||||
nodes: [settled({ meta: readMeta({ path: '/Users/u/notes.md' }) })],
|
||||
}), target, '/tmp/ws', {
|
||||
version: '0', cwd: '/tmp', attachedSessions: 0, home: '/Users/u', canOpenPath: false,
|
||||
})
|
||||
expect(view.getByText('~/notes.md')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running read keeps the 运行中… placeholder (no result view)', () => {
|
||||
it('a running read keeps the 运行中… placeholder (no result metadata)', () => {
|
||||
const view = mount(snapshot({ runningCalls: [running()] }), target)
|
||||
expect(view.getByText('运行中…')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-read]')).toBeNull()
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/tool/models/search-card-model.ts'
|
||||
@@ -45,9 +44,23 @@ const SID = 's1' as SessionId
|
||||
const GREP_ARGS = '{"pattern":"foo","path":"src"}'
|
||||
const GLOB_ARGS = '{"pattern":"**/*.ts","path":"src"}'
|
||||
|
||||
/** A grep result view: matches grouped by file. */
|
||||
const resultMatches = (over?: Partial<Extract<ToolResultView, { card: 'search'; shape: 'matches' }>>): ToolResultView => ({
|
||||
card: 'search', shape: 'matches',
|
||||
interface MatchesMeta {
|
||||
shape: 'matches'
|
||||
files: { path: string; matches: { lineNumber: number; line: string }[] }[]
|
||||
truncated: boolean
|
||||
total: number
|
||||
}
|
||||
|
||||
interface PathsMeta {
|
||||
shape: 'paths'
|
||||
paths: string[]
|
||||
truncated: boolean
|
||||
total: number
|
||||
}
|
||||
|
||||
/** Persisted grep metadata: matches grouped by file. */
|
||||
const matchesMeta = (over?: Partial<MatchesMeta>): MatchesMeta => ({
|
||||
shape: 'matches',
|
||||
files: [
|
||||
{ path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] },
|
||||
{ path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] },
|
||||
@@ -55,14 +68,14 @@ const resultMatches = (over?: Partial<Extract<ToolResultView, { card: 'search';
|
||||
truncated: false, total: 3, ...over,
|
||||
})
|
||||
|
||||
/** A glob result view: a flat path list. */
|
||||
const resultPaths = (over?: Partial<Extract<ToolResultView, { card: 'search'; shape: 'paths' }>>): ToolResultView => ({
|
||||
card: 'search', shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over,
|
||||
/** Persisted glob metadata: a flat path list. */
|
||||
const pathsMeta = (over?: Partial<PathsMeta>): PathsMeta => ({
|
||||
shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over,
|
||||
})
|
||||
|
||||
const runningGrep = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'grep', argsRaw: GREP_ARGS,
|
||||
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, subCalls: [], ...over,
|
||||
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
|
||||
})
|
||||
|
||||
const settledGrep = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
@@ -70,7 +83,7 @@ const settledGrep = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
call: { name: 'grep', argsRaw: GREP_ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'a.ts\n Line 12: const foo = 1' }], isError: false,
|
||||
callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), subCalls: [], ...over,
|
||||
meta: matchesMeta(), subCalls: [], ...over,
|
||||
})
|
||||
|
||||
const settledGlob = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
@@ -78,13 +91,12 @@ const settledGlob = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
call: { name: 'glob', argsRaw: GLOB_ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'src/a.ts\nsrc/b.ts' }], isError: false,
|
||||
callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), subCalls: [], ...over,
|
||||
meta: pathsMeta(), subCalls: [], ...over,
|
||||
})
|
||||
|
||||
describe('searchCardModel', () => {
|
||||
it('derives a matches card from the grep result view', () => {
|
||||
it('derives a matches card from grep result metadata', () => {
|
||||
expect(searchCardModel(settledGrep())).toEqual({
|
||||
title: undefined,
|
||||
recovery: undefined,
|
||||
card: {
|
||||
kind: 'matches',
|
||||
@@ -97,84 +109,78 @@ describe('searchCardModel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('derives a paths card from the glob result view, carrying the truncation signal', () => {
|
||||
it('derives a paths card from glob result metadata, carrying the truncation signal', () => {
|
||||
// Empty block content isolates the truncation signal from the recovery arm.
|
||||
expect(searchCardModel(settledGlob({ content: [], resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({
|
||||
title: undefined,
|
||||
expect(searchCardModel(settledGlob({ content: [], meta: pathsMeta({ truncated: true, total: 20 }) }))).toEqual({
|
||||
recovery: undefined,
|
||||
card: { kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: true, total: 20 },
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the result view\'s replacement title when the presenter sets one', () => {
|
||||
expect(searchCardModel(settledGrep({ resultView: resultMatches({ title: '3 matches' }) }))?.title).toBe('3 matches')
|
||||
// Without one it is absent, so the row keeps its args-derived summary.
|
||||
expect(searchCardModel(settledGrep())?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns null for every non-search call: running, no views, generic, terminal, unknown cards', () => {
|
||||
// A search card is result-time only: a running call has no result view yet.
|
||||
it('returns null for running, missing calls, errors, malformed args, unrelated tools, and children', () => {
|
||||
expect(searchCardModel(runningGrep())).toBeNull()
|
||||
expect(searchCardModel(settledGrep({ callView: null, resultView: null }))).toBeNull()
|
||||
// A generic result settles a search call as a generic card (grep/glob failure
|
||||
// or a nested run_code dispatch), which keeps the generic path.
|
||||
expect(searchCardModel(settledGrep({ resultView: { card: 'generic' } }))).toBeNull()
|
||||
// A terminal result view is a different card entirely.
|
||||
expect(searchCardModel(settledGrep({ resultView: { card: 'terminal', output: 'x' } }))).toBeNull()
|
||||
// A card tag this UI version does not know arrives over the wire; the
|
||||
// documented generic-card default takes it, not a crash.
|
||||
const future = { card: 'chart' } as unknown as ToolResultView
|
||||
expect(searchCardModel(settledGrep({ resultView: future }))).toBeNull()
|
||||
expect(searchCardModel(settledGrep({ call: null }))).toBeNull()
|
||||
expect(searchCardModel(settledGrep({ isError: true }))).toBeNull()
|
||||
expect(searchCardModel(settledGrep({ call: { name: 'grep', argsRaw: '{' } }))).toBeNull()
|
||||
expect(searchCardModel(settledGrep({ call: { name: 'echo', argsRaw: '{}' } }))).toBeNull()
|
||||
expect(searchCardModel(settledGrep({ parentCallId: 'parent' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a card:search view whose shape this version does not compile', () => {
|
||||
// `shape` rides the same untrusted wire frame as `card`; a subtype this client
|
||||
// does not know must fall to the generic path, never render as a paths card
|
||||
// that would crash SearchBlock on an absent `paths`.
|
||||
const futureShape = {
|
||||
card: 'search', shape: 'future', truncated: false, total: 0,
|
||||
} as unknown as ToolResultView
|
||||
expect(searchCardModel(settledGrep({ resultView: futureShape }))).toBeNull()
|
||||
it('returns null for metadata whose shape does not match the tool', () => {
|
||||
expect(searchCardModel(settledGrep({ meta: { shape: 'future', truncated: false, total: 0 } }))).toBeNull()
|
||||
expect(searchCardModel(settledGrep({ meta: pathsMeta() }))).toBeNull()
|
||||
expect(searchCardModel(settledGlob({ meta: matchesMeta() }))).toBeNull()
|
||||
})
|
||||
|
||||
it('validates declared search argument fields and accepts open-root extensions', () => {
|
||||
expect(searchCardModel(settledGrep({
|
||||
call: { name: 'grep', argsRaw: '{"pattern":"foo","include":7}' },
|
||||
}))).toBeNull()
|
||||
expect(searchCardModel(settledGrep({
|
||||
call: { name: 'grep', argsRaw: '{"pattern":"foo","include":"!*.ts"}' },
|
||||
}))).toBeNull()
|
||||
expect(searchCardModel(settledGlob({
|
||||
call: { name: 'glob', argsRaw: '{"pattern":"**/*.ts","path":7}' },
|
||||
}))).toBeNull()
|
||||
expect(searchCardModel(settledGrep({
|
||||
call: { name: 'grep', argsRaw: '{"pattern":"foo","extension":1}' },
|
||||
}))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a known shape whose structured shape is missing or malformed', () => {
|
||||
// The host wire schema checks the `card`/`shape` strings but not the grouped
|
||||
// shape, so a version mismatch could deliver shape:'matches' with no `files`
|
||||
// (or shape:'paths' with no `paths`). Rendering that crashes SearchBlock at
|
||||
// `.reduce`/`.map`; the derivation drops to the generic path instead.
|
||||
const noFiles = { card: 'search', shape: 'matches', truncated: false, total: 0 } as unknown as ToolResultView
|
||||
expect(searchCardModel(settledGrep({ resultView: noFiles }))).toBeNull()
|
||||
const noFiles = { shape: 'matches', truncated: false, total: 0 }
|
||||
expect(searchCardModel(settledGrep({ meta: noFiles }))).toBeNull()
|
||||
const badFile = {
|
||||
card: 'search', shape: 'matches', truncated: false, total: 1,
|
||||
shape: 'matches', truncated: false, total: 1,
|
||||
files: [{ path: 'a.ts', matches: [{ lineNumber: 'x', line: 1 }] }],
|
||||
} as unknown as ToolResultView
|
||||
expect(searchCardModel(settledGrep({ resultView: badFile }))).toBeNull()
|
||||
const noPaths = { card: 'search', shape: 'paths', truncated: false, total: 0 } as unknown as ToolResultView
|
||||
expect(searchCardModel(settledGlob({ resultView: noPaths }))).toBeNull()
|
||||
}
|
||||
expect(searchCardModel(settledGrep({ meta: badFile }))).toBeNull()
|
||||
const noPaths = { shape: 'paths', truncated: false, total: 0 }
|
||||
expect(searchCardModel(settledGlob({ meta: noPaths }))).toBeNull()
|
||||
const badPaths = {
|
||||
card: 'search', shape: 'paths', truncated: false, total: 1, paths: [42],
|
||||
} as unknown as ToolResultView
|
||||
expect(searchCardModel(settledGlob({ resultView: badPaths }))).toBeNull()
|
||||
shape: 'paths', truncated: false, total: 1, paths: [42],
|
||||
}
|
||||
expect(searchCardModel(settledGlob({ meta: badPaths }))).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces the recovery text only when the result was capped', () => {
|
||||
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
|
||||
// The recovery locator lives in the raw tool/result content (the view carries
|
||||
// no text), surfaced only when the card capped the result.
|
||||
// The recovery locator lives in raw tool/result content and is surfaced only
|
||||
// when metadata says the card was capped.
|
||||
const capped = searchCardModel(settledGrep({
|
||||
content: [{ type: 'text', text: recovery }],
|
||||
resultView: resultMatches({ truncated: true, total: 42 }),
|
||||
meta: matchesMeta({ truncated: true, total: 42 }),
|
||||
}))
|
||||
expect(capped?.recovery).toBe(recovery)
|
||||
// Not capped: the card holds every match, so the raw content adds nothing and
|
||||
// is dropped.
|
||||
const whole = searchCardModel(settledGrep({
|
||||
content: [{ type: 'text', text: recovery }],
|
||||
resultView: resultMatches({ truncated: false }),
|
||||
meta: matchesMeta({ truncated: false }),
|
||||
}))
|
||||
expect(whole?.recovery).toBeUndefined()
|
||||
// Capped but the block carries no text: nothing to surface.
|
||||
const noText = searchCardModel(settledGrep({ content: [], resultView: resultMatches({ truncated: true, total: 42 }) }))
|
||||
const noText = searchCardModel(settledGrep({ content: [], meta: matchesMeta({ truncated: true, total: 42 }) }))
|
||||
expect(noText?.recovery).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -210,7 +216,7 @@ describe('chat row search body (GenericToolCard fallback)', () => {
|
||||
|
||||
it('a non-search result keeps the args-JSON text body', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settledGrep({
|
||||
resultView: { card: 'generic' },
|
||||
meta: undefined,
|
||||
}), 'grep')} />)
|
||||
toggleRow(view)
|
||||
expect(view.getByText(/"pattern"/)).toBeTruthy()
|
||||
@@ -221,7 +227,7 @@ describe('chat row search body (GenericToolCard fallback)', () => {
|
||||
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
|
||||
const view = render(<GenericToolCard {...ownerProps(settledGrep({
|
||||
content: [{ type: 'text', text: recovery }],
|
||||
resultView: resultMatches({ truncated: true, total: 42 }),
|
||||
meta: matchesMeta({ truncated: true, total: 42 }),
|
||||
}), 'grep')} />)
|
||||
toggleRow(view)
|
||||
expect(searchKindOf(view.container)).toBe('matches')
|
||||
@@ -266,20 +272,20 @@ describe('SearchRow keyed card', () => {
|
||||
it('agrees with the summary row about the run state', () => {
|
||||
const runningView = render(<SearchRow {...rowProps(runningGrep(), 'grep')} />)
|
||||
expect(runningView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('running')
|
||||
// No result view yet, so no card even once material could expand.
|
||||
// No result metadata yet, so no card even once material could expand.
|
||||
expect(searchKindOf(runningView.container)).toBeNull()
|
||||
cleanup()
|
||||
const errorView = render(<SearchRow {...rowProps(settledGrep({
|
||||
isError: true, resultView: { card: 'generic' },
|
||||
isError: true,
|
||||
}), 'grep')} />)
|
||||
expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error')
|
||||
})
|
||||
|
||||
it('surfaces the result text through the Output section when an errored search has no card', () => {
|
||||
// grep/glob return no presentResult on error → no card; the row shows the
|
||||
// first error line as the collapsed summary and the full text once expanded.
|
||||
// Failed search metadata cannot select a success card; the row keeps the
|
||||
// first error line collapsed and the full text once expanded.
|
||||
const view = render(<SearchRow {...rowProps(settledGrep({
|
||||
isError: true, resultView: null,
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'grep: invalid regular expression' }],
|
||||
}), 'grep')} />)
|
||||
expect(searchKindOf(view.container)).toBeNull()
|
||||
@@ -291,12 +297,9 @@ describe('SearchRow keyed card', () => {
|
||||
})
|
||||
|
||||
it('surfaces the result text for a settled non-error call with no card once expanded', () => {
|
||||
// A successful nested run_code sub-dispatch (backend computes no
|
||||
// presentationMeta, so resultView is null) or a legacy generic result settles
|
||||
// with search === null and state ok. The keyed SearchRow owns the slot, so
|
||||
// ToolRow's Output section carries the text; it is only visible expanded.
|
||||
// Missing metadata keeps a successful result on ToolRow's raw Output path.
|
||||
const view = render(<SearchRow {...rowProps(settledGrep({
|
||||
isError: false, resultView: null,
|
||||
isError: false, meta: undefined,
|
||||
content: [{ type: 'text', text: 'nested run_code output line' }],
|
||||
}), 'grep')} />)
|
||||
expect(view.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('ok')
|
||||
@@ -311,7 +314,7 @@ describe('SearchRow keyed card', () => {
|
||||
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
|
||||
const view = render(<SearchRow {...rowProps(settledGrep({
|
||||
content: [{ type: 'text', text: recovery }],
|
||||
resultView: resultMatches({ truncated: true, total: 42 }),
|
||||
meta: matchesMeta({ truncated: true, total: 42 }),
|
||||
}), 'grep')} />)
|
||||
toggleRow(view)
|
||||
expect(searchKindOf(view.container)).toBe('matches')
|
||||
@@ -327,21 +330,14 @@ describe('SearchRow keyed card', () => {
|
||||
|
||||
it('falls back to the error name/code when an errored result has no text block', () => {
|
||||
const view = render(<SearchRow {...rowProps(settledGrep({
|
||||
isError: true, resultView: null, content: [],
|
||||
isError: true, content: [],
|
||||
error: { name: 'ToolError', code: 'timeout' },
|
||||
}), 'grep')} />)
|
||||
// Error state: the derived name/code line is the collapsed summary.
|
||||
expect(view.getByText('ToolError: timeout')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the result view\'s replacement title instead of the args summary', () => {
|
||||
const view = render(<SearchRow {...rowProps(settledGrep({
|
||||
resultView: resultMatches({ title: '3 matches in 2 files' }),
|
||||
}), 'grep')} />)
|
||||
expect(view.getByText('3 matches in 2 files')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the args-derived summary when the result view has no title', () => {
|
||||
it('keeps the args-derived summary beside the metadata-derived card', () => {
|
||||
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
|
||||
expect(view.getByText('foo')).toBeTruthy()
|
||||
})
|
||||
@@ -441,7 +437,7 @@ describe('DetailsPanel Output section (search)', () => {
|
||||
it('renders the recovery footer below the card for a capped search', () => {
|
||||
const recovery = 'src/a.ts\nsrc/b.ts\n\n(Showing 2 of 23 paths. Full sorted result stored at: spill://glob-7.)'
|
||||
const view = mount(snapshot({
|
||||
nodes: [settledGlob({ content: [{ type: 'text', text: recovery }], resultView: resultPaths({ truncated: true, total: 23 }) })],
|
||||
nodes: [settledGlob({ content: [{ type: 'text', text: recovery }], meta: pathsMeta({ truncated: true, total: 23 }) })],
|
||||
}), globTarget)
|
||||
expect(searchKindOf(view.container)).toBe('paths')
|
||||
expect(view.getByText(/Full sorted result stored at: spill:\/\/glob-7/)).toBeTruthy()
|
||||
@@ -449,7 +445,7 @@ describe('DetailsPanel Output section (search)', () => {
|
||||
|
||||
it('a non-search result keeps the flattened pre form', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settledGrep({ callView: null, resultView: null })],
|
||||
nodes: [settledGrep({ meta: undefined })],
|
||||
}), grepTarget)
|
||||
expect(searchKindOf(view.container)).toBeNull()
|
||||
const output = view.getByText('输出').closest('section')
|
||||
|
||||
@@ -11,21 +11,24 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { terminalCardModel, terminalFailed } from '../src/client/tool/models/terminal-card-model.ts'
|
||||
import {
|
||||
localizeTerminalCardModel, terminalCardModel, terminalFailed,
|
||||
} from '../src/client/tool/models/terminal-card-model.ts'
|
||||
import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx'
|
||||
import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx'
|
||||
import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx'
|
||||
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts'
|
||||
|
||||
type BashRowProps = Parameters<typeof BashRow>[0]
|
||||
|
||||
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
|
||||
const enT: GenericToolCardProps['t'] = makeTranslate(en, commonEn)
|
||||
const chatT = makeTranslate(chatZh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -46,19 +49,13 @@ const SID = 's1' as SessionId
|
||||
|
||||
const ARGS = '{"command":"ls -la","description":"List files"}'
|
||||
|
||||
/** The bash tool's own call view for a foreground command. */
|
||||
const callTerminal = (over?: Partial<Extract<ToolCallView, { card: 'terminal' }>>): ToolCallView => ({
|
||||
card: 'terminal', title: 'ls -la', description: 'List files', ...over,
|
||||
})
|
||||
|
||||
/** The bash tool's own result view for a settled foreground command. */
|
||||
const resultTerminal = (over?: Partial<Extract<ToolResultView, { card: 'terminal' }>>): ToolResultView => ({
|
||||
card: 'terminal', output: 'a.ts b.ts\nc.ts d.ts\n', exitCode: 0, ...over,
|
||||
const shellArgs = (over: Record<string, unknown> = {}): string => JSON.stringify({
|
||||
command: 'ls -la', description: 'List files', ...over,
|
||||
})
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'bash', argsRaw: ARGS,
|
||||
turn: 1, step: 1, time: 1_000, callView: callTerminal(), subCalls: [], ...over,
|
||||
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
|
||||
})
|
||||
|
||||
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
@@ -66,75 +63,73 @@ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
call: { name: 'bash', argsRaw: ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false,
|
||||
callView: callTerminal(), resultView: resultTerminal(), subCalls: [], ...over,
|
||||
subCalls: [], ...over,
|
||||
})
|
||||
|
||||
describe('terminalCardModel', () => {
|
||||
it('derives a running card from the call view alone', () => {
|
||||
expect(terminalCardModel(running({ callView: callTerminal({ cwd: '/projects/app' }) }))).toEqual({
|
||||
description: 'List files',
|
||||
it('derives a running standard-shell card from raw arguments', () => {
|
||||
expect(terminalCardModel(running({ argsRaw: shellArgs({ workdir: '/projects/app' }) }))).toEqual({
|
||||
copy: { kind: 'shell', command: 'ls -la', description: 'List files' },
|
||||
card: {
|
||||
command: 'ls -la', cwd: '/projects/app', output: undefined,
|
||||
cwd: '/projects/app', output: undefined,
|
||||
exitCode: undefined, signal: undefined, running: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('derives a settled card from both sides, carrying the exit status', () => {
|
||||
it('derives a settled standard-shell card and removes its final exit marker', () => {
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '/projects/app' }),
|
||||
resultView: resultTerminal({ output: 'boom\n', exitCode: 2 }),
|
||||
call: { name: 'bash', argsRaw: shellArgs({ workdir: '/projects/app' }) },
|
||||
content: [{ type: 'text', text: 'boom\n[exit code: 2]' }],
|
||||
}))).toEqual({
|
||||
description: 'List files',
|
||||
copy: { kind: 'shell', command: 'ls -la', description: 'List files' },
|
||||
card: {
|
||||
command: 'ls -la', cwd: '/projects/app', output: 'boom\n',
|
||||
cwd: '/projects/app', output: 'boom',
|
||||
exitCode: 2, signal: undefined, running: false,
|
||||
},
|
||||
})
|
||||
expect(terminalCardModel(settled({
|
||||
resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
|
||||
}))?.card.signal).toBe('SIGTERM')
|
||||
content: [{ type: 'text', text: 'gone\n[killed by signal: SIGTERM]' }],
|
||||
}))?.card).toMatchObject({ output: 'gone', signal: 'SIGTERM' })
|
||||
})
|
||||
|
||||
it('flags a failing exit as terminalFailed; clean exits and running cards are not', () => {
|
||||
// isError stays false on a failing command (the exit status is result
|
||||
// data), so this predicate is the row's only failure signal.
|
||||
expect(terminalFailed(terminalCardModel(settled({
|
||||
resultView: resultTerminal({ exitCode: 2 }),
|
||||
content: [{ type: 'text', text: 'boom\n[exit code: 2]' }],
|
||||
}))!)).toBe(true)
|
||||
expect(terminalFailed(terminalCardModel(settled({
|
||||
resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
|
||||
content: [{ type: 'text', text: 'gone\n[killed by signal: SIGTERM]' }],
|
||||
}))!)).toBe(true)
|
||||
expect(terminalFailed(terminalCardModel(settled())!)).toBe(false)
|
||||
expect(terminalFailed(terminalCardModel(running())!)).toBe(false)
|
||||
})
|
||||
|
||||
it('takes the result view\'s replacement title over the pending one', () => {
|
||||
// The presentation contract defines a result title as REPLACING the pending
|
||||
// title, so a tool that rewrites it at settle time must win here.
|
||||
it('keeps status text that has no terminal pill and requires a leading newline', () => {
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ title: 'pnpm run check' }),
|
||||
resultView: resultTerminal({ title: 'pnpm run check --filter web' }),
|
||||
}))?.card.command).toBe('pnpm run check --filter web')
|
||||
// Without one, the call's title is what the card keeps.
|
||||
expect(terminalCardModel(settled())?.card.command).toBe('ls -la')
|
||||
content: [{ type: 'text', text: 'timed out\n[timed out after 1000ms]\n[exit code: 2]' }],
|
||||
}))?.card).toMatchObject({ output: 'timed out\n[timed out after 1000ms]', exitCode: 2 })
|
||||
expect(terminalCardModel(settled({
|
||||
content: [{ type: 'text', text: '[exit code: 5]' }],
|
||||
}))?.card).toMatchObject({ output: '[exit code: 5]', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('resolves the cwd against the session workspace the way the bridge must', () => {
|
||||
it('resolves the raw workdir against the session workspace', () => {
|
||||
// Omitted workdir — the common bash call — IS the session workspace.
|
||||
expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
// A relative workdir joins under it.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: 'packages/ui' }),
|
||||
call: { name: 'bash', argsRaw: shellArgs({ workdir: 'packages/ui' }) },
|
||||
}), '/w/app')?.card.cwd).toBe('/w/app/packages/ui')
|
||||
// An absolute one is used as-is.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '/srv/other' }),
|
||||
call: { name: 'bash', argsRaw: shellArgs({ workdir: '/srv/other' }) },
|
||||
}), '/w/app')?.card.cwd).toBe('/srv/other')
|
||||
// With no session cwd there is nothing to resolve against: a relative path
|
||||
// stays as authored and an omitted one stays absent (a bare `$` prompt).
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: 'packages/ui' }),
|
||||
call: { name: 'bash', argsRaw: shellArgs({ workdir: 'packages/ui' }) },
|
||||
}))?.card.cwd).toBe('packages/ui')
|
||||
expect(terminalCardModel(settled())?.card.cwd).toBeUndefined()
|
||||
// The running arm resolves identically.
|
||||
@@ -144,97 +139,136 @@ describe('terminalCardModel', () => {
|
||||
it('normalizes a relative workdir so the label names the directory actually used', () => {
|
||||
// The bash executor resolves the workdir before running, so `..` against
|
||||
// /w/app runs in /w — the card must say `w`, not `..`.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '..' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w')
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '.' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../sibling' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/sibling')
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: './nested/../other' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/app/other')
|
||||
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '..' }) } }), '/w/app')?.card.cwd).toBe('/w')
|
||||
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '.' }) } }), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '../sibling' }) } }), '/w/app')?.card.cwd).toBe('/w/sibling')
|
||||
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: './nested/../other' }) } }), '/w/app')?.card.cwd).toBe('/w/app/other')
|
||||
// A `..` that would climb past the root is dropped, as a filesystem does.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../../..' }),
|
||||
}), '/w')?.card.cwd).toBe('/')
|
||||
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '../../..' }) } }), '/w')?.card.cwd).toBe('/')
|
||||
// An absolute path carrying segments normalizes too.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '/srv/./app/../other' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/srv/other')
|
||||
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '/srv/./app/../other' }) } }), '/w/app')?.card.cwd).toBe('/srv/other')
|
||||
// A Windows path keeps its separators.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: 'C:\\ws\\app\\..' }),
|
||||
}), '/w')?.card.cwd).toBe('C:\\ws')
|
||||
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: 'C:\\ws\\app\\..' }) } }), '/w')?.card.cwd).toBe('C:\\ws')
|
||||
// Without a session cwd a relative `..` has nothing to resolve against, so
|
||||
// it survives as authored rather than being silently dropped.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../elsewhere' }),
|
||||
}))?.card.cwd).toBe('../elsewhere')
|
||||
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '../elsewhere' }) } }))?.card.cwd).toBe('../elsewhere')
|
||||
})
|
||||
|
||||
it('keeps a UNC server and share as an unpoppable root', () => {
|
||||
// Windows cannot climb above a share, so `..` from the share root stays put.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '..' }),
|
||||
}), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share')
|
||||
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '..' }) } }), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share')
|
||||
// Below the share it pops normally, keeping the UNC separators.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '..' }),
|
||||
}), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
|
||||
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '..' }) } }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
|
||||
// Several `..` cannot escape the root either.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../../..' }),
|
||||
}), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
|
||||
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '../../..' }) } }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
|
||||
})
|
||||
|
||||
it('draws a bare $ when the window dropped the call head, rather than guessing', () => {
|
||||
// A truncated call carries no cwd anywhere: the result view has none, and
|
||||
// the original call may have used an explicit workdir. Falling back to the
|
||||
// session workspace here would name a directory the card cannot know.
|
||||
it('supports terminal_send without giving background or failed sends a terminal card', () => {
|
||||
const argsRaw = JSON.stringify({ sessionId: 'pty-3', text: 'make' })
|
||||
const run = running({ name: 'terminal_send', argsRaw })
|
||||
expect(terminalCardModel(run, '/w/app')).toMatchObject({
|
||||
copy: { kind: 'terminal-send', text: 'make', sessionId: 'pty-3' },
|
||||
card: { cwd: '/w/app', running: true },
|
||||
})
|
||||
const done = settled({ call: { name: 'terminal_send', argsRaw }, content: [{ type: 'text', text: 'ok' }] })
|
||||
expect(localizeTerminalCardModel(terminalCardModel(done)!, enT)).toMatchObject({
|
||||
description: 'Terminal pty-3', card: { command: 'make', output: 'ok', running: false },
|
||||
})
|
||||
expect(terminalCardModel(settled({
|
||||
call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }),
|
||||
}), '/w/app')?.card.cwd).toBeUndefined()
|
||||
// A present call view that omits its cwd still means the workspace.
|
||||
expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
})
|
||||
|
||||
it('carries the call view\'s description, which the contract renders above the card', () => {
|
||||
expect(terminalCardModel(settled())?.description).toBe('List files')
|
||||
expect(terminalCardModel(running())?.description).toBe('List files')
|
||||
// A presenter that supplies none, and a window-truncated call side, both
|
||||
// leave it absent so the row keeps its args-derived summary.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: { card: 'terminal', title: 'ls' },
|
||||
}))?.description).toBeUndefined()
|
||||
expect(terminalCardModel(settled({ call: null, callView: null }))?.description).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a window-truncated call side falls back to the result title, then to an empty command', () => {
|
||||
// Truncation drops both the call head and its view (conversation.ts).
|
||||
const truncated = { call: null, callView: null }
|
||||
expect(terminalCardModel(settled({
|
||||
...truncated, resultView: resultTerminal({ title: 'ls -la' }),
|
||||
}))?.card).toMatchObject({ command: 'ls -la', cwd: undefined, running: false })
|
||||
expect(terminalCardModel(settled(truncated))?.card).toMatchObject({ command: '', cwd: undefined })
|
||||
})
|
||||
|
||||
it('returns null for every non-terminal call: no views, generic views, unknown cards', () => {
|
||||
expect(terminalCardModel(running({ callView: null }))).toBeNull()
|
||||
expect(terminalCardModel(settled({ callView: null, resultView: null }))).toBeNull()
|
||||
expect(terminalCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull()
|
||||
// A generic result settles a terminal call as a generic card (the bash
|
||||
// tool's own execution-error and background paths).
|
||||
expect(terminalCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
|
||||
// A card tag this UI version does not know arrives over the wire; the
|
||||
// documented generic-card default takes it, not a crash.
|
||||
const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView
|
||||
expect(terminalCardModel(running({ callView: future }))).toBeNull()
|
||||
expect(terminalCardModel(settled({
|
||||
callView: future, resultView: { card: 'chart' } as unknown as ToolResultView,
|
||||
call: { name: 'terminal_send', argsRaw: JSON.stringify({ sessionId: 'pty-3', text: 'make', run_in_background: true }) },
|
||||
}))).toBeNull()
|
||||
expect(terminalCardModel(settled({ ...done, isError: true }))).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves persistent-shell running cards and settled generic output', () => {
|
||||
const persistent = JSON.stringify({ command: 'pwd' })
|
||||
expect(terminalCardModel(running({ argsRaw: persistent }))).toMatchObject({
|
||||
copy: { kind: 'shell', command: 'pwd', description: undefined }, card: { running: true },
|
||||
})
|
||||
expect(terminalCardModel(running({ name: 'pwsh', argsRaw: persistent }))).toMatchObject({
|
||||
copy: { kind: 'shell', command: 'pwd', description: undefined }, card: { running: true },
|
||||
})
|
||||
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: persistent } }))).toBeNull()
|
||||
expect(terminalCardModel(settled({ call: { name: 'pwsh', argsRaw: persistent } }))).toBeNull()
|
||||
})
|
||||
|
||||
it('derives the standard pwsh card from the same raw status markers', () => {
|
||||
expect(terminalCardModel(settled({
|
||||
call: { name: 'pwsh', argsRaw: ARGS },
|
||||
content: [{ type: 'text', text: 'failed\n[exit code: 3]' }],
|
||||
}))).toMatchObject({
|
||||
copy: { kind: 'shell', command: 'ls -la', description: 'List files' },
|
||||
card: { output: 'failed', exitCode: 3, running: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps terminal_send copy semantic until the render locale is known', () => {
|
||||
const model = terminalCardModel(running({
|
||||
name: 'terminal_send',
|
||||
argsRaw: JSON.stringify({ sessionId: 'pty-3', text: '' }),
|
||||
}))!
|
||||
expect(model.copy).toEqual({ kind: 'terminal-send', text: '', sessionId: 'pty-3' })
|
||||
expect(localizeTerminalCardModel(model, t)).toMatchObject({
|
||||
description: '终端 pty-3', card: { command: '(发送输入)' },
|
||||
})
|
||||
expect(localizeTerminalCardModel(model, enT)).toMatchObject({
|
||||
description: 'Terminal pty-3', card: { command: '(send input)' },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null without a paired call and for Code Dispatch children', () => {
|
||||
expect(terminalCardModel(settled({ call: null }))).toBeNull()
|
||||
expect(terminalCardModel(settled({ parentCallId: 'parent' }))).toBeNull()
|
||||
expect(terminalCardModel(running({ parentCallId: 'parent' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for background, errors, malformed args, unsupported tools, and non-text results', () => {
|
||||
expect(terminalCardModel(running({ argsRaw: shellArgs({ run_in_background: true }) }))).toBeNull()
|
||||
expect(terminalCardModel(settled({ isError: true }))).toBeNull()
|
||||
expect(terminalCardModel(running({ argsRaw: '{' }))).toBeNull()
|
||||
expect(terminalCardModel(running({ name: 'read' }))).toBeNull()
|
||||
expect(terminalCardModel(settled({ content: [] }))).toBeNull()
|
||||
expect(terminalCardModel(settled({ content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] }))).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['timeout type', { timeoutMs: '1000' }],
|
||||
['timeout value', { timeoutMs: 0 }],
|
||||
['workdir type', { workdir: 7 }],
|
||||
['background type', { run_in_background: 'yes' }],
|
||||
['permission type', { sandbox_permissions: 7, justification: 'Need access' }],
|
||||
['permission value', { sandbox_permissions: 'read-only', justification: 'Need access' }],
|
||||
['missing justification', { sandbox_permissions: 'workspace-write' }],
|
||||
['orphan justification', { justification: 'Need access' }],
|
||||
['blank justification', { sandbox_permissions: 'workspace-write', justification: ' ' }],
|
||||
])('keeps malformed standard-shell optional fields generic: %s', (_label, fields) => {
|
||||
expect(terminalCardModel(running({ argsRaw: shellArgs(fields) }))).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts valid optional and unknown standard-shell fields on the open parameter root', () => {
|
||||
expect(terminalCardModel(running({ argsRaw: shellArgs({
|
||||
timeoutMs: 1_000,
|
||||
sandbox_permissions: 'workspace-write',
|
||||
justification: 'Write generated output',
|
||||
extension: { version: 1 },
|
||||
}) }))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('validates terminal_send optional fields while retaining open-root extensions', () => {
|
||||
const send = (over: Record<string, unknown>) => running({
|
||||
name: 'terminal_send',
|
||||
argsRaw: JSON.stringify({ sessionId: 'pty-1', text: 'make', ...over }),
|
||||
})
|
||||
expect(terminalCardModel(send({ submit: 'yes' }))).toBeNull()
|
||||
expect(terminalCardModel(send({ run_in_background: 'yes' }))).toBeNull()
|
||||
expect(terminalCardModel(send({ submit: false, run_in_background: false }))).not.toBeNull()
|
||||
expect(terminalCardModel(send({ extension: { version: 1 } }))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps persistent shells with open-root extension fields on the running-card path', () => {
|
||||
const argsRaw = JSON.stringify({ command: 'pwd', extension: { version: 1 } })
|
||||
expect(terminalCardModel(running({ argsRaw }))).not.toBeNull()
|
||||
expect(terminalCardModel(running({ name: 'pwsh', argsRaw }))).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -263,7 +297,7 @@ describe('chat row terminal body', () => {
|
||||
it('a long output renders in full — the scroll container replaces the middle collapse', () => {
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `line-${i}`)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
resultView: resultTerminal({ output: `${lines.join('\n')}\n` }),
|
||||
content: [{ type: 'text', text: `${lines.join('\n')}\n` }],
|
||||
}))} />)
|
||||
toggleRow(view)
|
||||
expect(view.getByText('line-5')).toBeTruthy()
|
||||
@@ -273,7 +307,7 @@ describe('chat row terminal body', () => {
|
||||
|
||||
it('renders a multi-line command as one prompt row per line', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: callTerminal({ title: 'ls -la\necho done' }),
|
||||
call: { name: 'bash', argsRaw: shellArgs({ command: 'ls -la\necho done' }) },
|
||||
}))} />)
|
||||
toggleRow(view)
|
||||
const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
|
||||
@@ -282,22 +316,17 @@ describe('chat row terminal body', () => {
|
||||
expect(view.container.querySelectorAll('[data-terminal] [data-state]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('the fallback row shows the presenter description, not the args summary', () => {
|
||||
// Any terminal-declaring tool without its own keyed row lands here, so the
|
||||
// contract's above-card description has to win at this render site as well.
|
||||
it('the fallback row shows the call description', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: callTerminal({ description: 'Terminal 3' }),
|
||||
call: { name: 'bash', argsRaw: shellArgs({ description: 'Terminal 3' }) },
|
||||
}))} />)
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
expect(view.queryByText('List files')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the presenter description visible once the terminal card is expanded', () => {
|
||||
// The contract puts the description ABOVE the card. The collapsed summary is
|
||||
// hidden while a row is open, so an expanded terminal row has to draw it
|
||||
// itself or the description would only ever be visible collapsed.
|
||||
it('keeps the call description visible once the terminal card is expanded', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: callTerminal({ description: 'Terminal 3' }),
|
||||
call: { name: 'bash', argsRaw: shellArgs({ description: 'Terminal 3' }) },
|
||||
}))} />)
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
toggleRow(view)
|
||||
@@ -315,26 +344,40 @@ describe('chat row terminal body', () => {
|
||||
expect(runStateOf(view.container)).toBe('ongoing')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ locale: 'zh', translate: t, description: '终端 pty-3', command: '(发送输入)' },
|
||||
{ locale: 'en', translate: enT, description: 'Terminal pty-3', command: '(send input)' },
|
||||
])('renders terminal_send copy through the $locale locale', ({ translate, description, command }) => {
|
||||
const block = running({
|
||||
name: 'terminal_send',
|
||||
argsRaw: JSON.stringify({ sessionId: 'pty-3', text: '' }),
|
||||
})
|
||||
const view = render(<GenericToolCard {...ownerProps(block)} toolName="terminal_send" t={translate} />)
|
||||
expect(view.getByText(description)).toBeTruthy()
|
||||
toggleRow(view)
|
||||
expect(view.getByText(command)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a non-terminal call keeps the args-JSON text body', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: null, resultView: null,
|
||||
call: { name: 'bash', argsRaw: shellArgs({ run_in_background: true }) },
|
||||
}))} />)
|
||||
toggleRow(view)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a terminal call with no args still expands, through its terminal body alone', () => {
|
||||
// Empty args make the text body null; the terminal material carries the row.
|
||||
it('malformed empty args use the generic output body', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
call: { name: 'bash', argsRaw: '' },
|
||||
}))} />)
|
||||
toggleRow(view)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
expect(view.container.querySelector('[class*="_ioText_"]')?.textContent).toBe('a.ts b.ts\nc.ts d.ts\n')
|
||||
expect(view.container.querySelector('[data-terminal]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a failing exit status surfaces as the collapsed row\'s error state', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
resultView: resultTerminal({ exitCode: 2 }),
|
||||
content: [{ type: 'text', text: 'boom\n[exit code: 2]' }],
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-state]')?.getAttribute('data-state')).toBe('error')
|
||||
})
|
||||
@@ -386,34 +429,32 @@ describe('BashRow terminal card', () => {
|
||||
|
||||
it('a failing exit status surfaces as the collapsed row\'s error state', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
resultView: resultTerminal({ exitCode: 2 }),
|
||||
content: [{ type: 'text', text: 'boom\n[exit code: 2]' }],
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('error')
|
||||
})
|
||||
|
||||
it('shows the terminal presenter\'s description instead of the args summary', () => {
|
||||
// `terminal_send`-style presenters author a description the args do not
|
||||
// repeat; the contract puts it above the card, which is this row's summary.
|
||||
it('shows the call description as the terminal summary', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
callView: callTerminal({ description: 'Terminal 3' }),
|
||||
call: { name: 'bash', argsRaw: shellArgs({ description: 'Terminal 3' }) },
|
||||
}))} />)
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
expect(view.queryByText('List files')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the args-derived summary when the presenter authored no description', () => {
|
||||
it('keeps the command summary for a persistent shell with no description', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
callView: { card: 'terminal', title: 'ls -la' },
|
||||
call: { name: 'bash', argsRaw: JSON.stringify({ command: 'ls -la' }) },
|
||||
}))} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a non-terminal bash call (background start) renders the summary row alone', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
callView: { card: 'generic', title: 'sleep 30', kind: 'execute' },
|
||||
resultView: { card: 'generic' },
|
||||
call: { name: 'bash', argsRaw: shellArgs({ command: 'sleep 30', description: 'Wait', run_in_background: true }) },
|
||||
content: [{ type: 'text', text: 'started background job job-1' }],
|
||||
}))} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.getByText('Wait')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
expect(view.container.querySelector('[data-sample="bash"]')?.getAttribute('role')).toBeNull()
|
||||
})
|
||||
@@ -422,8 +463,6 @@ describe('BashRow terminal card', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
content: [{ type: 'text', text: 'Error: command aborted' }],
|
||||
isError: true,
|
||||
callView: { card: 'generic', title: 'ls -la', kind: 'execute' },
|
||||
resultView: { card: 'generic' },
|
||||
}))} />)
|
||||
const row = view.container.querySelector('[data-sample="bash"]')!
|
||||
expect(row.getAttribute('role')).toBe('button')
|
||||
@@ -498,7 +537,7 @@ describe('DetailsPanel Output section', () => {
|
||||
it('resets the card\'s expand state when the selected call changes', () => {
|
||||
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
|
||||
nodes: [settled({ content: [{ type: 'text', text: `${long.join('\n')}\n` }] })],
|
||||
}), target)
|
||||
fireEvent.click(view.getByRole('button', { name: '展开其余 4 行输出' }))
|
||||
expect(view.getByRole('button', { name: '收起输出' })).toBeTruthy()
|
||||
@@ -506,15 +545,15 @@ describe('DetailsPanel Output section', () => {
|
||||
cleanup()
|
||||
const second = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callId: 'c2', resultView: resultTerminal({ output: `${long.join('\n')}\n` }),
|
||||
callId: 'c2', content: [{ type: 'text', text: `${long.join('\n')}\n` }],
|
||||
})],
|
||||
}), { turnSeq: 10, callId: 'c2', toolName: 'bash' })
|
||||
expect(second.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the presenter description above the card', () => {
|
||||
it('renders the raw call description above the card', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ callView: callTerminal({ description: 'Terminal 3' }) })],
|
||||
nodes: [settled({ call: { name: 'bash', argsRaw: shellArgs({ description: 'Terminal 3' }) } })],
|
||||
}), target)
|
||||
const description = view.getByText('Terminal 3')
|
||||
const card = view.container.querySelector('[data-terminal]')
|
||||
@@ -523,16 +562,28 @@ describe('DetailsPanel Output section', () => {
|
||||
expect(description.compareDocumentPosition(card!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('localizes terminal_send copy in Details', () => {
|
||||
const argsRaw = JSON.stringify({ sessionId: 'pty-3', text: '' })
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({
|
||||
call: { name: 'terminal_send', argsRaw },
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
})],
|
||||
}), { ...target, toolName: 'terminal_send' })
|
||||
expect(view.getByText('终端 pty-3')).toBeTruthy()
|
||||
expect(view.getByText('(发送输入)')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('resolves the prompt cwd against the session workspace', () => {
|
||||
const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app')
|
||||
// No workdir in the call view: the prompt label is the workspace basename.
|
||||
// No workdir in the call args: the prompt label is the workspace basename.
|
||||
expect(view.getByText('app')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the terminal card at full height, keeping the JSON Input section', () => {
|
||||
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
|
||||
nodes: [settled({ content: [{ type: 'text', text: `${long.join('\n')}\n` }] })],
|
||||
}), target)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
@@ -549,14 +600,14 @@ describe('DetailsPanel Output section', () => {
|
||||
})
|
||||
|
||||
it('a running non-terminal call keeps the 运行中… placeholder', () => {
|
||||
const view = mount(snapshot({ runningCalls: [running({ callView: null })] }), target)
|
||||
const view = mount(snapshot({ runningCalls: [running({ argsRaw: shellArgs({ run_in_background: true }) })] }), target)
|
||||
expect(view.getByText('运行中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a non-terminal result keeps the flattened pre with its error styling', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null, isError: true,
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'permission denied' }],
|
||||
})],
|
||||
}), target)
|
||||
@@ -564,23 +615,8 @@ describe('DetailsPanel Output section', () => {
|
||||
expect(pre?.textContent).toBe('permission denied')
|
||||
})
|
||||
|
||||
// The panel resolves a sub-dispatch through the same material as a native
|
||||
// call, so a sub-call that DID carry terminal views would render the card.
|
||||
// The shipped wire cannot produce that yet: `session.ts` folds
|
||||
// `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and
|
||||
// the host's `viewFor` only presents top-level `tool/call`/`tool/result`. This
|
||||
// pins the resolution path with views injected directly, and the arm below
|
||||
// pins what the shipped path shows.
|
||||
it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => {
|
||||
const child = settled({ callId: 'c1' })
|
||||
const view = mount(snapshot({
|
||||
runningCalls: [running({ callId: 'p1', subCalls: [child] })],
|
||||
}), target)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => {
|
||||
const child = settled({ callId: 'c1', callView: null, resultView: null })
|
||||
it('a Code Dispatch child keeps the flattened form despite valid terminal raw fields', () => {
|
||||
const child = settled({ callId: 'c1', parentCallId: 'p1' })
|
||||
const view = mount(snapshot({
|
||||
runningCalls: [running({ callId: 'p1', subCalls: [child] })],
|
||||
}), target)
|
||||
@@ -591,20 +627,24 @@ describe('DetailsPanel Output section', () => {
|
||||
expect(output?.querySelector('pre')?.textContent).toContain('a.ts b.ts')
|
||||
})
|
||||
|
||||
it('a running run_code sub-dispatch resolves through the running material', () => {
|
||||
it('a running Code Dispatch child keeps the running placeholder', () => {
|
||||
const view = mount(snapshot({
|
||||
// The leading non-matching sub-call exercises the scan's skip.
|
||||
runningCalls: [running({
|
||||
callId: 'p1',
|
||||
subCalls: [running({ callId: 'other' }), running()],
|
||||
subCalls: [
|
||||
running({ callId: 'other', parentCallId: 'p1' }),
|
||||
running({ parentCallId: 'p1' }),
|
||||
],
|
||||
})],
|
||||
}), target)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
expect(view.getByText('运行中…')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-terminal]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a window-truncated call head titles the panel by callId and drops the Input section', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })],
|
||||
nodes: [settled({ call: null })],
|
||||
}), target)
|
||||
expect(view.getByText('c1')).toBeTruthy()
|
||||
expect(view.queryByText('输入')).toBeNull()
|
||||
@@ -674,7 +714,6 @@ describe('DetailsPanel Output section', () => {
|
||||
it('a non-text result block renders as JSON, and an empty result falls back to its error', () => {
|
||||
const nonText = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null,
|
||||
content: [{ type: 'reasoning', text: 'why' }],
|
||||
})],
|
||||
}), target)
|
||||
@@ -685,7 +724,7 @@ describe('DetailsPanel Output section', () => {
|
||||
cleanup()
|
||||
const empty = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null, content: [], isError: true,
|
||||
content: [], isError: true,
|
||||
error: { name: 'ToolError', code: 'interrupted' },
|
||||
})],
|
||||
}), target)
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('planSummary', () => {
|
||||
const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
|
||||
call: { name: 'todo_write', argsRaw },
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over,
|
||||
content: [], isError: false, subCalls: [], ...over,
|
||||
})
|
||||
|
||||
function rowProps(block: unknown): TodoRowProps {
|
||||
@@ -94,7 +94,7 @@ describe('TodoRow', () => {
|
||||
|
||||
it('omits the active clause when no item is in progress and reads running-call args', () => {
|
||||
const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
|
||||
render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
|
||||
render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, subCalls: [] })} />)
|
||||
expect(screen.getByText('1/1 已完成')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('TodoRow', () => {
|
||||
|
||||
it('keeps non-ok execution states visible through the shared row states', () => {
|
||||
const args = JSON.stringify({ todos: LIST })
|
||||
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [] })} />)
|
||||
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, subCalls: [] })} />)
|
||||
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
|
||||
running.unmount()
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/cl
|
||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { ToolTreeProps } from '../src/client/contract/slots.ts'
|
||||
import type { ToolCallOwnerProps, ToolTreeProps } from '../src/client/contract/slots.ts'
|
||||
import { ToolCallTree } from '../src/client/tool/ToolCallTree.tsx'
|
||||
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
|
||||
@@ -17,18 +17,21 @@ const t: ToolTreeProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId, call, callTime: 2_000,
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [],
|
||||
content: [], isError: false, subCalls: [],
|
||||
})
|
||||
|
||||
function props(
|
||||
block: ToolResultNode,
|
||||
selectedCallId?: string,
|
||||
description?: HostDescription,
|
||||
owners?: ToolCallOwnerProps[],
|
||||
): ToolTreeProps {
|
||||
const snapshot = {} as SessionSnapshot
|
||||
const useSession = ((selector: (value: SessionSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession']
|
||||
const renderSlot = ((_key: string, _owner: object, options?: { fallback?: React.ReactNode }) =>
|
||||
options?.fallback ?? null) as unknown as ToolTreeProps['renderSlot']
|
||||
const renderSlot = ((_key: string, owner: ToolCallOwnerProps, options?: { fallback?: React.ReactNode }) => {
|
||||
owners?.push(owner)
|
||||
return options?.fallback ?? null
|
||||
}) as unknown as ToolTreeProps['renderSlot']
|
||||
return {
|
||||
useSession,
|
||||
renderSlot,
|
||||
@@ -64,16 +67,21 @@ describe('ToolCallTree', () => {
|
||||
})
|
||||
|
||||
it('recursively renders a selected leaf without selecting its ancestors', () => {
|
||||
const leaf = root('parent:code:1:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' })
|
||||
const owners: ToolCallOwnerProps[] = []
|
||||
const leaf = {
|
||||
...root('parent:code:1:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' }),
|
||||
parentCallId: 'parent:code:1',
|
||||
}
|
||||
const child = {
|
||||
...root('parent:code:1', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
|
||||
parentCallId: 'parent',
|
||||
subCalls: [leaf],
|
||||
}
|
||||
const block = {
|
||||
...root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
|
||||
subCalls: [child],
|
||||
}
|
||||
const view = render(<ToolCallTree {...props(block, leaf.callId)} />)
|
||||
const view = render(<ToolCallTree {...props(block, leaf.callId, undefined, owners)} />)
|
||||
const nests = view.container.querySelectorAll('[data-subcalls]')
|
||||
expect(nests[0]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent"]'))
|
||||
expect(nests[1]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent:code:1"]'))
|
||||
@@ -81,6 +89,11 @@ describe('ToolCallTree', () => {
|
||||
expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.hasAttribute('data-selected')).toBe(false)
|
||||
expect(view.container.querySelector('[data-chat-call-id="parent:code:1:code:1"]')?.getAttribute('data-selected')).toBe('true')
|
||||
expect(nests).toHaveLength(2)
|
||||
expect(owners.map(owner => [owner.callId, owner.block.parentCallId ?? null])).toEqual([
|
||||
['parent', null],
|
||||
['parent:code:1', 'parent'],
|
||||
['parent:code:1:code:1', 'parent:code:1'],
|
||||
])
|
||||
})
|
||||
|
||||
it('abbreviates a POSIX home path in the generic tool summary', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Test adapter for the production conversation.details.tool registration. */
|
||||
import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionEventEntry, SessionToolCallView } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
ChatConversationViewNode, ChatSnapshot, ConversationNode, DetailsSlotProps,
|
||||
@@ -28,12 +28,6 @@ function jsonFixture(value: unknown): JsonValue {
|
||||
return value as JsonValue
|
||||
}
|
||||
|
||||
function sessionCallView(view: NonNullable<ToolResultNode['callView']>): SessionToolCallView {
|
||||
if (view.card !== 'generic') return view
|
||||
const { rawInput, ...wireView } = view
|
||||
return rawInput === undefined ? wireView : { ...wireView, rawInput: jsonFixture(rawInput) }
|
||||
}
|
||||
|
||||
/** Build the canonical Chat slice consumed by Tool rows and details tests. */
|
||||
export function toolChatSnapshot(
|
||||
settled: readonly ConversationNode[] = [],
|
||||
@@ -110,7 +104,6 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se
|
||||
arguments: node.call.argsRaw,
|
||||
},
|
||||
},
|
||||
...(node.callView === null ? {} : { view: { for: 'call', view: sessionCallView(node.callView) } }),
|
||||
}
|
||||
entries.push(callEntry)
|
||||
const resultEntry: SessionEventEntry = {
|
||||
@@ -137,7 +130,6 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se
|
||||
}),
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
...(node.resultView === null ? {} : { view: { for: 'result', view: node.resultView } }),
|
||||
}
|
||||
entries.push(resultEntry)
|
||||
}
|
||||
|
||||
@@ -18,14 +18,14 @@ const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
|
||||
turn: 1, step: 1, time: 1_000, callView: null, subCalls: [], ...over,
|
||||
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
|
||||
})
|
||||
|
||||
const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
|
||||
callTime: 1_000,
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over,
|
||||
content: [], isError: false, subCalls: [], ...over,
|
||||
})
|
||||
|
||||
describe('tool-call-model', () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name, argsRaw: args },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [],
|
||||
content: [], isError: false, subCalls: [],
|
||||
})
|
||||
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import {
|
||||
bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -32,12 +31,24 @@ const SID = 's1' as SessionId
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const chatT = makeTranslate(chatZh, commonZh)
|
||||
|
||||
const SEARCH_ARGS = '{"query":"deepseek harness"}'
|
||||
const SEARCH_ARGS = '{"queries":["deepseek harness"]}'
|
||||
const FETCH_ARGS = '{"url":"https://example.com/page"}'
|
||||
|
||||
/** A web_search result view; overrides tune the sources / answer / truncation. */
|
||||
const resultSearch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind: 'search' }>>): ToolResultView => ({
|
||||
card: 'web', kind: 'search', truncated: false,
|
||||
interface SearchMeta {
|
||||
sources: { url: string; title?: string; snippet?: string; publishedAt?: string }[]
|
||||
truncated: boolean
|
||||
answer?: string
|
||||
}
|
||||
|
||||
interface FetchMeta {
|
||||
url: string
|
||||
statusCode: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Persisted web_search result metadata. */
|
||||
const searchMeta = (over?: Partial<SearchMeta>): SearchMeta => ({
|
||||
truncated: false,
|
||||
answer: 'A short answer.',
|
||||
sources: [
|
||||
{ url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' },
|
||||
@@ -46,14 +57,14 @@ const resultSearch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind
|
||||
...over,
|
||||
})
|
||||
|
||||
/** A web_fetch result view. */
|
||||
const resultFetch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>>): ToolResultView => ({
|
||||
card: 'web', kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, ...over,
|
||||
/** Persisted web_fetch result metadata. */
|
||||
const fetchMeta = (over?: Partial<FetchMeta>): FetchMeta => ({
|
||||
url: 'https://example.com/page', statusCode: 200, truncated: false, ...over,
|
||||
})
|
||||
|
||||
const runningSearch = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS,
|
||||
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, subCalls: [], ...over,
|
||||
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
|
||||
})
|
||||
|
||||
const settledSearch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
@@ -61,7 +72,7 @@ const settledSearch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
call: { name: 'web_search', argsRaw: SEARCH_ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'search text' }], isError: false,
|
||||
callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), subCalls: [], ...over,
|
||||
meta: searchMeta(), subCalls: [], ...over,
|
||||
})
|
||||
|
||||
const settledFetch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
@@ -69,53 +80,59 @@ const settledFetch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
call: { name: 'web_fetch', argsRaw: FETCH_ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'fetch body' }], isError: false,
|
||||
callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), subCalls: [], ...over,
|
||||
meta: fetchMeta(), subCalls: [], ...over,
|
||||
})
|
||||
|
||||
describe('webCardModel', () => {
|
||||
it('derives a search card from the result view, projecting every source field', () => {
|
||||
it('derives a search card from result metadata, projecting every source field', () => {
|
||||
expect(webCardModel(settledSearch())).toEqual({
|
||||
kind: 'search',
|
||||
answer: 'A short answer.',
|
||||
truncated: false,
|
||||
sources: [
|
||||
{ url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' },
|
||||
{ url: 'https://plain.example.org/b', title: undefined, snippet: undefined, publishedAt: undefined },
|
||||
{ url: 'https://plain.example.org/b' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the search truncation flag and an absent answer', () => {
|
||||
const model = webCardModel(settledSearch({ resultView: { card: 'web', kind: 'search', truncated: true, sources: [] } }))
|
||||
const model = webCardModel(settledSearch({ meta: { truncated: true, sources: [] } }))
|
||||
expect(model).toEqual({ kind: 'search', answer: undefined, truncated: true, sources: [] })
|
||||
})
|
||||
|
||||
it('derives a fetch card from the result view', () => {
|
||||
it('derives a fetch card from result metadata', () => {
|
||||
expect(webCardModel(settledFetch())).toEqual({
|
||||
kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false,
|
||||
})
|
||||
expect(webCardModel(settledFetch({ resultView: resultFetch({ statusCode: 404, truncated: true }) })))
|
||||
expect(webCardModel(settledFetch({ meta: fetchMeta({ statusCode: 404, truncated: true }) })))
|
||||
.toEqual({ kind: 'fetch', url: 'https://example.com/page', statusCode: 404, truncated: true })
|
||||
})
|
||||
|
||||
it('returns null for a running call, since the web card is result-only', () => {
|
||||
expect(webCardModel(runningSearch())).toBeNull()
|
||||
// Even a running call that somehow carried a web call view stays generic:
|
||||
// the derivation reads resultView only.
|
||||
expect(webCardModel(runningSearch({ callView: null }))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a settled call whose result view is not a web card', () => {
|
||||
expect(webCardModel(settledSearch({ resultView: null }))).toBeNull()
|
||||
expect(webCardModel(settledSearch({ resultView: { card: 'generic' } }))).toBeNull()
|
||||
// A card tag this UI version does not know arrives over the wire; the
|
||||
// documented generic-card default takes it, not a crash.
|
||||
const future = { card: 'chart', kind: 'search' } as unknown as ToolResultView
|
||||
expect(webCardModel(settledSearch({ resultView: future }))).toBeNull()
|
||||
// A web card whose kind this UI version does not know (a newer host's
|
||||
// value) also takes the generic path, not a malformed fetch.
|
||||
const futureKind = { card: 'web', kind: 'timeline' } as unknown as ToolResultView
|
||||
expect(webCardModel(settledSearch({ resultView: futureKind }))).toBeNull()
|
||||
it('returns null for missing calls, errors, malformed args/meta, unrelated tools, and children', () => {
|
||||
expect(webCardModel(settledSearch({ call: null }))).toBeNull()
|
||||
expect(webCardModel(settledSearch({ isError: true }))).toBeNull()
|
||||
expect(webCardModel(settledSearch({ call: { name: 'web_search', argsRaw: '{' } }))).toBeNull()
|
||||
expect(webCardModel(settledSearch({ meta: undefined }))).toBeNull()
|
||||
expect(webCardModel(settledSearch({ meta: { sources: [], truncated: 'yes' } }))).toBeNull()
|
||||
expect(webCardModel(settledSearch({ call: { name: 'echo', argsRaw: '{}' } }))).toBeNull()
|
||||
expect(webCardModel(settledSearch({ parentCallId: 'parent' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts open-root extensions while validating declared web arguments', () => {
|
||||
expect(webCardModel(settledSearch({
|
||||
call: { name: 'web_search', argsRaw: '{"queries":["deepseek"],"extension":1}' },
|
||||
}))).not.toBeNull()
|
||||
expect(webCardModel(settledSearch({
|
||||
call: { name: 'web_search', argsRaw: '{"queries":[7]}' },
|
||||
}))).toBeNull()
|
||||
expect(webCardModel(settledFetch({
|
||||
call: { name: 'web_fetch', argsRaw: '{"url":" "}' },
|
||||
}))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,7 +189,7 @@ describe('chat row web body', () => {
|
||||
|
||||
it('a failed web call keeps the summary row without the card', () => {
|
||||
const view = render(<WebRow {...rowProps(settledSearch({
|
||||
isError: true, resultView: { card: 'generic' },
|
||||
isError: true,
|
||||
}), 'web_search')} />)
|
||||
expect(view.getByText('网页搜索')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
@@ -180,19 +197,19 @@ describe('chat row web body', () => {
|
||||
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('the GenericToolCard fallback also expands to a web card for a web-declaring tool', () => {
|
||||
it('the GenericToolCard fallback does not promote an unknown tool from metadata alone', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settledSearch({
|
||||
call: { name: 'fx-web', argsRaw: SEARCH_ARGS },
|
||||
}), 'fx-web')} t={t} />)
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
toggleRow(view)
|
||||
expect(view.getByText('Titled')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-web="search"]')).not.toBeNull()
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
expect(view.getByText('search text')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the GenericToolCard fallback keeps the plain row for a non-web call', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settledSearch({
|
||||
call: { name: 'echo', argsRaw: '{}' }, callView: null, resultView: null,
|
||||
call: { name: 'echo', argsRaw: '{}' },
|
||||
meta: undefined,
|
||||
}), 'echo')} t={t} />)
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
})
|
||||
@@ -254,7 +271,7 @@ describe('DetailsPanel web Output section', () => {
|
||||
expect(view.getByText('Titled')).toBeTruthy()
|
||||
expect(view.getByText('excerpt')).toBeTruthy()
|
||||
// The Input JSON section survives beside it.
|
||||
expect(view.getByText(/"query"/)).toBeTruthy()
|
||||
expect(view.getByText(/"queries"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the fetch card and keeps the fetched body below it', () => {
|
||||
@@ -270,7 +287,7 @@ describe('DetailsPanel web Output section', () => {
|
||||
|
||||
it('a non-web result keeps the flattened pre form', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settledSearch({ callView: null, resultView: null })],
|
||||
nodes: [settledSearch({ meta: undefined })],
|
||||
}), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
const output = view.getByText('输出').closest('section')
|
||||
|
||||
@@ -38,7 +38,6 @@ function rootCall(match: ConversationMatch): RunningToolCall {
|
||||
turn: match.event.data.turn,
|
||||
step: match.event.data.step,
|
||||
time: match.event.time,
|
||||
callView: match.view?.for === 'call' ? match.view.view : null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
@@ -60,8 +59,6 @@ function rootResult(
|
||||
isError: result.isError === true,
|
||||
...(match.event.data.error === undefined ? {} : { error: match.event.data.error }),
|
||||
meta: match.event.data.meta,
|
||||
callView: previous?.callView ?? null,
|
||||
resultView: match.view?.for === 'result' ? match.view.view : null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
@@ -79,12 +76,12 @@ function locationStep(match: ConversationMatch): number {
|
||||
function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall {
|
||||
return {
|
||||
callId: data.subCallId,
|
||||
parentCallId: data.parentCallId,
|
||||
name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: locationTurn(match),
|
||||
step: locationStep(match),
|
||||
time: match.event.time,
|
||||
callView: null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
@@ -99,12 +96,11 @@ function childResult(
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
callId: data.subCallId,
|
||||
parentCallId: data.parentCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
callTime: previous === undefined || 'kind' in previous ? null : previous.time,
|
||||
content: data.content ?? [],
|
||||
isError: data.isError === true,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
@@ -190,13 +186,12 @@ function projectCall(
|
||||
seq: interruptedAt.seq - 0.8,
|
||||
time: interruptedAt.time,
|
||||
callId: block.callId,
|
||||
...block.parentCallId === undefined ? {} : { parentCallId: block.parentCallId },
|
||||
call: { name: block.name, argsRaw: block.argsRaw },
|
||||
callTime: block.time,
|
||||
content: [],
|
||||
isError: true,
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: block.callView,
|
||||
resultView: null,
|
||||
subCalls,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ describe('Trajectory conversation Definitions', () => {
|
||||
}])
|
||||
})
|
||||
|
||||
it('keeps parallel interrupted roots and nests Code Dispatch results', () => {
|
||||
it('keeps parallel roots, raw Tool facts, and nested Code Dispatch results', () => {
|
||||
const current = snapshot(assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
@@ -209,16 +209,44 @@ describe('Trajectory conversation Definitions', () => {
|
||||
arguments: { path: 'README.md' },
|
||||
content: [{ type: 'text', text: 'contents' }],
|
||||
}),
|
||||
at(7, 'step/end', { turn: 1, step: 1 }),
|
||||
at(7, 'tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
id: 'result-root-a',
|
||||
role: 'user',
|
||||
source: { kind: 'tool', callId: 'root-a' },
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'root-a',
|
||||
content: [{ type: 'text', text: 'root failed' }],
|
||||
isError: true,
|
||||
}],
|
||||
},
|
||||
error: { name: 'ToolError', code: 'failed' },
|
||||
meta: { presentation: 'raw' },
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(8, 'step/end', { turn: 1, step: 1 }),
|
||||
]))
|
||||
|
||||
const tools = current.eventNodes.filter(node => node.kind === 'tool-result')
|
||||
expect(tools.map(node => node.callId).sort()).toEqual(['root-a', 'root-b'])
|
||||
expect(tools.find(node => node.callId === 'root-a')?.subCalls).toMatchObject([{
|
||||
expect(tools.find(node => node.callId === 'root-a')).toMatchObject({
|
||||
kind: 'tool-result',
|
||||
callId: 'child',
|
||||
call: { name: 'read' },
|
||||
}])
|
||||
callId: 'root-a',
|
||||
call: { name: 'code', argsRaw: '{}' },
|
||||
content: [{ type: 'text', text: 'root failed' }],
|
||||
isError: true,
|
||||
error: { name: 'ToolError', code: 'failed' },
|
||||
meta: { presentation: 'raw' },
|
||||
subCalls: [{
|
||||
kind: 'tool-result', callId: 'child', parentCallId: 'root-a', call: { name: 'read' },
|
||||
}],
|
||||
})
|
||||
expect(tools.find(node => node.callId === 'root-b')).toMatchObject({
|
||||
isError: true,
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
})
|
||||
})
|
||||
|
||||
it('assembles compaction lifecycle, checkpoint replacement, and orphan interruption', () => {
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200,
|
||||
content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
|
||||
content: [{ type: 'text', text: 'a.txt' }], isError: false,
|
||||
},
|
||||
] as unknown as LegacyConversationSlice['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
@@ -113,7 +113,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
partial: null,
|
||||
runningCalls: [{
|
||||
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
|
||||
turn: 1, step: 2, time: 9_000, callView: null, subCalls: [],
|
||||
turn: 1, step: 2, time: 9_000, subCalls: [],
|
||||
}],
|
||||
})
|
||||
expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
|
||||
@@ -177,7 +177,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
partial: { ...partial, blocks: [] },
|
||||
runningCalls: [{
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
|
||||
turn: 1, step: 1, time: 9_000, callView: null, subCalls: [],
|
||||
turn: 1, step: 1, time: 9_000, subCalls: [],
|
||||
}],
|
||||
})
|
||||
|
||||
@@ -218,12 +218,12 @@ describe('deriveTrajectoryLayout', () => {
|
||||
{
|
||||
kind: 'tool-result', seq: 2, time: 2_500, callId: 'a',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
content: [], isError: false,
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 4_000, callId: 'b',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
content: [], isError: false,
|
||||
},
|
||||
] as unknown as LegacyConversationSlice['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
@@ -447,7 +447,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
content: [], isError: false,
|
||||
},
|
||||
{
|
||||
kind: 'context', seq: 4, time: 9_000,
|
||||
@@ -502,7 +502,7 @@ describe('run_code sub-dispatch cells', () => {
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
|
||||
call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200,
|
||||
content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null,
|
||||
content: [{ type: 'text', text: 'done' }], isError: false,
|
||||
subCalls: [],
|
||||
},
|
||||
] as unknown as LegacyConversationSlice['nodes']
|
||||
@@ -511,7 +511,7 @@ describe('run_code sub-dispatch cells', () => {
|
||||
kind: 'tool-result' as const, seq: 100 + n, time: end,
|
||||
callId: `p1:code:${n}`,
|
||||
call: { name, argsRaw: '{"x":1}' }, callTime: start,
|
||||
content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null,
|
||||
content: [{ type: 'text' as const, text: 'ok' }], isError: false,
|
||||
subCalls: [],
|
||||
})
|
||||
|
||||
@@ -538,7 +538,7 @@ describe('run_code sub-dispatch cells', () => {
|
||||
it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
|
||||
const running = {
|
||||
callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
|
||||
turn: 0, step: 0, time: 6_400, callView: null, subCalls: [],
|
||||
turn: 0, step: 0, time: 6_400, subCalls: [],
|
||||
}
|
||||
const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] })
|
||||
const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
|
||||
|
||||
@@ -160,7 +160,6 @@ describe('TrajectorySnapshotBuilder', () => {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
time: 7,
|
||||
callView: null,
|
||||
subCalls: [],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -84,7 +84,7 @@ const NODES: LegacyConversationSlice['nodes'] = [
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200,
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [],
|
||||
content: [], isError: false, subCalls: [],
|
||||
},
|
||||
{
|
||||
kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [],
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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/core/tools/README.md
|
||||
README.md: 4f67dee611e6f28547ab276bd0884e787aeae0d9
|
||||
README.zh.md: 0137f295f2810930212dcb3f45e90af73bcf449c
|
||||
README.md: a140255a04187d4f2206df0f90c950d7608ee8ac
|
||||
README.zh.md: f519ba0a364f05ab63967c07ffd6681a5f62a70a
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user