diff --git a/docs/design/assets/session-sources-collapsed.png b/docs/design/assets/session-sources-collapsed.png new file mode 100644 index 00000000000..ee6168c2f78 Binary files /dev/null and b/docs/design/assets/session-sources-collapsed.png differ diff --git a/docs/design/assets/session-sources-expanded.png b/docs/design/assets/session-sources-expanded.png new file mode 100644 index 00000000000..a561561c8ed Binary files /dev/null and b/docs/design/assets/session-sources-expanded.png differ diff --git a/docs/design/web-shell-session-sources.md b/docs/design/web-shell-session-sources.md new file mode 100644 index 00000000000..44b2670a324 --- /dev/null +++ b/docs/design/web-shell-session-sources.md @@ -0,0 +1,427 @@ +# Web Shell Session Sources + +Status: implemented on this branch; local validation is recorded in the +[implementation notes](../plans/web-shell-session-sources.md). + +[中文版](./web-shell-session-sources.zh-CN.md) + +## Decision and scope + +Provide one Sources section for the session's uploaded files, workspace-file +references, and links. The section combines existing attachment storage with +explicit source metadata; users see each uploaded file once. A listed material +does not mean that a model has read, cited, or used it. Keep these inputs separate +from artifacts, and reuse existing previews and workspace ownership checks. + +The first implementation provides: + +- A `record_source` tool for explicit file/link registration. +- Session APIs to list, upsert, and remove references. +- Optional attachment metadata enrichment after Web Shell prompt admission. +- Durable metadata, deduplication, and a Sources section in the environment + panel, with previews in the existing right panel. + +Registration stores metadata only. It does not read or copy a file, fetch a URL, +publish content, append resource contents to a prompt, or change permissions. +An explicit tool call still contributes its normal tool acknowledgement to the +conversation. Adding the tool therefore changes the available tool schema, but +does not change prompt admission, scheduling, or model context construction. + +MCP/API connection management, automatic collection of file reads/search results, +usage tracking, citation graphs, hook producers, generic `ToolResult.sources`, +cross-session source libraries, and content retention are outside this phase. +The Desktop workspace connection registry is a different feature. + +## Current implementation and reuse boundaries + +This proposal was checked against `main` at `fb12a6e7fe` on 2026-09-07. + +| Existing surface | Relevant behavior | Source implementation decision | +| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | +| [record-artifact.ts](../../packages/core/src/tools/record-artifact.ts) | Emits artifact metadata through `ToolResult.artifacts` | Reuse naming and input conventions; do not emit artifacts for sources | +| [sessionArtifacts.ts](../../packages/acp-bridge/src/sessionArtifacts.ts) | Maintains artifact records, persistence coordination, and content status | Keep existing artifact behavior intact; do not add a source role to every artifact | +| [session routes](../../packages/cli/src/serve/routes/session.ts) | Owner-aware artifact and attachment APIs | Apply the same session owner and client authorization boundaries | +| [sessionAttachments.ts](../../packages/acp-bridge/src/sessionAttachments.ts) | Owns uploaded attachment bytes and references | Reference existing attachment IDs; do not create another upload store | +| [session actions](../../packages/web-shell/client/daemon/session/actions.ts) | Uploads attachments, then obtains prompt admission | Register accepted attachment references through a separate metadata request | +| [EnvironmentPanel.tsx](../../packages/web-shell/client/components/panels/EnvironmentPanel.tsx) | Environment, subagents, and background task sections | Add a configurable `sources` section | +| [ArtifactPanel.tsx](../../packages/web-shell/client/components/artifacts/ArtifactPanel.tsx) | File previews and right-panel detail surfaces | Reuse the relevant renderers and owner resolver through a source tab | + +The existing artifact `source: tool | hook | client` field describes the +registrant. It is not a session reference entity. An artifact and a source may +point to the same workspace file, but have independent IDs and removal behavior. +Source previews must not create hidden artifact records or add output cards to +the transcript. + +## Unified view and registered metadata + +Attachments remain the durable store for uploaded file bytes. The Sources +section displays those files directly, including historical files with no source +record. The source APIs continue to manage explicit reference metadata. The UI +uses real source records and attachment references as separate input types; it +does not invent source IDs or timestamps for uploaded files. + +Deduplicate by attachment ID. When a registered attachment source exists, its +title and description take precedence; otherwise show the existing filename. +Workspace references and links remain independent, even when names match. No +read, refresh, or migration automatically registers historical attachments. + +The source API's 200-record and field-length limits apply to registered metadata, +not to the number or filename length of already-uploaded files in the unified +view. The view preserves each store's ordering rather than inventing a common +creation time. + +Removing an attachment's source registration removes its metadata and source ID +from the source API. It does not delete the bytes: the file remains visible as a +plain uploaded file. Opening or refreshing that file does not recreate the +registration or advance its revision. Removing workspace-file or link references +removes those explicit entries. The unified list has no per-row dismiss action. + +## Data contract for registered metadata + +Public source API types: + +```ts +type SessionSourceLocator = + | { type: 'workspace_file'; workspacePath: string } + | { type: 'attachment'; attachmentId: string } + | { type: 'url'; url: string }; + +interface SessionSourceInput { + title: string; + locator: SessionSourceLocator; + description?: string; +} + +interface SessionSource extends SessionSourceInput { + id: string; + kind: 'file' | 'link'; + workspaceCwd?: string; + createdAt: string; + updatedAt: string; +} + +interface SessionSourcesSnapshot { + version: 1; + revision: number; + sources: SessionSource[]; +} +``` + +The server derives `kind`, ID, timestamps, and `workspaceCwd`; callers cannot set +them. `workspaceCwd` is required on stored workspace-file sources and absent on +other kinds. It captures the owning workspace at registration, so a later +session cwd change cannot silently retarget a reference. MIME +type, byte size, and resource availability come from the existing file or +attachment resolver when opening a preview. They are not registration-time +claims. No extensible metadata bag, retention flags, “used” state, per-turn usage +history, or caller-supplied workspace identity is needed. + +### Validation and identity + +- Accept exactly one locator variant with its required field; reject unknown + input fields. Trim titles/descriptions, reject empty titles and control + characters, and limit title to 200, description to 1,000, workspace path to + 500, attachment ID to 200, and URL to 2,048 characters. +- Workspace paths are relative to the session's bound workspace. Normalize + separators and `.` segments and reject absolute paths, traversal outside the + root, and NULs. Registration performs lexical validation only; preview uses + existing filesystem access/trust checks, including symlink containment. +- Attachment IDs must identify an existing attachment in the same session on + registration. The daemon checks this before forwarding the mutation. The + tool accepts only workspace files and URLs, so it cannot bypass this check. +- URLs must parse as HTTP(S), have a hostname, and have no embedded credentials. + Do not fetch them or infer canonical URLs through redirects. Preserve query + parameters and fragments: distinct document sections remain distinct sources. + Persisted URL/title data uses existing transcript privacy handling; no new + logging of raw source payloads is introduced. +- Deduplication key is the session ID plus locator type, normalized locator, and + the server-derived workspace cwd for workspace files. + Use a deterministic digest for the opaque source ID. Keep path case intact; + do not resolve symlinks or hash file contents to deduplicate. +- Upsert of the same locator keeps its ID and `createdAt`. A changed title or + description updates `updatedAt`; omitted description preserves the previous + value and an empty description clears it. Identical input is a no-op. +- URLs and files with similar names are distinct. Uploaded bytes with different + attachment IDs are distinct even when their filenames match. +- Cap the list at 200 records. Updating existing records still works at the + limit; adding another returns `409 source_limit_reached`. Do not silently + evict entries that the user expects to find later. + +Registered list order is stable: newest `createdAt` first, then ID. Metadata edits do not +reorder the list. + +## Registration and API behavior + +### Agent tool + +`record_source` accepts `title`, optional `description`, and either a +`workspace_file` or `url` locator. Its description explains that the caller is +adding a reference, not proving usage, and should use `record_artifact` for +newly produced deliverables. + +Example: + +```json +{ + "title": "Project requirements", + "locator": { + "type": "workspace_file", + "workspacePath": "docs/requirements.md" + } +} +``` + +Register the tool only for a top-level daemon ACP session with a bound source +service. Follow existing tool allow/deny rules. Do not expose it in standalone +CLI, SDK-only, or subagent execution in this phase; do not introduce a global +service or reuse the artifact enable flag. Binding must be established before +tool discovery and refreshed correctly on session load/replacement. + +The tool calls the session's source service directly and returns a short +acknowledgement with the source ID only after persistence succeeds. Validation +or persistence failure produces an ordinary tool error. Avoid new metadata +fields in every tool result, tool scheduler, hook result, and history replay +path just to support one explicit registration tool. + +### Session HTTP API + +| Method and path | Input | Successful result | +| --------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------- | +| `GET /session/:id/sources` | None | `200 { revision, sources }` | +| `POST /session/:id/sources` | One `SessionSourceInput` | `200 { revision, source, change }`, where change is `created`, `updated`, or `unchanged` | +| `DELETE /session/:id/sources/:sourceId` | None | `200 { revision, removed }`; an already absent ID gives `removed: false` | + +There is no PATCH endpoint; POST is the metadata upsert. No batch endpoint is +needed initially. Attachment automation sends the small number of references +individually and reports any failures per item. + +All three routes are **live-session-owner scoped**. Reads use +`withOwnerReadSession`; mutations use `withOwnerMutableSession` and the existing +strict mutation gate. Require a valid session-bound client ID for mutations; +reads retain the existing session read authorization rules. Resolve exactly one +trusted owning runtime before touching a bridge, service, file, or attachment. + +Reuse existing owner errors: unknown session, untrusted workspace, ambiguous +owner, and bootstrapping/draining/removed runtime states follow the established +route helpers. Never fall back to the primary runtime. Persisted-only sessions +must be resumed through the existing session lifecycle before these routes work. +Archived-session mutations follow the shared archive coordinator's rejection. +Source metadata operations may run while a prompt is active; they serialize +with source mutations and the recording writer, not with the entire model turn. +They must not acquire an idle-only prompt gate or hold a prompt scheduling lock. + +Invalid source payloads return `400 invalid_source`; unavailable persistence +returns `503 source_persistence_unavailable`. Use `404` for a missing attachment +at registration, without revealing attachments belonging to other sessions. +Mutation timeout is an unknown outcome: refetch the list or retry the same +upsert/delete. Do not retry through a different owner or transport. + +The TypeScript daemon session client exposes `listSources`, `upsertSource`, and +`removeSource`. These public methods use owner-routed REST even when the client +uses ACP for prompts, preserving one client authorization path. Internal child +methods described below are not an alternative public mutation transport. + +### Attachment metadata enrichment + +In the Web Shell session action, keep upload, prompt submission, admission +callbacks, optimistic messages, and rejected-upload cleanup unchanged. After +`submitPrompt` returns acceptance, launch a caught, independent registration +operation using the uploaded attachment IDs and original display names. + +- Capture the admitted session/client identity, attachment IDs, and owner guard. + Never obtain the destination from a later active-session selection. +- Do not await source registration before continuing stream handling or reporting + admission. Do not add source contents or source IDs to the prompt body. +- A definite prompt rejection or cancellation before acceptance registers nothing. + A model failure after acceptance does not remove the added references. +- Ambiguous admission follows existing prompt recovery. Do not register until + acceptance is confirmed, and never resubmit a prompt to repair source metadata. +- A registration failure leaves the sent message and visible uploaded file + intact. Show “Message sent; some source details could not be saved” with a + metadata-only Retry action. + On owner change, suppress stale UI callbacks; a request already sent remains + bound to the original session. +- Browser closure can lose this best-effort metadata enrichment. Retry state is + in memory only. The uploaded file remains visible and previewable without a + source record; do not scan/replay old messages to backfill metadata. +- Removing registered metadata does not cause a background history scan to add + it back. Existing uploaded bytes remain visible as a file. Explicit registration + or a newly accepted message may add metadata again. + +This boundary deliberately avoids server-side prompt admission changes and a +durable registration job queue. + +## Ownership, persistence, and notifications + +Use one small session-bound source service in the ACP child/core layer, where +the chat recording writer already lives. Both the tool and daemon-forwarded +mutations call this service. The bridge provides routing and notifications; it +does not maintain a second authoritative mutable source store. + +Proposed internal child methods are `qwen/session/sources/list`, +`qwen/session/sources/upsert`, and `qwen/session/sources/remove`, carried over the +existing authenticated daemon-to-child connection. Bind and validate their +session IDs like other internal session methods. External clients cannot use +these to bypass REST authorization or attachment ownership validation. + +Persist a versioned `session_sources_snapshot` system record containing the +complete bounded list and revision. With a maximum of 200 metadata entries, +one snapshot per actual mutation is simpler than a separate event journal, +tombstone index, database, and sidecar cache. Registration is explicit and +low-frequency; no-op retries do not append records. + +Start an empty service at revision zero. Serialize mutations through the session +service. Build and validate the next snapshot with revision N+1, append it +through the existing chat recording owner's strict append path (as used by +`recordSessionArtifactSnapshot`), then publish it as the live state. Durability +uses that writer's acknowledged-write guarantee, not a new power-loss guarantee. +A failed append changes neither +the live list nor the acknowledged revision. Deletion uses the same ordering; +do not show durable success for a removal that can reappear after restart. +If the append outcome is uncertain, reload the latest valid persisted snapshot +before accepting another mutation. Never acknowledge a volatile-only success. + +After commit, the child sends `qwen/notify/session/sources-changed` with session +ID and revision. The bridge exposes `source_changed` on the existing daemon +event stream with the same fields. This is a list invalidation signal, not a +chat message or artifact event. Clients fetch the authoritative list and ignore +older revisions and responses from stale session owners. Notification failure +does not undo a committed write; mutation responses and reconnect refresh repair +the display. Mark it as a known non-transcript event in SDK normalization and +reducers, so it cannot become a debug bubble in the conversation. + +### Lifecycle rules + +| Operation | Registered metadata behavior | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Refresh/reconnect | Fetch the current list after attachment to the session | +| Restart/load/resume | Restore the latest valid supported snapshot; no source records means an empty metadata list; uploaded files remain visible | +| History replay | Rebuild metadata only; never rerun registration tools or copy attachments | +| Compaction | Preserve the latest snapshot as session metadata, excluded from model context and summaries | +| Rewind | Keep the current reference list; if history is rewritten, carry its latest snapshot forward | +| Fork | Copy the current reference list as session metadata, independently of a turn cutoff; regenerate IDs for the target session | +| Archive | Keep metadata with the session; disable mutations under existing archive rules | +| Session deletion | Delete metadata through normal session storage deletion; external resources remain untouched | +| Remove reference | Persist the new list; leave original files, attachment bytes, and prior chat content intact | + +Fork uses existing attachment-copy/remap results for attachment locators. Keep +workspace-relative references only when the target has the same bound workspace +identity. Omit unmappable attachments or cross-workspace file references with a +source-specific warning; never guess paths or point at the parent's attachment +store. Links copy unchanged. A target source-snapshot write failure must be +reported as sources not copied and must not invalidate an otherwise successful +conversation fork. There is no promise of original file bytes: previews show +the currently accessible resource. + +Validate restored snapshots before use. A malformed last record or unsupported +version must not silently restore an older pre-removal list. Preserve the +transcript, mark registered metadata unavailable, and reject source writes until a supported +valid state can be restored. Conversation loading still proceeds. Restore/fork, +rewind, and compaction tests are required before advertising persistence support. + +## Web Shell interaction and preview + +Use `sources` in the default environment section list, after Environment and +before Subagents, and stop displaying a separate Attachments section. Keep the +public `attachments` customization value as a compatibility choice for hosts: +`attachments` alone shows uploaded files, `sources` shows the complete reference +view, and including both still produces one section. + +The `session_sources` capability gates registered metadata and Add actions. An +older daemon can still show its uploaded files through the existing attachment +capabilities. Source metadata and attachment-list loading/errors are independent; +one failed request must not hide successful results from the other store. + +- Header: “Sources” / “来源”, count, and an Add action. Show three rows initially. + Longer lists offer an accessible “View all” / “查看全部” button, which becomes + “Collapse” / “收起” when expanded and restores the three-row view. +- Rows show a file/link icon and a single-line, truncated title. The full locator + remains available in the hover title and preview details. Activating a row + opens its preview. Support keyboard focus, overflow truncation, and clear + accessible names. +- Empty state: “Add files or links for reference.” Explain in the Add form that + adding a reference does not send its contents to the assistant. +- Add supports a workspace-relative path or HTTP(S) link. Use existing primitives + and the portal root. Uploading stays in the composer; uploaded files already + appear in Sources and need no second picker or registration step. A title can + default to the filename/hostname and be edited before registration. +- Loading, failed load with Retry, and capability absence are separate states. + A failed refresh keeps the last same-owner list with a visible error. Initial + failure must not look like an empty successful list. + +Registered sources use a `source` right-panel tab keyed by session ID and source +ID. Plain uploaded files reuse existing attachment preview tabs, with HTML forced +to source-text rendering and that preview mode retained on tab restoration. +Both paths retain the session/workspace owner identity and locator. Resolve current capabilities at +use time, as described in +[artifact workspace ownership](./web-shell-artifact-workspace-ownership.md). +Invalidate pending loads and open tabs on owner replacement or trust loss. +Workspace-file previews must match the stored `workspaceCwd` to the session's +current bound workspace; mismatch shows an unavailable reference. Never rebase +the path onto a new cwd. Other references remain usable. A subsequent explicit +registration in the new workspace creates a distinct file source. + +Reuse file renderers through a narrow internal adapter or extraction where +needed. Do not generalize the entire artifact model or modify stable CSS merely +for consistency. Workspace files use scoped file actions; attachment bytes use +the existing session attachment endpoint and blob lifecycle. URL sources display +their metadata and an explicit Open original link, with no automatic fetch, +iframe, or link preview request. Source HTML defaults to source-text preview; +registering input HTML must not invoke artifact publishing or execute it. + +Reuse existing size limits and supported image/PDF/text previews. Unsupported +types offer the existing permitted download/open behavior. Missing files, +removed attachments, blocked access, and network errors appear in the detail +view with Retry where appropriate; they do not delete the reference. List +rendering does not stat every file or probe every URL. A source removed while +its tab is open closes that tab after successful refresh/mutation. + +## Implementation sequence and consumer checklist + +The implementation follows this sequence, with capability advertising after the +service, transport, and UI are connected: + +1. Core source types/service and snapshot validation; chat recording record + allowlists, restore, fork, rewind, and compaction handling. +2. Session-bound `record_source` tool and internal ACP handlers; bridge routing, + attachment validation, source event forwarding, and owner-aware REST routes. +3. TypeScript daemon request/response types, session client methods, event parser, + UI normalizer, and Web Shell daemon actions/provider event signals. +4. Web Shell source hook, environment section/customization, post-admission + registration with retry, and source preview tab with owner guards. +5. Focused regression tests, E2E evidence, documentation, then advertise + `session_sources` and expose the tool for supported sessions. + +The changes span core, CLI/ACP, bridge, TypeScript SDK, and Web Shell. Review must +cover all consumers above, including event-to-transcript conversion and session +lifecycle reconstruction. Python/Java SDKs, standalone CLI, other ACP clients, +and Desktop receive no new public source API in this phase; their existing +history readers must ignore the new metadata record safely. Existing artifact +records and APIs need no migration. Old sessions start with an empty metadata list while their uploaded files remain +visible; older daemons show files without source metadata actions. Do not fall back to artifact registration when +the source capability is absent or an operation fails. + +## Verification plan for implementation + +The acceptance matrix below defines the implementation checks. The global CLI +baseline, focused regression results, actual daemon and browser runs, and +self-audit notes are recorded locally under `.qwen/e2e-tests/session-sources.md`. +See the implementation notes for the tested boundaries. + +| Area | Required evidence | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Registration | File/link upsert, stable IDs, no-op revision, metadata edit, description clear, capacity, invalid locators and fields | +| Concurrency/durability | Simultaneous tool/client writes serialize; write failure retains old state; timeout/retry does not duplicate; restart after deletion does not resurrect | +| Lifecycle | Load, reconnect, compaction, rewind, fork attachment remap/cross-workspace omissions, archive rejection, malformed/future snapshots | +| Ownership | Primary and secondary sessions route correctly; unknown, untrusted, ambiguous, bootstrapping, draining, removed, and replaced owners never call primary operations | +| Attachments | Upload alone adds nothing; accepted message enriches file metadata; rejected submission adds nothing; registration failure/retry never resends the prompt; historical uploaded files stay visible without registration | +| Conversation isolation | Prompt content before/after registration is identical; no file read/network fetch during registration; no artifact added; source notifications produce no transcript bubble; model tool success requires persistence | +| UI | Empty/loading/error/long list; Add/open; keyboard and narrow layout; explicit host section configuration; capability absent | +| Preview | Workspace file, attachment image/PDF/text, URL Open original, source HTML text, unsupported type, revoked trust, stale response, missing resource, removed open tab | +| Compatibility | Older daemon shows uploaded files; old session metadata loads empty; existing artifacts and prompt flow remain unchanged; unrelated clients ignore metadata safely | + +The main tradeoff is deliberate: explicit registration and a small durable list +give a usable reference panel without a provenance engine. Automatic attachment +registration can be lost if the browser closes after admission; the initial +design makes that limitation visible and repairable without changing message +delivery or adding background infrastructure. diff --git a/docs/design/web-shell-session-sources.zh-CN.md b/docs/design/web-shell-session-sources.zh-CN.md new file mode 100644 index 00000000000..a1eee402fb4 --- /dev/null +++ b/docs/design/web-shell-session-sources.zh-CN.md @@ -0,0 +1,240 @@ +# Web Shell 会话来源设计 + +状态:本分支已实现;本地验收范围见[实施记录](../plans/web-shell-session-sources.md)。 + +[English version](./web-shell-session-sources.md) + +## 设计决策与范围 + +将会话中上传的文件、工作区文件引用和链接统一展示在“来源”分区。界面合并已有附件存储与显式来源元数据,同一上传文件只显示一次。资料在列表中不表示模型已经读取、引用或使用它。参考输入与 artifact(产物)分开,复用现有预览和工作区归属检查。 + +第一阶段提供: + +- `record_source` 工具,用于显式登记文件或链接。 +- 会话 API,用于查询、新增或更新、移除引用。 +- Web Shell 消息提交被接受后,尽力补充附件的来源元数据。 +- 元数据持久化、去重,以及环境面板中的“来源”分区;预览在现有右侧面板中打开。 + +登记只保存元数据,不读取或复制文件、不获取 URL 内容、不发布内容、不把资源内容加入 prompt,也不改变权限。显式工具调用仍会把正常的工具确认结果加入对话。因此,新增工具会改变可用工具的 schema,但不会改变消息准入、调度或模型上下文构建流程。 + +本阶段不包含 MCP/API 连接管理、自动收集文件读取或搜索结果、使用状态追踪、引用关系图、hook 登记入口、通用 `ToolResult.sources`、跨会话来源库或内容留存。Desktop 的工作区连接注册机制属于另一项功能。 + +## 当前实现与复用边界 + +本方案于 2026-09-07 基于 `main` 的 `fb12a6e7fe` 核对。 + +| 现有接入点 | 相关行为 | 来源机制的设计决策 | +| ---------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------ | +| [record-artifact.ts](../../packages/core/src/tools/record-artifact.ts) | 通过 `ToolResult.artifacts` 返回产物元数据 | 复用命名和输入约定,不为来源生成产物记录 | +| [sessionArtifacts.ts](../../packages/acp-bridge/src/sessionArtifacts.ts) | 管理产物记录、持久化协调和内容状态 | 保留现有产物行为,不给所有产物增加来源角色 | +| [session routes](../../packages/cli/src/serve/routes/session.ts) | 按会话归属路由的产物和附件 API | 沿用会话归属与客户端授权边界 | +| [sessionAttachments.ts](../../packages/acp-bridge/src/sessionAttachments.ts) | 管理上传附件的字节内容和引用 | 引用已有附件 ID,不新增上传存储 | +| [session actions](../../packages/web-shell/client/daemon/session/actions.ts) | 上传附件,然后取得消息准入结果 | 通过独立元数据请求登记已接受消息的附件引用 | +| [EnvironmentPanel.tsx](../../packages/web-shell/client/components/panels/EnvironmentPanel.tsx) | 展示环境、子 Agent 和后台任务分区 | 增加可配置的 `sources` 分区 | +| [ArtifactPanel.tsx](../../packages/web-shell/client/components/artifacts/ArtifactPanel.tsx) | 文件预览和右侧详情展示 | 通过来源标签页复用相关渲染器和归属解析器 | + +现有 artifact 的 `source: tool | hook | client` 字段描述登记方,不是会话参考资料实体。artifact 和 source 可以指向同一个工作区文件,但拥有独立的 ID 和移除行为。来源预览不能创建隐藏的产物记录,也不能往对话记录中添加产物卡片。 + +## 统一视图与登记元数据 + +附件存储继续持久化上传文件的字节内容。“来源”直接展示这些文件,包括没有来源登记记录的历史附件。来源 API 继续管理显式引用元数据。界面分别接收真实来源记录与附件引用,不为上传文件虚构来源 ID 或时间戳。 + +按 attachment ID 去重。附件有登记记录时优先使用其标题和描述,否则展示已有文件名。工作区引用与链接保持独立,即使名称相同也不合并。读取、刷新和迁移都不自动补登记历史附件。 + +来源 API 的 200 条及字段长度限制只约束登记元数据,不限制统一视图中已有上传文件的数量或文件名长度。列表保持各自存储的顺序,不虚构统一创建时间。 + +取消附件的来源登记会从来源 API 移除该元数据及 source ID,不删除字节;文件仍以普通上传文件显示。打开或刷新文件不会重新登记,也不会增加 metadata revision。移除工作区文件或链接引用会移除对应显式条目。统一列表不提供逐行关闭按钮。 + +## 登记元数据的数据契约 + +来源 API 的公开类型: + +```ts +type SessionSourceLocator = + | { type: 'workspace_file'; workspacePath: string } + | { type: 'attachment'; attachmentId: string } + | { type: 'url'; url: string }; + +interface SessionSourceInput { + title: string; + locator: SessionSourceLocator; + description?: string; +} + +interface SessionSource extends SessionSourceInput { + id: string; + kind: 'file' | 'link'; + workspaceCwd?: string; + createdAt: string; + updatedAt: string; +} + +interface SessionSourcesSnapshot { + version: 1; + revision: number; + sources: SessionSource[]; +} +``` + +`kind`、ID、时间戳和 `workspaceCwd` 由服务端生成,调用方不能设置。持久化的工作区文件来源必须包含 `workspaceCwd`,其他类型不包含该字段。它记录登记时所属的工作区,避免会话后续切换 cwd 时静默改变引用目标。 + +MIME 类型、字节大小和资源可用性在打开预览时,由现有文件或附件解析器提供,不作为登记时的事实声明。本阶段不需要可扩展元数据包、留存标记、“已使用”状态、逐轮使用历史或调用方传入的工作区身份。 + +### 校验与身份 + +- 只接受一种 locator 变体及其必填字段,拒绝未知输入字段。去掉标题和描述首尾空白,拒绝空标题和控制字符。标题最多 200 字符,描述最多 1,000 字符,工作区路径最多 500 字符,附件 ID 最多 200 字符,URL 最多 2,048 字符。 +- 工作区路径相对于会话绑定的工作区。规范化分隔符和 `.` 路径段,拒绝绝对路径、越出根目录的路径和 NUL 字符。登记只做路径文本校验;预览沿用现有文件访问和信任检查,包括符号链接解析后的目录边界检查。 +- 登记时,附件 ID 必须对应同一会话中已存在的附件。daemon 在转发写操作前完成检查。工具只接受工作区文件和 URL,因此不能绕过该检查。 +- URL 必须能解析为 HTTP(S),包含主机名,且不含嵌入式凭据。不请求 URL,也不通过重定向推断规范地址。保留查询参数和片段标识,不同文档章节仍视为不同来源。持久化的 URL 和标题沿用现有对话记录隐私处理,不新增来源原始载荷日志。 +- 去重键由会话 ID、locator 类型、规范化后的 locator 组成;工作区文件还包括服务端生成的工作区 cwd。使用确定性摘要生成不透明的来源 ID。保留路径大小写,不解析符号链接或计算文件内容哈希来去重。 +- 对同一 locator 执行 upsert 时,保留 ID 和 `createdAt`。标题或描述变化时更新 `updatedAt`;省略描述表示保留旧值,空描述表示清除。完全相同的输入不产生变更。 +- 名称相似的 URL 和文件仍是不同来源。附件 ID 不同的上传内容,即使文件名相同,也视为不同来源。 +- 列表最多 200 条。达到上限后仍可更新已有记录,新增记录返回 `409 source_limit_reached`。不能静默淘汰用户预期稍后还能找到的条目。 + +列表顺序保持稳定:先按 `createdAt` 从新到旧排序,再按 ID 排序。编辑元数据不改变列表位置。 + +## 登记与 API 行为 + +### Agent 工具 + +`record_source` 接受 `title`、可选的 `description`,以及 `workspace_file` 或 `url` 类型的 locator。工具说明应明确:调用是在添加引用,不是在证明资源已被使用;新生成的交付物应使用 `record_artifact`。 + +示例: + +```json +{ + "title": "Project requirements", + "locator": { + "type": "workspace_file", + "workspacePath": "docs/requirements.md" + } +} +``` + +只为已经绑定来源服务的顶层 daemon ACP 会话注册该工具,遵循现有工具允许/禁止规则。本阶段不向独立 CLI、仅 SDK 模式或子 Agent 暴露该工具;不引入全局服务,也不复用 artifact 启用开关。服务绑定必须在工具发现前建立,并在会话加载或替换时正确刷新。 + +工具直接调用所属会话的来源服务,只有持久化成功后才返回包含来源 ID 的简短确认。校验或持久化失败时返回正常的工具错误。不能仅为支持一个显式登记工具,就给所有工具结果、工具调度器、hook 结果和历史回放路径增加新的元数据字段。 + +### 会话 HTTP API + +| 方法与路径 | 输入 | 成功结果 | +| --------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- | +| `GET /session/:id/sources` | 无 | `200 { revision, sources }` | +| `POST /session/:id/sources` | 一个 `SessionSourceInput` | `200 { revision, source, change }`,其中 change 为 `created`、`updated` 或 `unchanged` | +| `DELETE /session/:id/sources/:sourceId` | 无 | `200 { revision, removed }`;ID 已不存在时返回 `removed: false` | + +不提供 PATCH 接口,POST 即元数据 upsert。第一阶段不需要批量接口;附件自动登记逐条提交少量引用,按条目报告失败。 + +三个路由均属于 **live-session-owner 作用域,即按活动会话所属运行时路由**。读取使用 `withOwnerReadSession`,写入使用 `withOwnerMutableSession` 和现有严格写操作准入检查。写操作要求有效且绑定当前会话的 client ID;读取保留现有会话读取授权规则。在访问 bridge、服务、文件或附件前,必须解析出唯一且受信任的所属运行时。 + +复用现有归属错误:未知会话、不受信任的工作区、归属歧义,以及运行时处于 bootstrapping、draining、removed 状态时,遵循现有路由辅助函数的语义。绝不能回退到 primary runtime。只有持久化记录、尚未加载的会话,必须先通过现有生命周期恢复,才能调用这些接口。对已归档会话的写操作遵循共享归档协调器的拒绝规则。 + +来源元数据操作允许在 prompt 执行期间运行。它们与其他来源写操作及记录写入器串行协调,不与整个模型轮次串行执行。不能要求 prompt 空闲,也不能持有 prompt 调度锁。 + +无效输入返回 `400 invalid_source`;持久化不可用返回 `503 source_persistence_unavailable`。登记时附件不存在则返回 `404`,不暴露其他会话的附件信息。写操作超时表示结果未知:重新读取列表,或重试同一个 upsert/delete。不能换一个所属运行时或传输路径重试。 + +TypeScript daemon 会话客户端提供 `listSources`、`upsertSource` 和 `removeSource`。即使客户端通过 ACP 发送 prompt,这些公开方法也使用按会话归属路由的 REST,以保留唯一的客户端授权路径。下文的内部 child 方法不作为另一条公开写入通道。 + +### 附件元数据补充 + +Web Shell 会话 action 保持上传、消息提交、准入回调、乐观消息展示和拒绝后的上传清理行为不变。在 `submitPrompt` 返回接受结果后,使用已上传附件 ID 和原始展示名称,启动独立且捕获错误的登记操作。 + +- 捕获已接受消息所属的 session/client 身份、附件 ID 和 owner guard,不从之后切换到的活动会话中获取目标。 +- 继续处理流式响应或报告消息已接受前,不等待来源登记完成。不把来源内容或来源 ID 加入 prompt 请求体。 +- 消息明确被拒绝,或在被接受前取消时,不登记任何来源。消息被接受后,即使模型执行失败,也不移除已添加的引用。 +- 接受状态不确定时,沿用现有消息恢复逻辑。确认消息被接受后才能登记,绝不能为修复来源元数据而重发 prompt。 +- 登记失败不影响已发送的消息和已显示的上传文件。提示“消息已发送,但部分来源信息未能保存”,提供仅重试元数据操作的“重试”按钮。会话归属变化时,抑制过期 UI 回调;已经发出的请求仍绑定原会话。 +- 浏览器关闭可能丢失这次尽力执行的元数据补充,重试状态仅保存在内存中。没有登记记录的上传文件仍可见并能预览,不扫描或回放旧消息来补登记。 +- 移除登记元数据后,不通过后台历史扫描将它重新加入。已有上传文件仍以文件形式显示;显式登记或新接受的消息可以再次补充元数据。 + +这一边界避免修改服务端消息准入逻辑,也避免引入持久化登记任务队列。 + +## 归属、持久化与通知 + +在 ACP child/core 层放置一个小型、绑定会话的来源服务;该层已经拥有对话记录写入器。工具和 daemon 转发的写操作都调用该服务。bridge 负责路由和通知,不再维护第二份具有权威写入能力的来源存储。 + +拟议的内部 child 方法为 `qwen/session/sources/list`、`qwen/session/sources/upsert` 和 `qwen/session/sources/remove`,通过现有受认证的 daemon-to-child 连接调用。像其他内部会话方法一样绑定并校验 session ID。外部客户端不能借此绕过 REST 授权或附件归属校验。 + +使用带版本号的 `session_sources_snapshot` system record,持久化完整的有界列表和 revision。最多 200 条元数据,每次实际变更写入一个快照,比另建事件日志、墓碑索引、数据库和 sidecar 缓存更简单。登记操作是显式且低频的;无变更的重试不追加记录。 + +空服务从 revision 0 开始。通过会话服务串行执行写操作:构造并校验 revision 为 N+1 的快照,通过现有对话记录写入器的严格追加路径写入(与 `recordSessionArtifactSnapshot` 相同),成功后再发布为内存中的当前状态。持久化保证沿用该写入器的“已确认写入”保证,不新增断电持久性承诺。 + +追加失败时,内存列表和已确认的 revision 均不改变。删除遵循相同顺序;不能对可能在重启后重新出现的移除操作报告持久化成功。如果追加结果不确定,必须先重新加载最近有效的持久化快照,再接受下一次写操作。不能仅写入内存就返回成功。 + +提交后,child 发送 `qwen/notify/session/sources-changed`,包含 session ID 和 revision。bridge 在现有 daemon 事件流中发布包含相同字段的 `source_changed`。该事件只通知列表需要刷新,不是聊天消息,也不是产物事件。客户端获取权威列表,并忽略旧 revision 和来自过期会话归属的响应。 + +通知失败不会回滚已提交的写入;写操作响应和重连刷新可以修复展示状态。SDK normalizer 和 reducer 必须将其识别为已知的非对话事件,避免在对话中生成调试气泡。 + +### 生命周期规则 + +| 操作 | 登记元数据行为 | +| -------------- | ------------------------------------------------------------------------------- | +| 刷新/重连 | 连接到会话后获取当前列表 | +| 重启/加载/恢复 | 恢复最近有效且版本受支持的快照;没有来源记录时元数据列表为空,上传文件仍可见 | +| 历史回放 | 只重建元数据,不重新执行登记工具或复制附件 | +| 上下文压缩 | 将最新快照作为会话元数据保留,不加入模型上下文或摘要 | +| 回退 | 保留当前参考资料清单;如果改写历史,需把最新快照带入新历史 | +| 分叉 | 将当前参考资料清单作为会话元数据复制,不受轮次截断点影响;为目标会话重新生成 ID | +| 归档 | 随会话保留元数据,按现有归档规则禁止写入 | +| 删除会话 | 通过正常会话存储删除流程移除元数据,不改变外部资源 | +| 移除引用 | 持久化新列表,保留原文件、附件字节和已有聊天内容 | + +分叉时,附件 locator 使用现有附件复制和 ID 映射结果。工作区相对路径引用只有在目标绑定相同工作区身份时才保留。对无法映射的附件或跨工作区文件引用,应省略该来源并给出来源相关警告;不能猜测路径,也不能指向父会话的附件存储。链接原样复制。 + +目标来源快照写入失败时,必须报告来源未复制成功,但不能让已经成功的对话分叉失效。本方案不保证保留原始文件字节,预览展示当前可访问的资源。 + +恢复的快照必须先校验再使用。最后一条记录损坏或版本不受支持时,不能静默回退到移除操作之前的旧列表。应保留对话记录,将登记元数据标记为不可用,并拒绝来源写入,直到能够恢复受支持的有效状态。对话加载仍继续。对外声明持久化能力前,必须完成恢复/分叉、回退和压缩测试。 + +## Web Shell 交互与预览 + +默认环境分区列表使用 `sources`,放在环境信息之后、子 Agent 之前,不再单独展示附件区。保留公开的 `attachments` 定制值兼容宿主:仅配置 `attachments` 时展示上传文件,配置 `sources` 时展示完整参考资料,两者同时配置也只渲染一个分区。 + +`session_sources` capability 控制登记元数据和添加操作。旧 daemon 仍可通过已有附件能力展示上传文件。来源元数据与附件列表分别管理加载和错误,任一请求失败都不能隐藏另一侧已成功读取的资料。 + +- 分区标题:“Sources” / “来源”,展示数量和添加入口。初始显示三条,超过三条时展示可访问的“View all” / “查看全部”按钮,展开后变为“Collapse” / “收起”,再次点击恢复三条。 +- 列表项统一展示文件/链接图标和单行截断标题,完整定位信息保留在悬浮提示和预览详情中,点击整行打开预览,支持键盘焦点、溢出截断和清晰的无障碍名称。 +- 空状态:“添加文件或链接作为参考资料。”在添加表单说明:添加引用不会把内容发送给助手。 +- 添加支持工作区相对路径或 HTTP(S) 链接,使用现有 UI 组件和 portal root。上传仍通过输入框;上传文件已经在来源中显示,不需要第二个选择器或登记步骤。标题可默认取文件名或主机名,登记前允许编辑。 +- 加载中、加载失败并可重试、能力不存在,是三种不同状态。刷新失败时保留同一会话归属下最近成功的列表,并展示错误。首次加载失败不能伪装成成功加载的空列表。 + +登记来源使用以 session ID 和 source ID 为键的 `source` 右侧标签页。普通上传文件复用附件预览标签页,HTML 强制按源码文本预览,恢复标签页时保留这一模式。两条路径都携带会话/工作区归属身份和 locator。按[产物工作区归属设计](./web-shell-artifact-workspace-ownership.md),在实际使用时根据当前 capability 解析归属。所属运行时被替换或失去信任时,让待完成的加载请求和已打开标签页失效。 + +工作区文件预览必须确认持久化的 `workspaceCwd` 与会话当前绑定工作区一致;不一致时展示引用不可用。绝不能把路径重新解释为新 cwd 下的文件。其他引用仍可使用。之后在新工作区显式登记同一路径时,创建独立的文件来源。 + +按需通过小范围的内部适配或提取复用文件渲染器,不泛化整个 artifact 模型,也不单为一致性重写稳定 CSS。工作区文件使用有明确工作区作用域的文件操作;附件字节通过现有会话附件接口读取,并沿用 blob 生命周期。 + +URL 来源展示元数据和显式的“打开原文”链接,不自动请求、不嵌入 iframe,也不请求链接预览。来源 HTML 默认展示源码文本;登记输入 HTML 不能触发产物发布或执行其中的代码。 + +复用现有大小限制及图片、PDF、文本预览。不支持的类型提供现有权限允许的下载/打开行为。文件缺失、附件被移除、访问被阻止和网络错误在详情中展示,并在适用时提供重试,不删除引用。渲染列表时不逐个 stat 文件,也不探测所有 URL。某个来源被移除后,如果其标签页已打开,应在成功刷新或写操作后关闭该标签页。 + +## 实施顺序与消费方清单 + +实现按以下顺序推进,在服务、传输和界面接通后声明 capability: + +1. Core 来源类型、服务和快照校验;对话记录类型白名单,以及恢复、分叉、回退、压缩处理。 +2. 绑定会话的 `record_source` 工具和内部 ACP handler;bridge 路由、附件校验、来源事件转发,以及按会话归属路由的 REST 接口。 +3. TypeScript daemon 请求/响应类型、会话客户端方法、事件解析器、UI normalizer,以及 Web Shell daemon action/provider 事件信号。 +4. Web Shell 来源 hook、环境分区和定制选项、消息被接受后的登记与重试,以及带 owner guard 的来源预览标签页。 +5. 定向回归测试、E2E 证据和文档,最后声明 `session_sources`,并为支持的会话暴露工具。 + +改动涉及 core、CLI/ACP、bridge、TypeScript SDK 和 Web Shell。审阅必须覆盖上述所有消费方,包括事件转对话记录和会话生命周期重建。本阶段不为 Python/Java SDK、独立 CLI、其他 ACP 客户端或 Desktop 增加公开来源 API;它们现有的历史读取逻辑必须能够安全忽略新的元数据记录。 + +现有 artifact 记录和 API 无须迁移。旧会话的登记元数据从空列表开始,上传文件仍可见;旧 daemon 展示文件但不提供来源元数据操作。当来源 capability 不存在或操作失败时,不能回退到产物登记。 + +## 实施验收计划 + +以下矩阵定义实施验收标准。全局 CLI 基线、定向回归、实际 daemon 和浏览器验收及自审记录保存在本地 `.qwen/e2e-tests/session-sources.md`,已验证范围见实施记录。 + +| 范围 | 必需证据 | +| ------------ | ----------------------------------------------------------------------------------------------------------------------- | +| 登记 | 文件/链接 upsert、稳定 ID、无变更时 revision 不变、元数据编辑、清除描述、数量上限、非法 locator 和字段 | +| 并发与持久化 | 工具/客户端同时写入时串行处理;写入失败保留旧状态;超时重试不重复;删除后重启不复活 | +| 生命周期 | 加载、重连、压缩、回退、分叉时附件映射和跨工作区省略、归档拒绝、损坏或未来版本快照 | +| 归属 | 主/次工作区会话正确路由;未知、不受信任、歧义、bootstrapping、draining、removed 或被替换的归属均不调用 primary 操作 | +| 附件 | 仅上传不登记;消息被接受后登记;拒绝提交不登记;登记失败或重试不重发 prompt;历史上传文件无需登记即可展示 | +| 对话隔离 | 登记前后 prompt 内容一致;登记不读取文件或发起网络请求;不新增 artifact;来源通知不产生聊天气泡;工具成功依赖持久化成功 | +| UI | 空状态、加载、错误、长列表;添加/打开;键盘和窄屏布局;宿主显式分区配置;capability 缺失 | +| 预览 | 工作区文件、附件图片/PDF/文本、URL 打开原文、HTML 源码文本、不支持类型、信任撤销、过期响应、资源缺失、移除已打开条目 | +| 兼容性 | 旧 daemon 展示上传文件;旧会话登记元数据为空;现有 artifact 和消息流程不变;其他客户端安全忽略元数据 | + +主要取舍是:通过显式登记和小型持久化列表提供可用的参考资料面板,不建立来源使用追踪引擎。浏览器在消息被接受后关闭,可能丢失附件自动登记;第一版让这一限制可见且可修复,无须修改消息投递或增加后台基础设施。 diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 37ab7fde370..b120e4fab6a 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -572,6 +572,7 @@ operator diagnostic snapshot documented below. | `standalone_sessions_v1` | the daemon has installed the complete standalone-session runtime, lifecycle coordinator, durable deletion journal, managed-directory implementation, and `/standalone/sessions` route family. Direct embeds without the complete dependency graph omit both the routes and this tag. | | `standalone_session_options_v1` | the complete standalone-session runtime is installed (same condition as `standalone_sessions_v1`), so the read-only, sessionless `GET /standalone/session-options` route is registered on the internal Conversations runtime. | | `session_artifacts_persistence` | session artifact persistence is wired for the runtime. | +| `session_sources` | session source persistence is wired for the runtime. Registers metadata-only workspace files, uploaded attachments, and HTTP(S) links through the live session owner. | | `session_generation` | session generation helpers are available. | | `scheduled_task_session_reuse` | durable scheduled-task session management is active and every managed daemon runtime has installed the callback that lets a task explicitly bind to its current existing session. | | `workspace_generation` | workspace-scoped generation helpers are available. | @@ -2726,7 +2727,7 @@ ACP-over-HTTP uses the same request and response bodies through vendor methods ` ### Multi-workspace live-session routing -When `multi_workspace_sessions` is advertised, live-session operations identify their workspace from the `sessionId`; clients do not add a workspace selector to the URL. In addition to the existing owner-routed lifecycle operations, this applies to `PATCH /session/:id/metadata`, `POST /session/:id/recap`, `POST /session/:id/generate`, `POST /session/:id/btw`, `POST /session/:id/mid-turn-message`, `GET /session/:id/mid-turn-messages`, `DELETE /session/:id/mid-turn-messages/:messageId`, `POST /session/:id/tasks/:taskId/cancel`, `POST /session/:id/goal/clear`, `POST /session/:id/continue`, `POST /session/:id/language`, `POST /session/:id/artifacts`, and `DELETE /session/:id/artifacts/:artifactId`. The daemon routes each request to the trusted runtime that owns the live session. An untrusted non-primary owner returns `403 untrusted_workspace`, a missing live owner returns `404 session_not_found`, and an ambiguous owner fails closed with `500 ambiguous_session_owner`. +When `multi_workspace_sessions` is advertised, live-session operations identify their workspace from the `sessionId`; clients do not add a workspace selector to the URL. In addition to the existing owner-routed lifecycle operations, this applies to `PATCH /session/:id/metadata`, `POST /session/:id/recap`, `POST /session/:id/generate`, `POST /session/:id/btw`, `POST /session/:id/mid-turn-message`, `GET /session/:id/mid-turn-messages`, `DELETE /session/:id/mid-turn-messages/:messageId`, `POST /session/:id/tasks/:taskId/cancel`, `POST /session/:id/goal/clear`, `POST /session/:id/continue`, `POST /session/:id/language`, `POST /session/:id/artifacts`, `DELETE /session/:id/artifacts/:artifactId`, `GET /session/:id/sources`, `POST /session/:id/sources`, and `DELETE /session/:id/sources/:sourceId`. The daemon routes each request to the trusted runtime that owns the live session. An untrusted non-primary owner returns `403 untrusted_workspace`, a missing live owner returns `404 session_not_found`, and an ambiguous owner fails closed with `500 ambiguous_session_owner`. This rule is live-session-only and does not make every workspace-less session route multi-workspace-aware. Persisted or archived operations use their documented workspace-qualified routes. `POST /session/:id/branch`, `POST /session/:id/fork`, and `POST /session/:id/cd` intentionally remain primary-only and return `non_primary_session_route_not_supported` for non-primary owners. diff --git a/docs/plans/web-shell-session-sources.md b/docs/plans/web-shell-session-sources.md new file mode 100644 index 00000000000..a9784825d6f --- /dev/null +++ b/docs/plans/web-shell-session-sources.md @@ -0,0 +1,29 @@ +# Web Shell session sources implementation + +The session source list follows the [design contract](../design/web-shell-session-sources.md). The feature is implemented on `codex/session-sources-design` for local acceptance of PR #11262. + +## Implementation + +The core owns one durable source service per daemon session. Both `record_source` and owner-routed HTTP mutations use this service. Snapshot writes are acknowledged before state changes; invalid or incomplete persisted data makes sources unavailable without preventing conversation loading. Source records remain outside prompt context, compaction summaries, and the active turn branch. + +The bridge validates a session-bound client and attachment existence before forwarding mutations to the owning child. It checks the captured owner again after asynchronous attachment validation. Internal source errors use a private result envelope so the ACP transport does not log the original source payload. Public HTTP errors retain the design's status codes. + +Daemon forks copy the current list after attachment copying, regenerate source IDs, and omit resources that cannot be mapped. Source-copy warnings are visible for both conversation forks and side tasks. The source copy waits for the temporary target writer to close before restoring the target session. + +The TypeScript session client always uses REST for source operations, including when prompts use ACP. Web Shell provides one Sources section combining uploaded files and explicit references, Add/open controls, optional attachment metadata enrichment after acceptance, and metadata-only retry. It deduplicates by attachment ID, preserves historical files without backfilling metadata, and uses the existing guarded preview paths. The standalone Web Shell entry point enables the section; an embedding host's explicit section choices remain authoritative. HTML sources render as text, and link sources require explicit navigation. + +## Acceptance and boundaries + +Local evidence is kept in `.qwen/e2e-tests/session-sources.md` and its adjacent `session-sources-evidence` directory. It includes the global CLI baseline, package test logs, real daemon HTTP/SSE and recording evidence, browser interaction results, and screenshots. The model endpoint is a deterministic localhost fixture with a test-only credential; the CLI, daemon, source tool, storage, and Web Shell are real local builds. No external model endpoint is part of this acceptance run. + +The acceptance covers registration, concurrency, capacity, client/owner rejection, persistence across restart, rewind, compaction, daemon fork and attachment copying, archive rejection, source notifications, preview behavior, and attachment retry without message resubmission. Final build, typecheck, focused tests, formatting, and lint results are recorded with the local evidence. + +A recording writer that enters its existing degraded state after an I/O failure continues to reject writes. Restoring filesystem permissions alone does not replace that writer; resume/restart restores the last acknowledged source list before retrying. The feature does not bypass writer ownership or acknowledge memory-only changes. + +This phase adds source APIs and complete source copying to daemon/Web Shell sessions. Standalone CLI, external ACP clients, and Python/Java SDKs receive no new source API. Cross-client source copying through standalone CLI `/branch` is outside this phase. Automatic attachment registration remains best-effort and its retry queue is held in browser memory, as specified in the design. + +## Unified uploaded files and sources + +The follow-up combines the previous Attachments and Sources sections into a single user-facing collection. Attachment bytes and explicit reference metadata retain their existing APIs. Registered attachment metadata supplies a preferred title; an unregistered upload stays visible as a plain file. Removing only its registration never recreates that source record, and does not imply deletion of the uploaded file. No source IDs or timestamps are fabricated for attachments. + +The default entry point enables `sources` once. Legacy host `attachments` configuration still displays files; older daemons expose their file list without unsupported metadata actions. Add no longer contains a redundant existing-attachment picker. HTML from either registered or plain uploaded files opens as text. Independent failures preserve whichever side of the unified collection is available. Expanded acceptance is recorded under `.qwen/e2e-tests/unified-session-sources.md`. diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index ebe1aa87779..98938a064d1 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -362,6 +362,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_events', 'session_artifacts', 'session_artifacts_persistence', + 'session_sources', 'slow_client_warning', 'typed_event_schema', 'session_set_model', diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 822544a64f1..c1fc28cb948 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -3193,6 +3193,11 @@ describe('createAcpSessionBridge', () => { ); await bridge.releaseManagedConversationBinding(sessionId, expectation); expect(artifactUpsertWorkspaceRoots).toEqual([]); + await bridge.getSessionSources(sessionId); + expect(handle.agent.extMethodCalls).toContainEqual({ + method: 'qwen/session/sources/list', + params: { sessionId }, + }); const deferredArtifactId = stableSessionArtifactId( sessionId, 'url:https://example.com/deferred-artifact', diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 75bc5dfbdbe..e14e73449a3 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -44,6 +44,8 @@ import { TURN_RESULT_CODE_TEXT_TRUNCATED, TURN_RESULT_TEXT_MAX_CHARS, TrustGateError, + SessionSourceError, + validateSessionSourceInput, canonicalSessionPrUrl, toSessionPrInfo, normalizeTurnResultError, @@ -1989,6 +1991,7 @@ const REFRESH_APPEND_BOOKKEEPING_EVENT_TYPES = new Set([ 'session_metadata_updated', 'session_cwd_changed', 'artifact_changed', + 'source_changed', 'settings_changed', 'extensions_changed', 'mcp_server_changed', @@ -6719,6 +6722,25 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return response as unknown as T; }; + const requestSessionSources = async ( + sessionId: string, + method: string, + params: Record = {}, + ): Promise => { + const result = await requestSessionStatus< + T & { + sourceError?: { code: SessionSourceError['code']; message: string }; + } + >(sessionId, method, params); + if (result.sourceError) { + throw new SessionSourceError( + result.sourceError.code, + result.sourceError.message, + ); + } + return result; + }; + const notifyAgentSessionClose = async ( entry: SessionEntry, ci: ChannelInfo | undefined, @@ -11279,6 +11301,32 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // before any restore attempt so a committed branch is visible to // catalog-version watchers even when the restore later fails. markSessionCatalogChanged(); + const sourceWarnings: string[] = []; + const copySources = async (attachments?: SessionAttachmentStore) => { + try { + const attachmentIds = attachments + ? (await attachments.list()).map((item) => item.attachmentId) + : []; + // Let the child release the target writer before restore, even + // if copying sources exceeds the normal request timeout. + const copied = (await Promise.race([ + entry.connection.extMethod('qwen/session/sources/copy', { + sessionId, + targetSessionId: result.newSessionId, + targetCwd: boundWorkspace, + attachmentIds, + }), + getTransportClosedReject(entry), + ])) as { warnings?: string[]; sourceError?: unknown }; + if (copied.sourceError) { + sourceWarnings.push('Session sources could not be copied.'); + } else { + sourceWarnings.push(...(copied.warnings ?? [])); + } + } catch { + sourceWarnings.push('Session sources could not be copied.'); + } + }; if (opts.sessionAttachmentsRoot) { const branchAttachments = new SessionAttachmentStore( opts.sessionAttachmentsRoot, @@ -11292,8 +11340,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { `qwen serve: failed to copy attachments for branched session ${result.newSessionId}: ${error instanceof Error ? error.message : String(error)}`, ); } finally { + await copySources(branchAttachments); await branchAttachments.close(); } + } else if (!restoreBranch) { + await copySources(); } const rawBranchName = result.displayName ?? result.title; const branchDisplayName = @@ -11303,6 +11354,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (!restoreBranch) { return { + ...(sourceWarnings.length > 0 ? { sourceWarnings } : {}), sessionId: result.newSessionId, displayName: branchDisplayName, forkedFrom: { @@ -11385,6 +11437,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); } } + if (!opts.sessionAttachmentsRoot) { + await copySources(newEntry?.attachments); + } if (newEntry) newEntry.displayName = branchDisplayName; let sourcePersisted: boolean | undefined; if (newEntry?.sourceType) { @@ -11418,6 +11473,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return { ...restored, + ...(sourceWarnings.length > 0 ? { sourceWarnings } : {}), displayName: branchDisplayName, forkedFrom: { sessionId, @@ -12096,6 +12152,81 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } }, + async getSessionSources(sessionId, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + if ( + isReservedStandaloneSessionSourceType(entry.sourceType) && + entry.managedConversationBinding?.released !== true + ) { + throw standaloneWorkingDirectoryMissingError(); + } + resolveTrustedClientId(entry, context?.clientId); + return requestSessionSources(sessionId, 'qwen/session/sources/list'); + }, + + async upsertSessionSource(sessionId, input, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + if ( + isReservedStandaloneSessionSourceType(entry.sourceType) && + entry.managedConversationBinding?.released !== true + ) { + throw standaloneWorkingDirectoryMissingError(); + } + const clientId = resolveTrustedClientId(entry, context.clientId); + if (!clientId) { + throw new RequestError( + -32602, + 'A session-bound client id is required', + { + errorKind: 'client_id_required', + }, + ); + } + const validated = validateSessionSourceInput(input); + if (validated.locator.type === 'attachment') { + const attachmentId = validated.locator.attachmentId; + const attachments = await entry.attachments.list(); + if (byId.get(sessionId) !== entry) { + throw new SessionNotFoundError(sessionId); + } + resolveTrustedClientId(entry, context.clientId); + if (!attachments.some((item) => item.attachmentId === attachmentId)) { + throw new RequestError(-32602, 'Session attachment not found', { + errorKind: 'source_attachment_not_found', + }); + } + } + return requestSessionSources(sessionId, 'qwen/session/sources/upsert', { + input: validated, + }); + }, + + async removeSessionSource(sessionId, sourceId, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + if ( + isReservedStandaloneSessionSourceType(entry.sourceType) && + entry.managedConversationBinding?.released !== true + ) { + throw standaloneWorkingDirectoryMissingError(); + } + const clientId = resolveTrustedClientId(entry, context.clientId); + if (!clientId) { + throw new RequestError( + -32602, + 'A session-bound client id is required', + { + errorKind: 'client_id_required', + }, + ); + } + return requestSessionSources(sessionId, 'qwen/session/sources/remove', { + sourceId, + }); + }, + async getSessionArtifacts(sessionId, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index 5b6f9fcbfc3..c43ed814af6 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -2210,6 +2210,25 @@ export class BridgeClient implements Client { ) { return; } + if (method === 'qwen/notify/session/sources-changed') { + const sessionId = params['sessionId']; + const revision = params['revision']; + if ( + typeof sessionId !== 'string' || + typeof revision !== 'number' || + !Number.isSafeInteger(revision) || + revision < 0 || + !this.ownsSession(sessionId) + ) + return; + const entry = this.resolveEntry(sessionId); + if (!entry) return; + entry.events.publish({ + type: 'source_changed', + data: { sessionId, revision }, + }); + return; + } if (method === ACTIVE_WORK_NOTIFICATION_METHOD) { const snapshot = parseActiveWorkSnapshot(params); if (snapshot) { diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 3d622340158..461dc08d243 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -10,6 +10,10 @@ import type { GoalSnapshotV2, GoalStateResponse, SessionGroupPresetColor, + SessionSourceInput, + SessionSourcesResult, + SessionSourceUpsertResult, + SessionSourceRemoveResult, TurnResultCode, TurnResultErrorPayload, } from '@qwen-code/qwen-code-core'; @@ -623,6 +627,7 @@ export interface BridgeBranchSessionRequest { } export interface BridgePersistedBranchedSession { + sourceWarnings?: string[]; sessionId: string; displayName: string; forkedFrom: { sessionId: string; displayName: string }; @@ -641,6 +646,7 @@ export interface BridgeSideTaskSessionRequest { } export interface BridgeSideTaskSession extends BridgeRestoredSession { + sourceWarnings?: string[]; displayName: string; parentSessionId: string; } @@ -1773,6 +1779,23 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { */ setSessionPrs?(sessionId: string, prs: SessionPrInfo[]): void; + getSessionSources( + sessionId: string, + context?: BridgeClientRequestContext, + ): Promise; + + upsertSessionSource( + sessionId: string, + input: SessionSourceInput, + context: BridgeClientRequestContext, + ): Promise; + + removeSessionSource( + sessionId: string, + sourceId: string, + context: BridgeClientRequestContext, + ): Promise; + /** * List the structured artifacts registered for a live session. Throws * `SessionNotFoundError` when the id is unknown. diff --git a/packages/acp-bridge/src/session-sources.test.ts b/packages/acp-bridge/src/session-sources.test.ts new file mode 100644 index 00000000000..c186872b1ff --- /dev/null +++ b/packages/acp-bridge/src/session-sources.test.ts @@ -0,0 +1,376 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { SERVE_CONTROL_EXT_METHODS } from './status.js'; +import { SessionAttachmentStore } from './sessionAttachments.js'; +import { makeBridge, makeChannel, WS_A } from './internal/testUtils.js'; + +const link = { + title: 'Requirements', + locator: { type: 'url' as const, url: 'https://example.com/spec#part' }, +}; + +describe('session source bridge', () => { + it('blocks all source operations before standalone workspace activation', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const sessionId = 'standalone-sources'; + try { + await bridge.restoreStandaloneSession('resume', { + sessionId, + workspaceCwd: WS_A, + }); + const results = await Promise.allSettled([ + bridge.getSessionSources(sessionId), + bridge.upsertSessionSource(sessionId, link, {}), + bridge.removeSessionSource(sessionId, 'source-id', {}), + ]); + for (const result of results) { + expect(result).toMatchObject({ + status: 'rejected', + reason: { data: { errorKind: 'working_directory_missing' } }, + }); + } + expect( + handle.agent.extMethodCalls.filter(({ method }) => + method.startsWith('qwen/session/sources/'), + ), + ).toEqual([]); + } finally { + await bridge.shutdown(); + } + }); + + it.each([ + { persistedOnly: false, attachmentRoot: false }, + { persistedOnly: false, attachmentRoot: true }, + { persistedOnly: true, attachmentRoot: false }, + { persistedOnly: true, attachmentRoot: true }, + ])( + 'copies sources once for fork $persistedOnly / attachment storage $attachmentRoot', + async ({ persistedOnly, attachmentRoot }) => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-source-fork-'), + ); + const handle = makeChannel({ + extMethodImpl: (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionBranch) + return { newSessionId: 'fork-1', title: 'Fork' }; + if (method === 'qwen/session/sources/copy') + return { warnings: ['One reference was omitted'] }; + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + ...(attachmentRoot ? { sessionAttachmentsRoot: root } : {}), + }); + try { + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const attachment = await bridge.storeSessionAttachment( + session.sessionId, + Buffer.from('reference'), + 'text/plain', + { clientId: session.clientId }, + 'reference.txt', + ); + const fork = await bridge.branchSession(session.sessionId, { + ...(persistedOnly + ? { atRecordId: '11111111-1111-4111-8111-111111111111' } + : {}), + }); + const copies = handle.agent.extMethodCalls.filter( + ({ method }) => method === 'qwen/session/sources/copy', + ); + expect(copies).toEqual([ + { + method: 'qwen/session/sources/copy', + params: { + sessionId: session.sessionId, + targetSessionId: 'fork-1', + targetCwd: WS_A, + attachmentIds: + attachmentRoot || !persistedOnly + ? [attachment.attachmentId] + : [], + }, + }, + ]); + expect(fork.sourceWarnings).toEqual(['One reference was omitted']); + expect(handle.agent.loadSessionCalls).toHaveLength( + persistedOnly ? 0 : 1, + ); + } finally { + await bridge.shutdown(); + await fs.rm(root, { recursive: true, force: true }); + } + }, + ); + + it.each([ + { result: { warnings: [] }, warning: false }, + { + result: { sourceError: { code: 'source_persistence_unavailable' } }, + warning: true, + }, + { result: { sourceError: { code: 'invalid_source' } }, warning: true }, + ])( + 'preserves fork source-copy warning semantics for $result', + async ({ result, warning }) => { + const handle = makeChannel({ + extMethodImpl: (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionBranch) + return { newSessionId: 'fork-1', title: 'Fork' }; + if (method === 'qwen/session/sources/copy') return result; + return {}; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + try { + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const fork = await bridge.branchSession(session.sessionId, { + atRecordId: '11111111-1111-4111-8111-111111111111', + }); + if (warning) + expect(fork.sourceWarnings).toEqual([ + 'Session sources could not be copied.', + ]); + else expect(fork).not.toHaveProperty('sourceWarnings'); + } finally { + await bridge.shutdown(); + } + }, + ); + + it('never sends an attachment mutation to a replaced owner after the existence check', async () => { + const handles: Array> = []; + const bridge = makeBridge({ + channelFactory: async () => { + const handle = makeChannel(); + handles.push(handle); + return handle.channel; + }, + }); + const original = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + let release!: ( + value: Awaited>, + ) => void; + let started!: () => void; + const waiting = new Promise((resolve) => { + started = resolve; + }); + const list = vi + .spyOn(SessionAttachmentStore.prototype, 'list') + .mockImplementationOnce(() => { + started(); + return new Promise((resolve) => { + release = resolve; + }); + }); + try { + const mutation = bridge.upsertSessionSource( + original.sessionId, + { + title: 'Old attachment', + locator: { type: 'attachment', attachmentId: 'original.txt' }, + }, + { clientId: original.clientId }, + ); + const rejected = mutation.catch((error: unknown) => error); + await waiting; + await bridge.closeSession(original.sessionId, { + clientId: original.clientId, + }); + const replacement = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(replacement.sessionId).toBe(original.sessionId); + release([ + { + type: 'resource', + attachmentId: 'original.txt', + mimeType: 'text/plain', + size: 1, + }, + ]); + expect(await rejected).toMatchObject({ name: 'SessionNotFoundError' }); + expect( + handles.some((handle) => + handle.agent.extMethodCalls.some( + (call) => call.method === 'qwen/session/sources/upsert', + ), + ), + ).toBe(false); + } finally { + list.mockRestore(); + await bridge.shutdown(); + } + }); + + it('maps private source errors without requiring raw-payload RPC errors', async () => { + const handle = makeChannel({ + extMethodImpl: () => ({ + sourceError: { + code: 'source_persistence_unavailable', + message: 'Source metadata could not be persisted', + }, + }), + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + try { + await expect( + bridge.upsertSessionSource(session.sessionId, link, { + clientId: session.clientId, + }), + ).rejects.toMatchObject({ + code: 'source_persistence_unavailable', + message: 'Source metadata could not be persisted', + }); + } finally { + await bridge.shutdown(); + } + }); + + it('forwards to the bound child and validates clients and attachment ownership first', async () => { + const handle = makeChannel({ + extMethodImpl: (_method, params) => ({ + revision: 3, + sources: [], + input: params['input'], + }), + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const context = { clientId: session.clientId }; + try { + await expect( + bridge.getSessionSources(session.sessionId, context), + ).resolves.toMatchObject({ revision: 3 }); + await expect( + bridge.upsertSessionSource(session.sessionId, link, context), + ).resolves.toMatchObject({ input: link }); + await expect( + bridge.removeSessionSource(session.sessionId, 'source-1', context), + ).resolves.toMatchObject({ revision: 3 }); + const methods = handle.agent.extMethodCalls.filter((call) => + call.method.startsWith('qwen/session/sources/'), + ); + expect(methods.map((call) => call.method)).toEqual([ + 'qwen/session/sources/list', + 'qwen/session/sources/upsert', + 'qwen/session/sources/remove', + ]); + expect( + methods.every((call) => call.params['sessionId'] === session.sessionId), + ).toBe(true); + await expect( + bridge.upsertSessionSource(session.sessionId, link, { + clientId: 'foreign', + }), + ).rejects.toMatchObject({ name: 'InvalidClientIdError' }); + await expect( + bridge.upsertSessionSource(session.sessionId, link, {}), + ).rejects.toMatchObject({ data: { errorKind: 'client_id_required' } }); + await expect( + bridge.upsertSessionSource( + session.sessionId, + { + title: 'Unknown', + locator: { type: 'attachment', attachmentId: 'unknown.txt' }, + }, + context, + ), + ).rejects.toMatchObject({ + data: { errorKind: 'source_attachment_not_found' }, + }); + await expect( + bridge.upsertSessionSource( + session.sessionId, + { ...link, extra: true } as typeof link, + context, + ), + ).rejects.toMatchObject({ code: 'invalid_source' }); + expect( + handle.agent.extMethodCalls.filter((call) => + call.method.startsWith('qwen/session/sources/'), + ), + ).toHaveLength(3); + } finally { + await bridge.shutdown(); + } + }); + + it('forwards accepted same-session attachment metadata without reading its contents', async () => { + const handle = makeChannel({ + extMethodImpl: (_method, params) => ({ + revision: 1, + input: params['input'], + }), + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const context = { clientId: session.clientId }; + try { + const attachment = await bridge.storeSessionAttachment( + session.sessionId, + Buffer.from('reference'), + 'text/plain', + context, + 'reference.txt', + ); + const input = { + title: 'Reference', + locator: { + type: 'attachment' as const, + attachmentId: attachment.attachmentId, + }, + }; + await expect( + bridge.upsertSessionSource(session.sessionId, input, context), + ).resolves.toMatchObject({ input }); + expect( + (await bridge.getSessionArtifacts(session.sessionId)).artifacts, + ).toEqual([]); + } finally { + await bridge.shutdown(); + } + }); + + it('drops foreign and malformed child notifications and publishes a metadata invalidation', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + try { + await handle.agentConnection.extNotification( + 'qwen/notify/session/sources-changed', + { sessionId: 'foreign', revision: 1 }, + ); + await handle.agentConnection.extNotification( + 'qwen/notify/session/sources-changed', + { sessionId: session.sessionId, revision: -1 }, + ); + await handle.agentConnection.extNotification( + 'qwen/notify/session/sources-changed', + { sessionId: session.sessionId, revision: 2 }, + ); + // The following RPC waits behind the notifications on the same transport. + await bridge.getSessionSources(session.sessionId); + const sources = bridge + .getSessionReplaySnapshot(session.sessionId) + ?.liveJournal.filter((event) => event.type === 'source_changed'); + expect(sources).toHaveLength(1); + expect(sources?.[0]?.data).toEqual({ + sessionId: session.sessionId, + revision: 2, + }); + } finally { + await bridge.shutdown(); + } + }); +}); diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 3ea0abbedcf..0dee4c72d71 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -227,6 +227,12 @@ vi.mock('node:stream', async (importOriginal) => { // Mock core dependencies vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ + SessionSourceService: ( + await importOriginal() + ).SessionSourceService, + SessionSourceError: ( + await importOriginal() + ).SessionSourceError, BranchPointInvalidError: class BranchPointInvalidError extends Error { constructor(readonly recordId: string) { super(`Invalid or inactive branch point: ${recordId}`); @@ -4430,6 +4436,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { setSessionWriterReclaimPolicy: vi.fn(), setSessionWriterTakeoverPolicy: vi.fn(), setSessionSource: vi.fn(), + setSessionSourceServiceFactory: vi.fn(), + registerSessionSourceTool: vi.fn().mockResolvedValue(undefined), + getSessionSourceService: vi.fn(), getCronScheduler: vi.fn(), getSessionSourceType: vi.fn().mockReturnValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), @@ -4466,6 +4475,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getCurrentModelRegistryBaseUrl: vi.fn().mockReturnValue(undefined), getAllConfiguredModels: vi.fn().mockReturnValue([]), getLlmClient: vi.fn().mockReturnValue({ + setTools: vi.fn().mockResolvedValue(undefined), + refreshStartupContextReminder: vi.fn().mockResolvedValue(undefined), isInitialized: vi.fn().mockReturnValue(true), initialize: vi.fn().mockResolvedValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), @@ -6923,6 +6934,291 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + async function setupSourceMocks(sessionId: string) { + const innerConfig = await setupSessionMocks(sessionId); + const recording = { + flush: vi.fn().mockResolvedValue(undefined), + recordSessionSourcesSnapshot: vi.fn().mockResolvedValue(undefined), + }; + innerConfig.getChatRecordingService = vi.fn().mockReturnValue(recording); + innerConfig.getSessionService = vi + .fn() + .mockReturnValue({ readSessionSources: vi.fn().mockResolvedValue({}) }); + let service: + | import('@qwen-code/qwen-code-core').SessionSourceService + | undefined; + innerConfig.setSessionSourceServiceFactory.mockImplementation( + ( + factory: () => import('@qwen-code/qwen-code-core').SessionSourceService, + ) => { + service = factory(); + }, + ); + innerConfig.getSessionSourceService.mockImplementation(() => service); + return { innerConfig, recording }; + } + + it('source methods require the trusted daemon parent before resolving a session', async () => { + const sessionId = '11111111-1111-4111-8111-111111111111'; + await setupSessionMocks(sessionId); + const { agent, agentPromise } = await bootAcpAgent(); + try { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod('qwen/session/sources/list', { sessionId }), + ).resolves.toEqual({ + sourceError: { + code: 'source_persistence_unavailable', + message: 'Session source operation failed', + }, + }); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('binds sources before tool discovery and persists owner-session metadata through internal methods', async () => { + const sessionId = '11111111-1111-4111-8111-111111111111'; + const { innerConfig, recording } = await setupSourceMocks(sessionId); + const { agent, agentPromise } = await bootInitializedAcpAgent( + makeSessionSettings(), + 'trusted-capability', + ); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + expect( + innerConfig.setSessionSourceServiceFactory.mock.invocationCallOrder[0], + ).toBeLessThan(innerConfig.initialize.mock.invocationCallOrder[0]!); + await expect( + agent.extMethod('qwen/session/sources/list', { sessionId }), + ).resolves.toEqual({ revision: 0, sources: [] }); + const created = await agent.extMethod('qwen/session/sources/upsert', { + sessionId, + input: { + title: 'Docs', + locator: { type: 'workspace_file', workspacePath: 'README.md' }, + }, + }); + expect(created).toMatchObject({ revision: 1, change: 'created' }); + expect(recording.recordSessionSourcesSnapshot).toHaveBeenCalledOnce(); + await expect( + agent.extMethod('qwen/session/sources/upsert', { + sessionId, + input: { + title: 'Docs', + locator: { type: 'url', url: 'file:///secret' }, + }, + }), + ).resolves.toMatchObject({ sourceError: { code: 'invalid_source' } }); + await expect( + agent.extMethod('qwen/session/sources/remove', { + sessionId, + sourceId: (created['source'] as { id: string }).id, + }), + ).resolves.toEqual({ revision: 2, removed: true }); + mockConnectionState.resolve(); + await agentPromise; + }); + + it('binds standalone sources only after activation and registers their tool', async () => { + const sessionId = '11111111-1111-4111-8111-111111111111'; + const { innerConfig, recording } = await setupSourceMocks(sessionId); + innerConfig.getSessionSourceType = vi.fn().mockReturnValue('standalone'); + const { agent, agentPromise } = await bootInitializedAcpAgent( + makeSessionSettings(), + 'trusted-capability', + ); + try { + await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + _meta: { + [SESSION_SOURCE_META_KEY]: { + sourceType: 'standalone', + [DAEMON_OWNED_STANDALONE_CREATION_KEY]: true, + }, + }, + }); + expect(innerConfig.setSessionSourceServiceFactory).not.toHaveBeenCalled(); + await lastSessionMock!.installManagedConversationActivation.mock.calls[0]![0](); + expect( + innerConfig.setSessionSourceServiceFactory.mock.invocationCallOrder[0], + ).toBeGreaterThan( + innerConfig.activateProvisionalWorkspace.mock.invocationCallOrder[0]!, + ); + expect(innerConfig.registerSessionSourceTool).toHaveBeenCalledOnce(); + expect(innerConfig.getLlmClient().setTools).toHaveBeenCalledOnce(); + expect( + innerConfig.getLlmClient().setTools.mock.invocationCallOrder[0], + ).toBeGreaterThan( + innerConfig.registerSessionSourceTool.mock.invocationCallOrder[0]!, + ); + expect( + innerConfig.getLlmClient().refreshStartupContextReminder, + ).toHaveBeenCalledOnce(); + expect( + innerConfig.getLlmClient().refreshStartupContextReminder.mock + .invocationCallOrder[0], + ).toBeGreaterThan( + innerConfig.getLlmClient().setTools.mock.invocationCallOrder[0]!, + ); + await expect( + agent.extMethod('qwen/session/sources/upsert', { + sessionId, + input: { + title: 'Docs', + locator: { type: 'workspace_file', workspacePath: 'README.md' }, + }, + }), + ).resolves.toMatchObject({ revision: 1, change: 'created' }); + expect(recording.recordSessionSourcesSnapshot).toHaveBeenCalledOnce(); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('keeps source identity on the bound workspace after a live cwd change', async () => { + const sessionId = '11111111-1111-4111-8111-111111111111'; + const { innerConfig } = await setupSourceMocks(sessionId); + const { agent, agentPromise } = await bootInitializedAcpAgent( + makeSessionSettings(), + 'trusted-capability', + ); + try { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const input = { + title: 'Docs', + locator: { type: 'workspace_file', workspacePath: 'README.md' }, + }; + const before = await agent.extMethod('qwen/session/sources/upsert', { + sessionId, + input, + }); + innerConfig.getTargetDir.mockReturnValue('/tmp/subdir'); + const after = await agent.extMethod('qwen/session/sources/upsert', { + sessionId, + input, + }); + expect(after).toMatchObject({ + revision: 1, + change: 'unchanged', + source: before['source'], + }); + expect(after['source']).toMatchObject({ workspaceCwd: '/tmp' }); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('skips source copying only when chat recording is disabled, retaining route errors', async () => { + const sessionId = '11111111-1111-4111-8111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.getChatRecordingService = vi.fn().mockReturnValue(undefined); + const { agent, agentPromise } = await bootInitializedAcpAgent( + makeSessionSettings(), + 'trusted-capability', + ); + try { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod('qwen/session/sources/copy', { sessionId }), + ).resolves.toEqual({ warnings: [] }); + await expect( + agent.extMethod('qwen/session/sources/list', { sessionId }), + ).resolves.toMatchObject({ + sourceError: { code: 'source_persistence_unavailable' }, + }); + innerConfig.getChatRecordingService.mockReturnValue( + {} as ReturnType, + ); + await expect( + agent.extMethod('qwen/session/sources/copy', { sessionId }), + ).resolves.toMatchObject({ + sourceError: { code: 'source_persistence_unavailable' }, + }); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('validates fork ownership and preserves durable copy results when temporary cleanup fails', async () => { + const sessionId = '11111111-1111-4111-8111-111111111111'; + const targetSessionId = '22222222-2222-4222-8222-222222222222'; + const { innerConfig } = await setupSourceMocks(sessionId); + const loadSession = vi + .fn() + .mockResolvedValue({ conversation: { messages: [] } }); + innerConfig.getSessionService.mockReturnValue({ + loadSession, + } as unknown as SessionService); + const { agent, agentPromise } = await bootInitializedAcpAgent( + makeSessionSettings(), + 'trusted-capability', + ); + const internals = agent as unknown as { + newSessionConfig: (...args: unknown[]) => Promise; + cleanupUnstoredConfig: (config: Config) => Promise; + }; + try { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const list = vi.fn().mockResolvedValue({ sources: [] }); + innerConfig.getSessionSourceService.mockReturnValue({ list }); + const copyFrom = vi + .fn() + .mockResolvedValue({ warnings: ['Copied with an omission'] }); + const targetConfig = { + getSessionSourceService: () => ({ copyFrom }), + } as unknown as Config; + const bootstrap = vi + .spyOn(internals, 'newSessionConfig') + .mockResolvedValue(targetConfig); + const cleanup = vi + .spyOn(internals, 'cleanupUnstoredConfig') + .mockRejectedValue(new Error('cleanup failed')); + const request = { + sessionId, + targetSessionId, + targetCwd: '/tmp', + attachmentIds: [], + }; + await expect( + agent.extMethod('qwen/session/sources/copy', request), + ).resolves.toEqual({ warnings: [] }); + expect(bootstrap).not.toHaveBeenCalled(); + expect(loadSession).not.toHaveBeenCalled(); + list.mockResolvedValue({ sources: [{ title: 'Docs' }] }); + await expect( + agent.extMethod('qwen/session/sources/copy', { + ...request, + targetCwd: '/other', + }), + ).resolves.toHaveProperty('sourceError'); + expect(loadSession).not.toHaveBeenCalled(); + await expect( + agent.extMethod('qwen/session/sources/copy', request), + ).resolves.toHaveProperty('sourceError'); + expect(bootstrap).not.toHaveBeenCalled(); + loadSession.mockResolvedValue({ + conversation: { messages: [{ forkedFrom: { sessionId } }] }, + }); + await expect( + agent.extMethod('qwen/session/sources/copy', request), + ).resolves.toEqual({ warnings: ['Copied with an omission'] }); + expect(copyFrom).toHaveBeenCalledOnce(); + expect(cleanup).toHaveBeenCalledWith(targetConfig); + copyFrom.mockRejectedValueOnce(new Error('persistence failed')); + await expect( + agent.extMethod('qwen/session/sources/copy', request), + ).resolves.toHaveProperty('sourceError'); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + it('sessionArtifactsPersist records artifact events and snapshots', async () => { const sessionId = 'session-A'; const recording = { @@ -22864,6 +23160,9 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { setSessionWriterReclaimPolicy: vi.fn(), setSessionWriterTakeoverPolicy: vi.fn(), setSessionSource: vi.fn(), + setSessionSourceServiceFactory: vi.fn(), + registerSessionSourceTool: vi.fn().mockResolvedValue(undefined), + getSessionSourceService: vi.fn(), waitForMcpReady: vi.fn().mockResolvedValue(undefined), getModelsConfig: vi.fn().mockReturnValue({ getCurrentAuthType: vi.fn().mockReturnValue('api-key'), @@ -22883,6 +23182,8 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { getAuthType: vi.fn().mockReturnValue('api-key'), getAllConfiguredModels: vi.fn().mockReturnValue([]), getLlmClient: vi.fn().mockReturnValue({ + setTools: vi.fn().mockResolvedValue(undefined), + refreshStartupContextReminder: vi.fn().mockResolvedValue(undefined), isInitialized: vi.fn().mockReturnValue(true), initialize: vi.fn().mockResolvedValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), @@ -23353,6 +23654,17 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { vi.mocked(reloadEnvironment).mockClear(); await lastManagedConversationActivation!(); + expect( + innerConfig.getLlmClient().refreshStartupContextReminder, + ).toHaveBeenCalledOnce(); + if (action === 'load') { + expect( + innerConfig.getLlmClient().refreshStartupContextReminder.mock + .invocationCallOrder[0], + ).toBeGreaterThan( + lastSessionMock!.replayHistory.mock.invocationCallOrder[0]!, + ); + } expect(reloadScopesFromDiskAtomically).toHaveBeenCalledTimes(2); expect(reloadScopesFromDiskAtomically).toHaveBeenLastCalledWith([ diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 530d34e5190..05711e614b6 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -147,6 +147,8 @@ import { listWorkflowSnapshots, type TurnResultRecordPayload, sessionIdContext, + SessionSourceService, + SessionSourceError, } from '@qwen-code/qwen-code-core'; import { randomUUID, timingSafeEqual } from 'node:crypto'; import { performance } from 'node:perf_hooks'; @@ -8488,6 +8490,27 @@ class QwenAgent implements Agent { return await this.extMethodInternal(method, normalizedParams); } catch (error) { + if ( + [ + 'qwen/session/sources/list', + 'qwen/session/sources/upsert', + 'qwen/session/sources/remove', + 'qwen/session/sources/copy', + ].includes(method) + ) { + if (!(error instanceof SessionSourceError)) { + debugLogger.error('[ACP] Session source ext-method error:', error); + } + return { + sourceError: + error instanceof SessionSourceError + ? { code: error.code, message: error.message } + : { + code: 'source_persistence_unavailable', + message: 'Session source operation failed', + }, + }; + } const writerError = getSessionWriterError(error); if (writerError) { throw new RequestError(writerError.rpcCode, writerError.message, { @@ -10248,6 +10271,124 @@ class QwenAgent implements Agent { } return { requestId, cancelled }; } + case 'qwen/session/sources/list': + case 'qwen/session/sources/upsert': + case 'qwen/session/sources/remove': + case 'qwen/session/sources/copy': { + if (!this.isTrustedManagedParent()) { + throw RequestError.invalidParams( + undefined, + 'Sources require a trusted private ACP parent', + ); + } + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || !sessionId) { + throw RequestError.invalidParams( + undefined, + 'Invalid source sessionId', + ); + } + const session = this.sessionOrThrow(sessionId); + const sourceConfig = session.getConfig(); + const service = sourceConfig.getSessionSourceService(); + if ( + method === 'qwen/session/sources/copy' && + !service && + !sourceConfig.getChatRecordingService() + ) { + return { warnings: [] }; + } + if (!service) + throw new SessionSourceError( + 'source_persistence_unavailable', + 'Session sources unavailable', + ); + if (method === 'qwen/session/sources/list') + return { ...(await service.list()) }; + if (method === 'qwen/session/sources/upsert') + return { ...(await service.upsert(params['input'])) }; + if (method === 'qwen/session/sources/remove') { + const sourceId = params['sourceId']; + if ( + typeof sourceId !== 'string' || + !sourceId || + sourceId.length > 200 + ) + throw new SessionSourceError('invalid_source', 'Invalid source ID'); + return { ...(await service.remove(sourceId)) }; + } + const targetSessionId = params['targetSessionId']; + const targetCwd = params['targetCwd']; + const attachmentIds = params['attachmentIds']; + if ( + typeof targetSessionId !== 'string' || + !SESSION_ID_RE.test(targetSessionId) || + targetSessionId === sessionId || + typeof targetCwd !== 'string' || + path.resolve(targetCwd) !== + path.resolve(sourceConfig.storage.getProjectRoot()) || + !Array.isArray(attachmentIds) || + attachmentIds.some((id) => typeof id !== 'string') + ) { + throw RequestError.invalidParams( + undefined, + 'Invalid source copy target', + ); + } + const sources = (await service.list()).sources; + if (!sources.length) return { warnings: [] }; + const targetData = await sourceConfig + .getSessionService() + .loadSession(targetSessionId); + if ( + !targetData?.conversation.messages.some( + (record) => record.forkedFrom?.sessionId === sessionId, + ) + ) { + throw RequestError.invalidParams( + undefined, + 'Source copy target is not a fork of this session', + ); + } + let temporaryConfig: Config | undefined; + try { + const targetConfig = + this.sessions.get(targetSessionId)?.getConfig() ?? + (temporaryConfig = await this.newSessionConfig( + targetCwd, + [], + loadSettings(targetCwd), + undefined, + targetSessionId, + true, + { + skipMcpDiscovery: true, + skipHooks: true, + skipSkillManager: true, + skipFileCheckpointing: true, + lenientToolWarmup: true, + }, + )); + const targetService = targetConfig.getSessionSourceService(); + if (!targetService) + throw new SessionSourceError( + 'source_persistence_unavailable', + 'Target sources unavailable', + ); + return await targetService.copyFrom( + sources, + attachmentIds as string[], + ); + } finally { + if (temporaryConfig) { + try { + await this.cleanupUnstoredConfig(temporaryConfig); + } catch (error) { + debugLogger.warn('Failed to clean up source copy config:', error); + } + } + } + } case SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist: { const sessionId = params['sessionId']; if (typeof sessionId !== 'string' || sessionId.length === 0) { @@ -14028,6 +14169,9 @@ class QwenAgent implements Agent { }); }); } + if (!provisionalWorkspace && chatRecording !== false) { + this.bindSessionSourceService(config); + } try { await config.initialize({ ...initializeOptions, @@ -14056,6 +14200,43 @@ class QwenAgent implements Agent { return config; } + private bindSessionSourceService(config: Config): void { + if (this.isTrustedManagedParent() && config.getChatRecordingService()) { + config.setSessionSourceServiceFactory(() => { + const sourceSessionId = config.getSessionId(); + const recording = config.getChatRecordingService(); + const sourceSessions = config.getSessionService(); + const workspaceCwd = config.storage.getProjectRoot(); + return new SessionSourceService({ + sessionId: sourceSessionId, + workspaceCwd: () => workspaceCwd, + load: async () => { + if (!recording) + throw new SessionSourceError( + 'source_persistence_unavailable', + 'Chat recording service unavailable', + ); + await recording.flush(); + return sourceSessions.readSessionSources(sourceSessionId); + }, + persist: async (snapshot) => { + if (!recording) + throw new SessionSourceError( + 'source_persistence_unavailable', + 'Chat recording service unavailable', + ); + await recording.recordSessionSourcesSnapshot(snapshot); + }, + notify: (revision) => + this.connection.extNotification( + 'qwen/notify/session/sources-changed', + { sessionId: sourceSessionId, revision }, + ), + }); + }); + } + } + private async surfaceMcpFailuresWhenReady(config: Config): Promise { try { await config.waitForMcpReady(); @@ -14378,6 +14559,9 @@ class QwenAgent implements Agent { this.assertManagedSessionAdmission(); await config.activateProvisionalWorkspace(); this.assertManagedSessionAdmission(); + this.bindSessionSourceService(config); + await config.registerSessionSourceTool(); + await config.getLlmClient().setTools(); this.setupFileSystem(config); config.hydrateSessionRestoreFileHistory?.(); if (sessionData?.fileHistorySnapshots?.length) { @@ -14387,6 +14571,7 @@ class QwenAgent implements Agent { } await replaySessionHistory(); await options.beforeStartPostReplayServices?.(session); + await config.getLlmClient().refreshStartupContextReminder(); session.installRewriter(); config.finalizeSessionRestore?.(); startNonInteractiveOpenAILogHousekeeping(config, settings); diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 5bb45742a24..ae13ebf1b51 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -193,6 +193,7 @@ export default { 'toolDisplayName.Agent': 'toolDisplayName.Agent', 'toolDisplayName.Artifact': 'toolDisplayName.Artifact', 'toolDisplayName.RecordArtifact': 'toolDisplayName.RecordArtifact', + 'toolDisplayName.RecordSource': 'toolDisplayName.RecordSource', 'toolDisplayName.ReportFindings': 'toolDisplayName.ReportFindings', 'toolDisplayName.DisplayImage': 'toolDisplayName.DisplayImage', 'toolDisplayName.Skill': 'toolDisplayName.Skill', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 4e6e64198f7..b105408da96 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -184,6 +184,7 @@ export default { 'toolDisplayName.Agent': 'Agent', 'toolDisplayName.Artifact': '製品', 'toolDisplayName.RecordArtifact': '記錄製品', + 'toolDisplayName.RecordSource': '記錄來源', 'toolDisplayName.ReportFindings': '上報評審發現', 'toolDisplayName.DisplayImage': '顯示圖片', 'toolDisplayName.Skill': '技能', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 705508fb3c3..13512ee2de7 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -185,6 +185,7 @@ export default { 'toolDisplayName.Agent': 'Agent', 'toolDisplayName.Artifact': '制品', 'toolDisplayName.RecordArtifact': '记录制品', + 'toolDisplayName.RecordSource': '记录来源', 'toolDisplayName.ReportFindings': '上报评审发现', 'toolDisplayName.DisplayImage': '显示图片', 'toolDisplayName.Skill': '技能', diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 8911c5a264c..64c52e77956 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -69,6 +69,7 @@ export const SERVE_CAPABILITY_REGISTRY = { session_events: { since: 'v1' }, session_artifacts: { since: 'v1' }, session_artifacts_persistence: { since: 'v1' }, + session_sources: { since: 'v1' }, // Daemon emits `slow_client_warning` synthetic frames at 75% queue // fill and honors `?maxQueued=N` (range [16, 2048]) on // `GET /session/:id/events`. Old daemons silently lack both — SDK @@ -650,6 +651,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< 'session_artifacts_persistence', (toggles) => toggles.sessionArtifactsPersistenceAvailable === true, ], + [ + 'session_sources', + (toggles) => toggles.sessionArtifactsPersistenceAvailable === true, + ], [ 'session_generation', (toggles) => toggles.sessionGenerationAvailable === true, diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 66d4093d2fb..a1d7b2ff38f 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -831,6 +831,12 @@ function makeBridge( refreshed: params.syncOutputLanguage, }; }, + getSessionSources: vi.fn(async () => ({ revision: 0, sources: [] })), + upsertSessionSource: vi.fn(async () => ({ + revision: 1, + change: 'created', + })), + removeSessionSource: vi.fn(async () => ({ revision: 1, removed: true })), async addSessionArtifact( sessionId: string, artifact: Parameters[1], @@ -1570,6 +1576,103 @@ describe('multi-workspace session dispatch', () => { expect(secondaryBridge.rewindCalls).toHaveLength(3); }); + const sourceAuth = (test: request.Test) => + test.set('Host', host()).set('Authorization', TEST_AUTHORIZATION); + + it('session sources use the trusted secondary owner for all metadata operations', async () => { + const { app, primaryBridge, secondaryBridge } = makeHarness({ + token: TEST_TOKEN, + }); + const sessionId = '22222222-2222-4222-a222-222222222222'; + const input = { + title: 'Secondary source', + locator: { type: 'url', url: 'https://example.com/' }, + }; + const listed = await sourceAuth( + request(app).get(`/session/${sessionId}/sources`), + ); + const added = await sourceAuth( + request(app).post(`/session/${sessionId}/sources`), + ) + .set('X-Qwen-Client-Id', 'client-secondary') + .send(input); + const removed = await sourceAuth( + request(app).delete(`/session/${sessionId}/sources/source-1`), + ).set('X-Qwen-Client-Id', 'client-secondary'); + expect([listed.status, added.status, removed.status]).toEqual([ + 200, 200, 200, + ]); + expect(secondaryBridge.getSessionSources).toHaveBeenCalledWith( + sessionId, + undefined, + ); + expect(secondaryBridge.upsertSessionSource).toHaveBeenCalledWith( + sessionId, + input, + { clientId: 'client-secondary' }, + ); + expect(secondaryBridge.removeSessionSource).toHaveBeenCalledWith( + sessionId, + 'source-1', + { clientId: 'client-secondary' }, + ); + expect(primaryBridge.getSessionSources).not.toHaveBeenCalled(); + expect(primaryBridge.upsertSessionSource).not.toHaveBeenCalled(); + expect(primaryBridge.removeSessionSource).not.toHaveBeenCalled(); + }); + + it.each(['unknown', 'untrusted', 'ambiguous', 'replacing'] as const)( + 'session sources fail closed for %s owners without primary fallback', + async (state) => { + const sessionId = + state === 'unknown' + ? 'missing' + : '22222222-2222-4222-a222-222222222222'; + const harness = makeHarness({ + token: TEST_TOKEN, + secondaryTrusted: state !== 'untrusted', + ...(state === 'ambiguous' + ? { primarySummaries: [makeSummary(sessionId, PRIMARY_CWD)] } + : {}), + }); + if (state === 'replacing') { + harness.registry.beginReplacement( + harness.registry.getEntryByWorkspaceId('secondary-id')!, + 'policy-2', + ); + } + const responses = [ + await sourceAuth( + request(harness.app).get(`/session/${sessionId}/sources`), + ), + await sourceAuth( + request(harness.app).post(`/session/${sessionId}/sources`), + ) + .set('X-Qwen-Client-Id', 'client-secondary') + .send({}), + await sourceAuth( + request(harness.app).delete(`/session/${sessionId}/sources/source-1`), + ).set('X-Qwen-Client-Id', 'client-secondary'), + ]; + const status = { + unknown: 404, + untrusted: 403, + ambiguous: 500, + replacing: 404, + }[state]; + expect(responses.map((response) => response.status)).toEqual([ + status, + status, + status, + ]); + for (const bridge of [harness.primaryBridge, harness.secondaryBridge]) { + expect(bridge.getSessionSources).not.toHaveBeenCalled(); + expect(bridge.upsertSessionSource).not.toHaveBeenCalled(); + expect(bridge.removeSessionSource).not.toHaveBeenCalled(); + } + }, + ); + it('fails closed for unknown, untrusted, and ambiguous rewind owners', async () => { const unknown = makeHarness(); const unknownRes = await request(unknown.app) diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 52fc7de1c0f..86dd17ef87d 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -38,6 +38,7 @@ import { toSessionPrInfo, upsertSessionPr, SESSION_PR_URL_MAX_LENGTH, + type SessionSourceInput, type ApprovalMode, type SessionGroupColor, type SessionGroupPresetColor, @@ -6301,6 +6302,75 @@ export function registerSessionRoutes( ), ); + app.get( + '/session/:id/sources', + withOwnerReadSession( + 'GET /session/:id/sources', + async (req, res, sessionId, runtime) => { + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + res + .status(200) + .json( + await runtime.bridge.getSessionSources( + sessionId, + clientId !== undefined ? { clientId } : undefined, + ), + ); + }, + ), + ); + + app.post( + '/session/:id/sources', + mutate({ strict: true }), + withOwnerMutableSession( + 'POST /session/:id/sources', + async (req, res, sessionId, runtime) => { + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + if (clientId === undefined) { + res.status(403).json({ + error: 'Source mutations require a session-bound client id', + code: 'client_id_required', + }); + return; + } + const result = await runtime.bridge.upsertSessionSource( + sessionId, + req.body as SessionSourceInput, + { clientId }, + ); + res.status(200).json(result); + }, + ), + ); + + app.delete( + '/session/:id/sources/:sourceId', + mutate({ strict: true }), + withOwnerMutableSession( + 'DELETE /session/:id/sources/:sourceId', + async (req, res, sessionId, runtime) => { + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + if (clientId === undefined) { + res.status(403).json({ + error: 'Source mutations require a session-bound client id', + code: 'client_id_required', + }); + return; + } + const result = await runtime.bridge.removeSessionSource( + sessionId, + req.params['sourceId']!, + { clientId }, + ); + res.status(200).json(result); + }, + ), + ); + app.get( '/session/:id/artifacts', withOwnerReadSession( diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 44edb2a41a2..cac0f0ca6bf 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -753,7 +753,7 @@ const EXPECTED_REGISTERED_FEATURES = [ return [feature, 'workspace_skills_config_runtime']; } if (feature === 'session_artifacts') { - return [feature, 'session_artifacts_persistence']; + return [feature, 'session_artifacts_persistence', 'session_sources']; } if (feature === 'mcp_guardrail_events') { return [feature, 'external_tool_guard']; @@ -2492,6 +2492,25 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { summaryCalls.push(sessionId); return summaryImpl(sessionId); }, + async getSessionSources() { + return { revision: 0, sources: [] }; + }, + async upsertSessionSource(_sessionId, input) { + return { + revision: 1, + source: { + ...input, + id: 'source-1', + kind: input.locator.type === 'url' ? 'link' : 'file', + createdAt: '2026-09-07T00:00:00.000Z', + updatedAt: '2026-09-07T00:00:00.000Z', + }, + change: 'created', + }; + }, + async removeSessionSource() { + return { revision: 1, removed: false }; + }, async getSessionArtifacts(sessionId, context) { sessionArtifactsCalls.push({ sessionId, @@ -3365,7 +3384,10 @@ describe('createServeApp', () => { ); continue; } - if (feature === 'session_artifacts_persistence') { + if ( + feature === 'session_artifacts_persistence' || + feature === 'session_sources' + ) { expect( predicate({ sessionArtifactsPersistenceAvailable: true }), ).toBe(true); @@ -24225,6 +24247,78 @@ describe('createServeApp', () => { expect(bridge.addSessionArtifactCalls).toHaveLength(0); }); + it('session sources route reads and mutations to the owner with client identity', async () => { + const bridge = fakeBridge(); + const list = vi.spyOn(bridge, 'getSessionSources'); + const upsert = vi.spyOn(bridge, 'upsertSessionSource'); + const remove = vi.spyOn(bridge, 'removeSessionSource'); + const app = createServeApp(tokenOpts, undefined, { bridge }); + const input = { + title: 'Requirements', + locator: { type: 'url', url: 'https://example.com/#part' }, + }; + const get = await auth(request(app).get('/session/session-A/sources')); + expect(get.status).toBe(200); + expect(get.body).toEqual({ revision: 0, sources: [] }); + expect(list).toHaveBeenCalledWith('session-A', undefined); + const post = await auth(request(app).post('/session/session-A/sources')) + .set('X-Qwen-Client-Id', 'client-1') + .send(input); + expect(post.status).toBe(200); + expect(upsert).toHaveBeenCalledWith('session-A', input, { + clientId: 'client-1', + }); + const deleted = await auth( + request(app).delete('/session/session-A/sources/source-1'), + ).set('X-Qwen-Client-Id', 'client-1'); + expect(deleted.body).toEqual({ revision: 1, removed: false }); + expect(remove).toHaveBeenCalledWith('session-A', 'source-1', { + clientId: 'client-1', + }); + }); + + it('session sources mutations require a bound client before forwarding', async () => { + const bridge = fakeBridge(); + const upsert = vi.spyOn(bridge, 'upsertSessionSource'); + const remove = vi.spyOn(bridge, 'removeSessionSource'); + const app = createServeApp(tokenOpts, undefined, { bridge }); + const post = await auth( + request(app).post('/session/session-A/sources'), + ).send({ title: 'Test' }); + const deleted = await auth( + request(app).delete('/session/session-A/sources/source-1'), + ); + expect(post.status).toBe(403); + expect(deleted.status).toBe(403); + expect(upsert).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + }); + + it.each([ + ['invalid_source', 400], + ['source_limit_reached', 409], + ['source_persistence_unavailable', 503], + ['source_attachment_not_found', 404], + ])( + 'session sources maps %s without acknowledging a mutation', + async (errorKind, status) => { + const bridge = fakeBridge(); + vi.spyOn(bridge, 'upsertSessionSource').mockRejectedValue( + Object.assign(new Error('Source operation failed'), { + data: { errorKind }, + }), + ); + const app = createServeApp(tokenOpts, undefined, { bridge }); + const result = await auth( + request(app).post('/session/session-A/sources'), + ) + .set('X-Qwen-Client-Id', 'client-1') + .send({ title: 'Test' }); + expect(result.status).toBe(status); + expect(result.body.code).toBe(errorKind); + }, + ); + it('POST /session/:id/artifacts requires a client id', async () => { const bridge = fakeBridge(); const app = createServeApp(tokenOpts, undefined, { bridge }); diff --git a/packages/cli/src/serve/server/error-response.test.ts b/packages/cli/src/serve/server/error-response.test.ts index 2d3f07d08a3..f6d49219491 100644 --- a/packages/cli/src/serve/server/error-response.test.ts +++ b/packages/cli/src/serve/server/error-response.test.ts @@ -13,6 +13,7 @@ import { import { InvalidSessionTranscriptTurnAnchorError, SessionIdCaseConflictError, + SessionSourceError, SessionTranscriptChangedError, SessionWriterConflictError, SessionWriterLostError, @@ -49,6 +50,72 @@ function responseMock(): { } describe('sendBridgeError session writer errors', () => { + it.each(['local', 'rpc'] as const)( + 'records %s source failures with request context', + (transport) => { + for (const [code, statusCode, level] of [ + ['invalid_source', 400, 'warn'], + ['source_persistence_unavailable', 503, 'error'], + ] as const) { + const { response, status, json } = responseMock(); + const daemonLog = { + warn: vi.fn(), + error: vi.fn(), + } as unknown as DaemonLogger; + const error = + transport === 'local' + ? new SessionSourceError(code, 'Source operation failed') + : Object.assign(new Error('Source operation failed'), { + data: { errorKind: code }, + }); + const context = { + route: 'POST /session/:id/sources', + sessionId: 'session-1', + }; + + sendBridgeError(response, error, context, daemonLog); + + expect(status).toHaveBeenCalledWith(statusCode); + expect(json).toHaveBeenCalledWith({ + error: 'Source operation failed', + code, + }); + if (level === 'error') { + expect(daemonLog.error).toHaveBeenCalledWith( + error.message, + error, + context, + ); + } else { + expect(daemonLog.warn).toHaveBeenCalledWith(error.message, { + ...context, + errorType: error.name, + }); + } + } + }, + ); + + it('logs unavailable source persistence to stderr without a daemon logger', () => { + const { response } = responseMock(); + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + try { + sendBridgeError( + response, + new SessionSourceError( + 'source_persistence_unavailable', + 'Source persistence is unavailable', + ), + { route: 'POST /session/:id/sources', sessionId: 'session-1' }, + ); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('POST /session/:id/sources session=session-1'), + ); + } finally { + stderr.mockRestore(); + } + }); + it('maps concurrent MCP authentication to conflict', () => { const { response, status, json } = responseMock(); diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index dae3bcc801f..0e320a7821c 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -15,6 +15,7 @@ import { SessionTranscriptSnapshotUnavailableError, SessionTranscriptTooLargeError, SessionWriterError, + SessionSourceError, TrustGateError, } from '@qwen-code/qwen-code-core'; import type { Response } from 'express'; @@ -270,6 +271,34 @@ export function sendBridgeError( ctx?: BridgeErrorContext, daemonLog?: DaemonLogger, ): void { + const sourceErrorKind = + err instanceof SessionSourceError + ? err.code + : (err as { data?: { errorKind?: unknown } } | null)?.data?.errorKind; + const sourceErrorStatus = + sourceErrorKind === 'invalid_source' + ? 400 + : sourceErrorKind === 'source_limit_reached' + ? 409 + : sourceErrorKind === 'source_persistence_unavailable' + ? 503 + : sourceErrorKind === 'source_attachment_not_found' + ? 404 + : undefined; + if (sourceErrorStatus !== undefined) { + const sourceError = + err instanceof Error ? err : new Error('Source operation failed'); + if (sourceErrorStatus >= 500) { + reportBridgeError(sourceError, ctx, daemonLog); + } else { + recordExpectedBridgeError(sourceError, ctx, daemonLog); + } + res.status(sourceErrorStatus).json({ + error: err instanceof Error ? err.message : 'Source operation failed', + code: sourceErrorKind, + }); + return; + } if (err instanceof BridgeTimeoutError && err.label === 'initialize') { recordExpectedBridgeError(err, ctx, daemonLog); if (ctx?.initPrecedesMutations === true) { diff --git a/packages/cli/src/serve/server/telemetry-catalog.test.ts b/packages/cli/src/serve/server/telemetry-catalog.test.ts index c407b6765aa..418cfbbcaf1 100644 --- a/packages/cli/src/serve/server/telemetry-catalog.test.ts +++ b/packages/cli/src/serve/server/telemetry-catalog.test.ts @@ -98,7 +98,7 @@ describe('legacy session telemetry route drift guard', () => { .map(({ method, path }) => `${method} ${path}`) .sort(); - expect(registered).toHaveLength(69); + expect(registered).toHaveLength(72); expect(registered).toEqual(catalog); }); }); diff --git a/packages/cli/src/serve/server/telemetry.test.ts b/packages/cli/src/serve/server/telemetry.test.ts index b7aabe2e18e..2a69f252f28 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -1090,17 +1090,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { }); describe('legacy session telemetry route catalog', () => { - it('contains 69 unique routes with the audited 67/2 attribution split', () => { + it('contains 72 unique routes with the audited 70/2 attribution split', () => { const keys = legacySessionTelemetryRoutes.map( ({ method, path }) => `${method} ${path}`, ); - expect(keys).toHaveLength(69); - expect(new Set(keys).size).toBe(69); + expect(keys).toHaveLength(72); + expect(new Set(keys).size).toBe(72); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'handler_resolved', ), - ).toHaveLength(67); + ).toHaveLength(70); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'pre_resolved', diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index b25dff252fd..94ab68b05a6 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -205,6 +205,24 @@ export const legacySessionTelemetryRoutes = [ attribution: 'handler_resolved', route: 'DELETE /session/:id/artifacts/:artifactId', }, + { + method: 'GET', + path: '/session/:id/sources', + attribution: 'handler_resolved', + route: 'GET /session/:id/sources', + }, + { + method: 'POST', + path: '/session/:id/sources', + attribution: 'handler_resolved', + route: 'POST /session/:id/sources', + }, + { + method: 'DELETE', + path: '/session/:id/sources/:sourceId', + attribution: 'handler_resolved', + route: 'DELETE /session/:id/sources/:sourceId', + }, { method: 'POST', path: '/session/:id/tasks/:taskId/cancel', diff --git a/packages/cli/src/serve/web-shell-static.test.ts b/packages/cli/src/serve/web-shell-static.test.ts index 47ec535de4c..00273d81b0f 100644 --- a/packages/cli/src/serve/web-shell-static.test.ts +++ b/packages/cli/src/serve/web-shell-static.test.ts @@ -37,11 +37,18 @@ describe('Web Shell sandbox framing', () => { ); }); - it('allows only the daemon loopback port in frame-src', () => { + it('allows local Blob previews and only the daemon loopback port in frame-src', () => { const csp = buildWebShellCsp([], loopbackSandboxOrigins('localhost:4170')); expect(csp).toContain( - 'frame-src http://localhost:4170 http://127.0.0.1:4170 https://localhost:4170 https://127.0.0.1:4170', + 'frame-src blob: http://localhost:4170 http://127.0.0.1:4170 https://localhost:4170 https://127.0.0.1:4170', ); + expect( + csp + .split('; ') + .find((directive) => directive.startsWith('frame-src ')) + ?.split(' ') + .slice(1), + ).toEqual(['blob:', ...loopbackSandboxOrigins('localhost:4170')]); expect(csp).not.toContain('[::1]'); expect(csp).not.toContain('http://localhost:*'); expect(csp).not.toContain('http://127.0.0.1:*'); diff --git a/packages/cli/src/serve/web-shell-static.ts b/packages/cli/src/serve/web-shell-static.ts index 64bebb080e3..d35e5cfbc20 100644 --- a/packages/cli/src/serve/web-shell-static.ts +++ b/packages/cli/src/serve/web-shell-static.ts @@ -112,7 +112,7 @@ export function buildWebShellCsp( const fa = frameAncestors.length ? `frame-ancestors ${frameAncestors.join(' ')}` : "frame-ancestors 'none'"; - const frameSrc = `frame-src ${frameSrcOrigins.join(' ')}`; + const frameSrc = `frame-src blob: ${frameSrcOrigins.join(' ')}`; return [...WEB_SHELL_CSP_DIRECTIVES, frameSrc, fa].join('; '); } diff --git a/packages/core/src/agents/runtime/agent-core.test.ts b/packages/core/src/agents/runtime/agent-core.test.ts index be031c2e9c3..424d8c77c83 100644 --- a/packages/core/src/agents/runtime/agent-core.test.ts +++ b/packages/core/src/agents/runtime/agent-core.test.ts @@ -1296,6 +1296,32 @@ describe('AgentCore.prepareTools', () => { expect(tools.map((t) => t.name)).toEqual(['lsp']); }); + it.each(['subagent', 'teammate'])( + 'excludes parent-owned record_source from a reused registry in a %s', + async (context) => { + const { core } = buildAgentForTools({ tools: ['*'] }, [ + { name: ToolNames.RECORD_SOURCE }, + { name: ToolNames.READ_FILE }, + ]); + + const prepareTools = () => core.prepareTools(); + const tools = + context === 'subagent' + ? await runWithAgentContext('workflow-subagent', prepareTools) + : await runWithTeammateIdentity( + { + agentId: 'scribe@demo', + agentName: 'scribe', + teamName: 'demo', + isTeamLead: false, + }, + prepareTools, + ); + + expect(tools.map((tool) => tool.name)).toEqual([ToolNames.READ_FILE]); + }, + ); + it('explicit tools list does NOT use the wildcard inherit path', async () => { // When the subagent enumerates tools by name, deferred-tool inclusion // is not the wildcard branch's responsibility — getFunctionDeclarationsFiltered @@ -1771,6 +1797,7 @@ describe('extractParentToolNames', () => { { name: ToolNames.WORKFLOW }, { name: ToolNames.AGENT }, { name: ToolNames.REQUEST_SHUTDOWN }, + { name: ToolNames.RECORD_SOURCE }, { name: ToolNames.READ_FILE }, ], }, @@ -1782,6 +1809,7 @@ describe('extractParentToolNames', () => { // Leader-only team control: a subagent must never impersonate the // leader by requesting a teammate shutdown (#9401). expect(names).not.toContain(ToolNames.REQUEST_SHUTDOWN); + expect(names).not.toContain(ToolNames.RECORD_SOURCE); }); it('filters out empty and non-string declaration names', () => { diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 3aa9d45ce29..808c8aa7eaf 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -221,9 +221,10 @@ export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet = new Set([ // never enter or exit the user's worktree state independently. ToolNames.ENTER_WORKTREE, ToolNames.EXIT_WORKTREE, - // V1 session artifacts are owned by the parent daemon session. + // V1 session artifacts and sources are owned by the parent daemon session. ToolNames.ARTIFACT, ToolNames.RECORD_ARTIFACT, + ToolNames.RECORD_SOURCE, // FIX-8 (SEC-I1): WORKFLOW is excluded to prevent unbounded recursive // fan-out: a subagent spawned by Workflow that calls Workflow would create // O(k^n) subagents. @@ -283,6 +284,7 @@ const EXCLUDED_TOOLS_FOR_TEAMMATES: ReadonlySet = new Set([ // Worktree management belongs to the parent session. ToolNames.ENTER_WORKTREE, ToolNames.EXIT_WORKTREE, + ToolNames.RECORD_SOURCE, // Same recursion guard as EXCLUDED_TOOLS_FOR_SUBAGENTS: the teammate // identity propagates through AsyncLocalStorage into anything it // spawns, so prepareTools() would keep choosing THIS exclusion set diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index ac3f98c4586..e74d86a01f4 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -6105,6 +6105,111 @@ describe('Server Config (config.ts)', () => { expect(registeredNames).toContain(ToolNames.RECORD_ARTIFACT); }); + it('binds record_source only for a supported top-level session and refreshes it after session rotation', async () => { + const { SessionSourceService } = await import( + '../services/session-sources.js' + ); + const config = new Config({ + ...baseParams, + interactive: false, + sdkMode: false, + }); + const factory = vi.fn( + () => + new SessionSourceService({ + sessionId: config.getSessionId(), + workspaceCwd: () => config.getTargetDir(), + load: async () => ({}), + persist: async () => undefined, + }), + ); + config.setSessionSourceServiceFactory(factory); + const original = config.getSessionSourceService(); + await config.initialize(); + const registeredNames = ( + ToolRegistry.prototype.registerFactory as Mock + ).mock.calls.map((call) => call[0]); + expect(registeredNames).toContain(ToolNames.RECORD_SOURCE); + const child = Object.create(config) as Config; + expect(child.getSessionSourceService()).toBeUndefined(); + config.startNewSession('replacement-source-session'); + expect(factory).toHaveBeenCalledTimes(2); + expect(config.getSessionSourceService()).not.toBe(original); + }); + + it.each(['registered', 'deferred', 'disabled'] as const)( + 'registers a source tool bound after initialization with %s permissions', + async (status) => { + const { SessionSourceService } = await import( + '../services/session-sources.js' + ); + const config = new Config({ ...baseParams, sdkMode: false }); + await config.initialize(); + const registry = config.getToolRegistry(); + const existingRegistry = registry; + (ToolRegistry.prototype.registerFactory as Mock).mockClear(); + ( + ToolRegistry.prototype.registerPermissionDeferredFactory as Mock + ).mockClear(); + vi.spyOn( + config.getPermissionManager()!, + 'getToolRegistrationStatus', + ).mockResolvedValue(status); + config.setSessionSourceServiceFactory( + () => + new SessionSourceService({ + sessionId: config.getSessionId(), + workspaceCwd: () => config.getTargetDir(), + load: async () => ({}), + persist: async () => undefined, + }), + ); + await config.registerSessionSourceTool(); + expect(config.getToolRegistry()).toBe(existingRegistry); + const eagerNames = ( + ToolRegistry.prototype.registerFactory as Mock + ).mock.calls.map((call) => call[0]); + const deferredNames = ( + ToolRegistry.prototype.registerPermissionDeferredFactory as Mock + ).mock.calls.map((call) => call[0]); + expect(eagerNames.includes(ToolNames.RECORD_SOURCE)).toBe( + status === 'registered', + ); + expect(deferredNames.includes(ToolNames.RECORD_SOURCE)).toBe( + status === 'deferred', + ); + }, + ); + + it('does not register record_source without a bound service or in SDK sessions', async () => { + const { SessionSourceService } = await import( + '../services/session-sources.js' + ); + for (const sdkMode of [false, true]) { + (ToolRegistry.prototype.registerFactory as Mock).mockClear(); + const config = new Config({ + ...baseParams, + interactive: false, + sdkMode, + }); + if (sdkMode) + config.setSessionSourceServiceFactory( + () => + new SessionSourceService({ + sessionId: config.getSessionId(), + workspaceCwd: () => config.getTargetDir(), + load: async () => ({}), + persist: async () => undefined, + }), + ); + await config.initialize(); + const names = ( + ToolRegistry.prototype.registerFactory as Mock + ).mock.calls.map((call) => call[0]); + expect(names).not.toContain(ToolNames.RECORD_SOURCE); + } + }); + it('registers report_findings even in headless sessions — review run depends on it', async () => { const config = new Config({ ...baseParams, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 154338a5dff..0f19a649f70 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { SessionSourceService } from '../services/session-sources.js'; + // Node built-ins import type { EventEmitter } from 'node:events'; import * as fs from 'node:fs'; @@ -4599,6 +4601,7 @@ export class Config { this.chatRecordingService = this.chatRecordingEnabled ? this.createChatRecordingService() : undefined; + this.sessionSourceService = this.sessionSourceServiceFactory?.(); this.initializeGoalRuntime(this.sessionData?.conversation.messages); // The file-read cache is session-scoped: its `file_unchanged` // placeholder relies on the model having seen the prior full read @@ -7875,6 +7878,20 @@ export class Config { return this.artifactEnabled; } + private sessionSourceService?: SessionSourceService; + private sessionSourceServiceFactory?: () => SessionSourceService; + + setSessionSourceServiceFactory(factory: () => SessionSourceService): void { + this.sessionSourceServiceFactory = factory; + this.sessionSourceService = factory(); + } + + getSessionSourceService(): SessionSourceService | undefined { + return Object.hasOwn(this, 'sessionSourceService') + ? this.sessionSourceService + : undefined; + } + isRecordArtifactEnabled(): boolean { if (process.env['QWEN_CODE_DISABLE_ARTIFACT'] === '1') return false; if (this.sdkMode) return false; @@ -9487,6 +9504,47 @@ export class Config { } } + async registerSessionSourceTool( + registry: ToolRegistry = this.toolRegistry, + ): Promise { + if ( + !this.getSessionSourceService() || + this.sdkMode || + this.getBareMode() || + this.isSafeMode() || + registry.getAllToolNames().includes(ToolNames.RECORD_SOURCE) + ) { + return; + } + let status: ToolRegistrationStatus = 'registered'; + try { + const permissionManager = this.getPermissionManager(); + status = permissionManager + ? await permissionManager.getToolRegistrationStatus( + ToolNames.RECORD_SOURCE, + ) + : 'registered'; + } catch (error) { + this.debugLogger.warn( + `Failed to check permissions for tool "${ToolNames.RECORD_SOURCE}", skipping registration:`, + error, + ); + return; + } + const factory: ToolFactory = async () => { + const { RecordSourceTool } = await import('../tools/record-source.js'); + return new RecordSourceTool(this); + }; + if (status === 'deferred') { + registry.registerPermissionDeferredFactory( + ToolNames.RECORD_SOURCE, + factory, + ); + } else if (status === 'registered') { + registry.registerFactory(ToolNames.RECORD_SOURCE, factory); + } + } + async createToolRegistry( sendSdkMcpMessage?: SendSdkMcpMessage, options?: { skipDiscovery?: boolean; forSubAgent?: boolean }, @@ -9822,6 +9880,9 @@ export class Config { return new ArtifactTool(this); }); } + if (!options?.forSubAgent) { + await this.registerSessionSourceTool(registry); + } if (this.isRecordArtifactEnabled()) { await registerLazy(ToolNames.RECORD_ARTIFACT, async () => { const { RecordArtifactTool } = await import( diff --git a/packages/core/src/config/session-source-tool-activation.test.ts b/packages/core/src/config/session-source-tool-activation.test.ts new file mode 100644 index 00000000000..d7e19a63103 --- /dev/null +++ b/packages/core/src/config/session-source-tool-activation.test.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Config } from './config.js'; +import { + getInitialChatHistory, + getStartupContextLength, +} from '../core/environmentContext.js'; +import { ToolRegistry } from '../tools/tool-registry.js'; +import { LlmClient } from '../core/client.js'; +import { LlmChat } from '../core/llm-chat.js'; +import { ToolSearchTool } from '../tools/tool-search.js'; +import { ToolNames } from '../tools/tool-names.js'; +import { SessionSourceService } from '../services/session-sources.js'; +import { PermissionManager } from '../permissions/permission-manager.js'; + +const workspaces: string[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + workspaces + .splice(0) + .map((workspace) => rm(workspace, { recursive: true, force: true })), + ); +}); + +describe('late session source activation', () => { + it.each([ + { status: 'registered', visible: false }, + { status: 'registered', visible: true }, + { status: 'deferred', visible: false }, + { status: 'disabled', visible: false }, + ] as const)( + 'refreshes source discovery and declarations ($status, visible=$visible)', + async ({ status, visible }) => { + const workspace = await mkdtemp( + join(tmpdir(), 'qwen-source-activation-'), + ); + workspaces.push(workspace); + const config = new Config({ + cwd: workspace, + targetDir: workspace, + model: 'test-model', + debugMode: false, + chatRecording: false, + usageStatisticsEnabled: false, + telemetry: { enabled: false }, + visibleTools: visible ? [ToolNames.RECORD_SOURCE] : [], + }); + const registry = new ToolRegistry(config); + vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry); + registry.registerTool(new ToolSearchTool(config)); + const client = new LlmClient(config); + const [startupHistory] = await getInitialChatHistory(config); + const conversation = [ + { role: 'user', parts: [{ text: 'Keep the previous request' }] }, + { role: 'model', parts: [{ text: 'Keep the previous answer' }] }, + ]; + client['chat'] = new LlmChat(config, {}, [ + ...startupHistory, + ...conversation, + ]); + expect(getStartupContextLength(client.getHistory())).toBe(1); + await client.setTools(); + const permissions = new PermissionManager(config); + vi.spyOn(permissions, 'getToolRegistrationStatus').mockResolvedValue( + status, + ); + vi.spyOn(config, 'getPermissionManager').mockReturnValue(permissions); + config.setSessionSourceServiceFactory( + () => + new SessionSourceService({ + sessionId: config.getSessionId(), + workspaceCwd: () => config.storage.getProjectRoot(), + load: async () => ({}), + persist: async () => undefined, + }), + ); + await config.registerSessionSourceTool(registry); + expect( + registry + .getDeferredToolSummary() + .some(({ name }) => name === ToolNames.RECORD_SOURCE), + ).toBe(false); + expect( + registry + .getAllTools() + .some(({ name }) => name === ToolNames.RECORD_SOURCE), + ).toBe(false); + await client.setTools(); + expect( + registry + .getAllTools() + .some(({ name }) => name === ToolNames.RECORD_SOURCE), + ).toBe(status !== 'disabled'); + expect( + registry + .getDeferredToolSummary() + .some(({ name }) => name === ToolNames.RECORD_SOURCE), + ).toBe(status !== 'disabled' && !visible); + const declarations = + client + .getChat() + .getGenerationConfig() + .tools?.flatMap((tool) => + 'functionDeclarations' in tool + ? (tool.functionDeclarations ?? []) + : [], + ) ?? []; + expect( + declarations.some(({ name }) => name === ToolNames.RECORD_SOURCE), + ).toBe(visible); + expect(registry.isDeferredToolRevealed(ToolNames.RECORD_SOURCE)).toBe( + false, + ); + const beforeContextRefresh = JSON.stringify(client.getHistory()); + expect(beforeContextRefresh).not.toContain(ToolNames.RECORD_SOURCE); + await client.refreshStartupContextReminder(); + const history = client.getHistory(); + expect(JSON.stringify(history).includes(ToolNames.RECORD_SOURCE)).toBe( + status !== 'disabled' && !visible, + ); + expect(getStartupContextLength(history)).toBe(1); + expect(history.slice(1)).toEqual(conversation); + }, + ); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e319b3bf3ae..e11b9ae513d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -814,3 +814,6 @@ export { type StartupEventSink, type StartupEventAttrs, } from './utils/startupEventSink.js'; + +export * from './services/session-sources.js'; +export { RecordSourceTool } from './tools/record-source.js'; diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index 02f1e8812bc..34c86f93bc5 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -249,6 +249,8 @@ export const TOOL_NAME_ALIASES: Readonly> = { artifact: 'artifact', Artifact: 'artifact', record_artifact: 'record_artifact', + record_source: 'record_source', + RecordSource: 'record_source', RecordArtifact: 'record_artifact', // Report Findings tool diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 9ecbd144c3b..caaa4f46fb3 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { SessionSourcesSnapshot } from './session-sources.js'; + import { type Config } from '../config/config.js'; import path from 'node:path'; import fs from 'node:fs'; @@ -311,6 +313,7 @@ export interface ChatRecord { | 'user_text_elements' | 'session_artifact_event' | 'session_artifact_snapshot' + | 'session_sources_snapshot' | 'branch_checkpoint' | 'goal_state' | 'goal_runtime' @@ -375,6 +378,7 @@ export interface ChatRecord { | UserTextElementsRecordPayload | SessionArtifactEventRecordPayload | SessionArtifactSnapshotRecordPayload + | SessionSourcesSnapshot | BranchCheckpointRecordPayloadV1 | GoalStateRecordPayloadV2 | TurnResultRecordPayload; @@ -2994,4 +2998,15 @@ export class ChatRecordingService { }; await this.appendRecordStrict(record, { updateActiveTail: false }); } + async recordSessionSourcesSnapshot( + payload: SessionSourcesSnapshot, + ): Promise { + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'session_sources_snapshot', + systemPayload: payload, + }; + await this.appendRecordStrict(record, { updateActiveTail: false }); + } } diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index 253b45f556a..6dbb050bf37 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -12,6 +12,7 @@ import { rebuildSessionArtifactSnapshot, remapSessionArtifactPayloadForFork, stableSessionArtifactId, + selectActiveSideArtifactRecordUuids, type PersistedSessionArtifact, type SessionArtifactEventRecordPayload, type SessionArtifactSnapshotRecordPayload, @@ -52,6 +53,29 @@ function event(payload: SessionArtifactEventRecordPayload): { }; } +describe('source snapshots beside artifact history', () => { + it.each(['session_sources_snapshot', 'custom_title'])( + 'does not let %s hide a restored artifact snapshot', + (subtype) => { + expect( + selectActiveSideArtifactRecordUuids( + [ + { uuid: 'turn', parentUuid: null, type: 'user' }, + { + uuid: 'artifact', + parentUuid: 'turn', + type: 'system', + subtype: 'session_artifact_snapshot', + }, + { uuid: 'metadata', parentUuid: 'turn', type: 'system', subtype }, + ], + ['turn'], + ), + ).toEqual(['artifact']); + }, + ); +}); + describe('session artifact persistence records', () => { it('roundtrips persisted document artifacts', () => { const document = artifact('s1', 'https://example.com/unused', { diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index b8ed4d3f352..e45957d077d 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -195,7 +195,11 @@ export function selectActiveSideArtifactRecordUuids( nextBlockingUuid = undefined; } else if ( !isSessionArtifactRecord(record) && - !(record.type === 'system' && record.subtype === 'custom_title') + !( + record.type === 'system' && + (record.subtype === 'custom_title' || + record.subtype === 'session_sources_snapshot') + ) ) { nextBlockingUuid = record.uuid; } diff --git a/packages/core/src/services/session-sources.test.ts b/packages/core/src/services/session-sources.test.ts new file mode 100644 index 00000000000..d0c2aeaee99 --- /dev/null +++ b/packages/core/src/services/session-sources.test.ts @@ -0,0 +1,292 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + SessionSourceService, + restoreSessionSources, + validateSessionSourceInput, + type SessionSourcesSnapshot, +} from './session-sources.js'; + +const file = (workspacePath = 'docs/requirements.md') => ({ + title: 'Requirements', + locator: { type: 'workspace_file', workspacePath }, +}); +const link = (url = 'https://example.com/doc#one') => ({ + title: 'Reference', + locator: { type: 'url', url }, +}); + +function fixture(sessionId = 'session', workspaceCwd = '/workspace') { + let stored: SessionSourcesSnapshot | undefined; + const persist = vi.fn(async (snapshot: SessionSourcesSnapshot) => { + stored = structuredClone(snapshot); + }); + const notify = vi.fn(async () => undefined); + const load = vi.fn(async () => ({ sourcesSnapshot: stored })); + const service = new SessionSourceService({ + sessionId, + workspaceCwd: () => workspaceCwd, + persist, + notify, + load, + }); + const restart = () => + new SessionSourceService({ + sessionId, + workspaceCwd: () => workspaceCwd, + persist, + notify, + load, + }); + return { service, persist, notify, load, restart }; +} + +describe('session sources', () => { + it('normalizes relative files lexically and keeps distinct URL fragments', async () => { + const { service, persist } = fixture(); + const first = await service.upsert( + file('docs\\./draft/../requirements.md'), + ); + const retry = await service.upsert(file()); + expect(retry).toEqual({ + revision: 1, + source: first.source, + change: 'unchanged', + }); + expect(first.source).toMatchObject({ + workspaceCwd: '/workspace', + locator: { workspacePath: 'docs/requirements.md' }, + }); + const one = await service.upsert(link()); + const two = await service.upsert(link('https://example.com/doc#two')); + expect(one.source.id).not.toBe(two.source.id); + expect(one.source).not.toHaveProperty('workspaceCwd'); + expect(persist).toHaveBeenCalledTimes(3); + }); + + it('preserves significant whitespace in workspace filenames', async () => { + const { service } = fixture(); + const spaced = await service.upsert(file(' report.md ')); + const plain = await service.upsert(file('report.md')); + expect(spaced.source.locator).toEqual({ + type: 'workspace_file', + workspacePath: ' report.md ', + }); + expect(spaced.source.id).not.toBe(plain.source.id); + }); + + it('keeps identity, creation time and order on metadata edits; omitted description preserves and empty clears', async () => { + const { service } = fixture(); + const created = await service.upsert({ ...file(), description: 'Details' }); + const changed = await service.upsert({ ...file(), title: 'Updated' }); + expect(changed.source).toMatchObject({ + id: created.source.id, + createdAt: created.source.createdAt, + description: 'Details', + }); + expect(changed.change).toBe('updated'); + const cleared = await service.upsert({ + ...file(), + title: 'Updated', + description: '', + }); + expect(cleared.source.description).toBe(''); + expect(cleared.revision).toBe(3); + expect((await service.upsert({ ...file(), title: 'Updated' })).change).toBe( + 'unchanged', + ); + }); + + it('reports normalized URL length overflow separately from invalid credentials', () => { + expect(() => + validateSessionSourceInput( + link(`https://example.com/${'文'.repeat(230)}`), + ), + ).toThrow( + 'Source URL is too long (maximum 2048 characters after normalization)', + ); + }); + + it.each([ + { ...file(), extra: true }, + { ...file(), title: ' ' }, + { ...file(), title: 'a\nb' }, + { ...file(), title: 'a\u200bb' }, + file('docs\u202esecret.md'), + file('/absolute'), + file('C:\\absolute'), + file('../../outside'), + file('..'), + file('a\0b'), + link('file:///etc/passwd'), + link('https://user:secret@example.com'), + link('javascript:alert(1)'), + { + ...file(), + locator: { + type: 'workspace_file', + workspacePath: 'a', + url: 'https://example.com', + }, + }, + { ...file(), locator: { type: 'attachment' } }, + { ...file(), title: 'x'.repeat(201) }, + { ...file(), description: 'x'.repeat(1001) }, + file('x'.repeat(501)), + link(`https://example.com/${'x'.repeat(2048)}`), + ])('rejects invalid metadata without persistence: %j', async (input) => { + const { service, persist } = fixture(); + await expect(service.upsert(input)).rejects.toMatchObject({ + code: 'invalid_source', + }); + expect(persist).not.toHaveBeenCalled(); + }); + + it('serializes concurrent tool/client writes and limits creation without blocking updates', async () => { + const { service, persist } = fixture(); + await Promise.all( + Array.from({ length: 200 }, (_, n) => service.upsert(file(`file-${n}`))), + ); + expect((await service.list()).revision).toBe(200); + await expect(service.upsert(file('overflow'))).rejects.toMatchObject({ + code: 'source_limit_reached', + }); + expect( + (await service.upsert({ ...file('file-0'), title: 'Changed' })).revision, + ).toBe(201); + expect(persist).toHaveBeenCalledTimes(201); + }); + + it('publishes only after acknowledged persistence, then repairs an uncertain append before retry', async () => { + const { service, persist, load, notify } = fixture(); + const first = await service.upsert(file()); + persist.mockRejectedValueOnce(new Error('disk failure')); + await expect(service.remove(first.source.id)).rejects.toMatchObject({ + code: 'source_persistence_unavailable', + }); + expect(notify).toHaveBeenCalledTimes(1); + expect(await service.list()).toEqual({ + revision: 1, + sources: [first.source], + }); + expect(load).toHaveBeenCalledTimes(2); + expect(await service.remove(first.source.id)).toEqual({ + revision: 2, + removed: true, + }); + }); + + it('recovers an acknowledged-unknown append before an idempotent retry', async () => { + const { service, persist } = fixture(); + const write = persist.getMockImplementation()!; + persist.mockImplementationOnce(async (snapshot) => { + await write(snapshot); + throw new Error('ack lost'); + }); + await expect(service.upsert(file())).rejects.toMatchObject({ + code: 'source_persistence_unavailable', + }); + const retry = await service.upsert(file()); + expect(retry).toMatchObject({ revision: 1, change: 'unchanged' }); + expect(persist).toHaveBeenCalledOnce(); + }); + + it('does not resurrect removed sources after restart or append duplicate delete snapshots', async () => { + const { service, restart, persist } = fixture(); + const created = await service.upsert(file()); + await service.remove(created.source.id); + expect(await restart().list()).toEqual({ revision: 2, sources: [] }); + expect(await service.remove(created.source.id)).toEqual({ + revision: 2, + removed: false, + }); + expect(persist).toHaveBeenCalledTimes(2); + }); + + it('ignores notification delivery failure after a committed mutation', async () => { + const { service, notify, restart } = fixture(); + notify.mockRejectedValueOnce(new Error('disconnected')); + const result = await service.upsert(file()); + expect((await restart().list()).sources).toEqual([result.source]); + }); + + it('never restores an older snapshot when the last one is malformed or from a future version', async () => { + const { service, persist } = fixture(); + await service.upsert(file()); + const record = { + type: 'system', + subtype: 'session_sources_snapshot', + systemPayload: persist.mock.calls[0][0], + }; + for (const systemPayload of [ + { version: 2, revision: 2, sources: [] }, + { version: 1, revision: 2, sources: [{}] }, + ]) { + const state = restoreSessionSources( + [record, { ...record, systemPayload }], + 'session', + ); + expect(state).toEqual({ sourcesUnavailable: true }); + const unavailable = new SessionSourceService({ + sessionId: 'session', + workspaceCwd: () => '/workspace', + load: async () => state, + persist, + }); + await expect(unavailable.upsert(file())).rejects.toMatchObject({ + code: 'source_persistence_unavailable', + }); + } + }); + + it('regenerates fork IDs, maps known attachment IDs, and omits cross-workspace references', async () => { + const parent = fixture(); + const original = await parent.service.upsert(file()); + await parent.service.upsert(link()); + await parent.service.upsert({ + title: 'Uploaded', + locator: { type: 'attachment', attachmentId: 'upload-1' }, + }); + const list = (await parent.service.list()).sources; + const same = fixture('same'); + expect(await same.service.copyFrom(list, ['upload-1'])).toEqual({ + warnings: [], + }); + const copied = (await same.service.list()).sources; + expect(copied).toHaveLength(3); + expect( + copied.find((source) => source.title === original.source.title)?.id, + ).not.toBe(original.source.id); + const other = fixture('other', '/different'); + expect((await other.service.copyFrom(list, [])).warnings).toHaveLength(2); + expect( + (await other.service.list()).sources.map((source) => source.kind), + ).toEqual(['link']); + }); + + it('does not expose mutable source records to callers', async () => { + const { service } = fixture(); + const created = await service.upsert(file()); + created.source.title = 'tampered'; + const list = await service.list(); + list.sources.length = 0; + expect((await service.list()).sources[0]?.title).toBe('Requirements'); + }); + + it('validates attachments as metadata without file reads or URL fetches', () => { + expect( + validateSessionSourceInput({ + title: ' Attachment ', + locator: { type: 'attachment', attachmentId: 'upload-1' }, + }), + ).toEqual({ + title: 'Attachment', + locator: { type: 'attachment', attachmentId: 'upload-1' }, + }); + }); +}); diff --git a/packages/core/src/services/session-sources.ts b/packages/core/src/services/session-sources.ts new file mode 100644 index 00000000000..f6ba08a4b9a --- /dev/null +++ b/packages/core/src/services/session-sources.ts @@ -0,0 +1,493 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import path from 'node:path'; + +export type SessionSourceLocator = + | { type: 'workspace_file'; workspacePath: string } + | { type: 'attachment'; attachmentId: string } + | { type: 'url'; url: string }; + +export interface SessionSourceInput { + title: string; + locator: SessionSourceLocator; + description?: string; +} + +export interface SessionSource extends SessionSourceInput { + id: string; + kind: 'file' | 'link'; + workspaceCwd?: string; + createdAt: string; + updatedAt: string; +} + +export interface SessionSourcesSnapshot { + version: 1; + revision: number; + sources: SessionSource[]; +} + +export interface SessionSourcesResult { + revision: number; + sources: SessionSource[]; +} + +export interface SessionSourceUpsertResult { + revision: number; + source: SessionSource; + change: 'created' | 'updated' | 'unchanged'; +} + +export interface SessionSourceRemoveResult { + revision: number; + removed: boolean; +} + +export class SessionSourceError extends Error { + constructor( + readonly code: + | 'invalid_source' + | 'source_limit_reached' + | 'source_persistence_unavailable', + message: string, + ) { + super(message); + this.name = 'SessionSourceError'; + } +} + +const invalid = (message: string): never => { + throw new SessionSourceError('invalid_source', message); +}; + +function object(value: unknown, fields: string[]): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return invalid('Source must be an object'); + } + if (Object.keys(value).some((key) => !fields.includes(key))) { + return invalid('Unknown source field'); + } + return value as Record; +} + +function text( + value: unknown, + field: string, + limit: number, + empty = false, + trim = true, +): string { + if (typeof value !== 'string' || /[\p{Cc}\p{Cf}]/u.test(value)) { + return invalid(`Invalid ${field}`); + } + const normalized = trim ? value.trim() : value; + if ((!empty && !normalized) || normalized.length > limit) { + return invalid(`Invalid ${field} length (maximum ${limit})`); + } + return normalized; +} + +export function validateSessionSourceInput(value: unknown): SessionSourceInput { + const input = object(value, ['title', 'description', 'locator']); + const title = text(input['title'], 'title', 200); + const description = + input['description'] === undefined + ? undefined + : text(input['description'], 'description', 1000, true); + const raw = object(input['locator'], [ + 'type', + 'workspacePath', + 'attachmentId', + 'url', + ]); + let locator: SessionSourceLocator; + switch (raw['type']) { + case 'workspace_file': { + object(raw, ['type', 'workspacePath']); + const sourcePath = text( + raw['workspacePath'], + 'workspacePath', + 500, + false, + false, + ).replaceAll('\\', '/'); + if (sourcePath.startsWith('/') || /^[a-z]:/iu.test(sourcePath)) { + return invalid('Workspace path must be relative'); + } + const segments: string[] = []; + for (const segment of sourcePath.split('/')) { + if (!segment || segment === '.') continue; + if (segment === '..') { + if (!segments.length) + return invalid('Workspace path escapes its root'); + segments.pop(); + } else { + segments.push(segment); + } + } + if (!segments.length) + return invalid('Workspace path must identify a file'); + locator = { type: 'workspace_file', workspacePath: segments.join('/') }; + break; + } + case 'attachment': + object(raw, ['type', 'attachmentId']); + locator = { + type: 'attachment', + attachmentId: text( + raw['attachmentId'], + 'attachmentId', + 200, + false, + false, + ), + }; + break; + case 'url': { + object(raw, ['type', 'url']); + const maxUrlLength = 2048; + const url = text(raw['url'], 'url', maxUrlLength); + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return invalid('Invalid source URL'); + } + if ( + !['http:', 'https:'].includes(parsed.protocol) || + !parsed.hostname || + parsed.username || + parsed.password + ) { + return invalid('Source URL must be HTTP(S) without credentials'); + } + if (parsed.href.length > maxUrlLength) { + return invalid( + `Source URL is too long (maximum ${maxUrlLength} characters after normalization)`, + ); + } + locator = { type: 'url', url: parsed.href }; + break; + } + default: + return invalid('Unknown source locator type'); + } + return { + title, + locator, + ...(description !== undefined ? { description } : {}), + }; +} + +export function sessionSourceId( + sessionId: string, + locator: SessionSourceLocator, + workspaceCwd?: string, +): string { + return createHash('sha256') + .update(JSON.stringify([sessionId, locator, workspaceCwd ?? null])) + .digest('hex'); +} + +function ordered(sources: SessionSource[]): SessionSource[] { + return sources.sort( + (a, b) => + b.createdAt.localeCompare(a.createdAt) || a.id.localeCompare(b.id), + ); +} + +export function parseSessionSourcesSnapshot( + value: unknown, + sessionId: string, +): SessionSourcesSnapshot { + const raw = object(value, ['version', 'revision', 'sources']); + if ( + raw['version'] !== 1 || + !Number.isSafeInteger(raw['revision']) || + (raw['revision'] as number) < 0 || + !Array.isArray(raw['sources']) || + raw['sources'].length > 200 + ) { + return invalid('Unsupported or malformed sources snapshot'); + } + const ids = new Set(); + const sources = raw['sources'].map((value: unknown) => { + const source = object(value, [ + 'id', + 'kind', + 'workspaceCwd', + 'createdAt', + 'updatedAt', + 'title', + 'description', + 'locator', + ]); + const input = validateSessionSourceInput({ + title: source['title'], + locator: source['locator'], + ...(source['description'] !== undefined + ? { description: source['description'] } + : {}), + }); + const workspaceCwd = source['workspaceCwd']; + if ( + input.locator.type === 'workspace_file' + ? typeof workspaceCwd !== 'string' || + !path.isAbsolute(workspaceCwd) || + path.normalize(workspaceCwd) !== workspaceCwd + : workspaceCwd !== undefined + ) { + return invalid('Invalid source workspace binding'); + } + const id = sessionSourceId( + sessionId, + input.locator, + workspaceCwd as string | undefined, + ); + const kind = input.locator.type === 'url' ? 'link' : 'file'; + const createdAt = source['createdAt']; + const updatedAt = source['updatedAt']; + const isTimestamp = (value: unknown): value is string => + typeof value === 'string' && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString() === value; + if ( + source['id'] !== id || + ids.has(id) || + source['kind'] !== kind || + !isTimestamp(createdAt) || + !isTimestamp(updatedAt) || + updatedAt < createdAt || + source['title'] !== input.title || + source['description'] !== input.description + ) { + return invalid('Invalid stored source'); + } + ids.add(id); + return { + ...input, + id, + kind, + ...(typeof workspaceCwd === 'string' ? { workspaceCwd } : {}), + createdAt, + updatedAt, + } satisfies SessionSource; + }); + return { + version: 1, + revision: raw['revision'] as number, + sources: ordered(sources), + }; +} + +export interface SessionSourcesRestoreState { + sourcesSnapshot?: SessionSourcesSnapshot; + sourcesUnavailable?: true; +} + +export function restoreSessionSources( + records: ReadonlyArray<{ + type?: unknown; + subtype?: unknown; + systemPayload?: unknown; + }>, + sessionId: string, +): SessionSourcesRestoreState { + const latest = records.findLast( + (record) => + record.type === 'system' && record.subtype === 'session_sources_snapshot', + ); + if (!latest) return {}; + try { + return { + sourcesSnapshot: parseSessionSourcesSnapshot( + latest.systemPayload, + sessionId, + ), + }; + } catch { + return { sourcesUnavailable: true }; + } +} + +export class SessionSourceService { + private snapshot: SessionSourcesSnapshot = { + version: 1, + revision: 0, + sources: [], + }; + private loaded = false; + private queue: Promise = Promise.resolve(); + + constructor( + private readonly options: { + sessionId: string; + workspaceCwd: () => string; + load: () => Promise; + persist: (snapshot: SessionSourcesSnapshot) => Promise; + notify?: (revision: number) => Promise; + }, + ) {} + + private serial(operation: () => Promise): Promise { + const result = this.queue.then(async () => { + if (!this.loaded) { + let restored: SessionSourcesRestoreState; + try { + restored = await this.options.load(); + } catch { + throw new SessionSourceError( + 'source_persistence_unavailable', + 'Stored sources could not be loaded', + ); + } + if (restored.sourcesUnavailable) + throw new SessionSourceError( + 'source_persistence_unavailable', + 'Stored sources are unavailable', + ); + this.snapshot = restored.sourcesSnapshot ?? { + version: 1, + revision: 0, + sources: [], + }; + this.loaded = true; + } + return operation(); + }); + this.queue = result.catch(() => undefined); + return result; + } + + private async commit(sources: SessionSource[]): Promise { + const next = parseSessionSourcesSnapshot( + { version: 1, revision: this.snapshot.revision + 1, sources }, + this.options.sessionId, + ); + try { + await this.options.persist(next); + } catch { + this.loaded = false; + throw new SessionSourceError( + 'source_persistence_unavailable', + 'Source metadata could not be persisted', + ); + } + this.snapshot = next; + void Promise.resolve() + .then(() => this.options.notify?.(next.revision)) + .catch(() => undefined); + } + + list(): Promise { + return this.serial(async () => + structuredClone({ + revision: this.snapshot.revision, + sources: this.snapshot.sources, + }), + ); + } + + upsert(value: unknown): Promise { + return this.serial(async () => { + const input = validateSessionSourceInput(value); + const workspaceCwd = + input.locator.type === 'workspace_file' + ? path.resolve(this.options.workspaceCwd()) + : undefined; + const id = sessionSourceId( + this.options.sessionId, + input.locator, + workspaceCwd, + ); + const previous = this.snapshot.sources.find((source) => source.id === id); + const description = input.description ?? previous?.description; + if ( + previous && + previous.title === input.title && + previous.description === description + ) { + return { + revision: this.snapshot.revision, + source: structuredClone(previous), + change: 'unchanged', + }; + } + if (!previous && this.snapshot.sources.length >= 200) + throw new SessionSourceError( + 'source_limit_reached', + 'A session can contain at most 200 sources', + ); + const now = new Date().toISOString(); + const source: SessionSource = { + ...input, + ...(description !== undefined ? { description } : {}), + id, + kind: input.locator.type === 'url' ? 'link' : 'file', + ...(workspaceCwd ? { workspaceCwd } : {}), + createdAt: previous?.createdAt ?? now, + updatedAt: now, + }; + await this.commit([ + ...this.snapshot.sources.filter((item) => item.id !== id), + source, + ]); + return { + revision: this.snapshot.revision, + source: structuredClone(source), + change: previous ? 'updated' : 'created', + }; + }); + } + + remove(sourceId: string): Promise { + return this.serial(async () => { + const sources = this.snapshot.sources.filter( + (source) => source.id !== sourceId, + ); + const removed = sources.length !== this.snapshot.sources.length; + if (removed) await this.commit(sources); + return { revision: this.snapshot.revision, removed }; + }); + } + + copyFrom( + sources: SessionSource[], + attachmentIds: string[], + ): Promise<{ warnings: string[] }> { + return this.serial(async () => { + const warnings: string[] = []; + const cwd = path.resolve(this.options.workspaceCwd()); + const copied = sources.flatMap((source) => { + if ( + (source.locator.type === 'attachment' && + !attachmentIds.includes(source.locator.attachmentId)) || + (source.locator.type === 'workspace_file' && + source.workspaceCwd !== cwd) + ) { + warnings.push( + `Source ${source.id} was not copied because its resource could not be mapped`, + ); + return []; + } + return [ + { + ...source, + id: sessionSourceId( + this.options.sessionId, + source.locator, + source.workspaceCwd, + ), + }, + ]; + }); + if (copied.length) await this.commit(copied); + return { warnings }; + }); + } +} diff --git a/packages/core/src/services/session-transcript-reader.test.ts b/packages/core/src/services/session-transcript-reader.test.ts index 879830632ca..37c9f05b1af 100644 --- a/packages/core/src/services/session-transcript-reader.test.ts +++ b/packages/core/src/services/session-transcript-reader.test.ts @@ -13,6 +13,7 @@ const { mockDebugLogger, mockAddDaemonRequestAttribute } = vi.hoisted(() => ({ mockDebugLogger: { debug: vi.fn(), warn: vi.fn(), + error: vi.fn(), }, mockAddDaemonRequestAttribute: vi.fn(), })); @@ -45,6 +46,11 @@ vi.mock('node:fs/promises', async (importOriginal) => { }); import { Storage } from '../config/storage.js'; +import { CompressionStatus } from '../core/turn.js'; +import { + SessionSourceService, + type SessionSourcesSnapshot, +} from './session-sources.js'; import type { ChatRecord } from './chatRecordingService.js'; import { buildApiHistoryFromConversation, @@ -118,6 +124,257 @@ describe('SessionTranscriptReader', () => { return filePath; } + it('restores the latest session sources across rewind and compression without changing model history', async () => { + const persisted: SessionSourcesSnapshot[] = []; + const sources = new SessionSourceService({ + sessionId, + workspaceCwd: () => workspaceDir, + load: async () => ({}), + persist: async (snapshot) => { + persisted.push(snapshot); + }, + }); + const added = await sources.upsert({ + title: 'Requirements', + locator: { type: 'workspace_file', workspacePath: 'requirements.md' }, + }); + await sources.remove(added.source.id); + const first = record('u1', null, 'original prompt'); + const answer = record('a1', 'u1', 'answer'); + const metadata = ( + snapshot: SessionSourcesSnapshot, + index: number, + ): ChatRecord => ({ + ...record(`sources-${index}`, 'a1', ''), + type: 'system', + subtype: 'session_sources_snapshot', + message: undefined, + systemPayload: snapshot, + }); + const rewind: ChatRecord = { + ...record('rewind', null, ''), + type: 'system', + subtype: 'rewind', + message: undefined, + systemPayload: { truncatedCount: 2 }, + }; + const current = record('u2', 'rewind', 'replacement prompt'); + const compression: ChatRecord = { + ...record('compression', 'u2', ''), + type: 'system', + subtype: 'chat_compression', + message: undefined, + systemPayload: { + info: { + originalTokenCount: 100, + newTokenCount: 10, + compressionStatus: CompressionStatus.COMPRESSED, + }, + compressedHistory: [{ role: 'user', parts: [{ text: 'summary' }] }], + }, + }; + const records = [ + first, + answer, + metadata(persisted[0]!, 0), + metadata(persisted[1]!, 1), + rewind, + current, + compression, + ]; + await writeRecords(records); + const service = new SessionService(workspaceDir, { + runtimeBaseDir: runtimeDir, + }); + const loaded = await service.loadSession(sessionId); + const restored = await service.readRestoreProjection(sessionId, { + replay: { kind: 'none' }, + }); + const live = await service.readLiveRestoreProjection(sessionId, { + replay: { kind: 'none' }, + }); + for (const state of [loaded, restored?.runtime, live]) + expect(state?.sourcesSnapshot).toEqual({ + version: 1, + revision: 2, + sources: [], + }); + expect(buildApiHistoryFromConversation(loaded!.conversation)).toEqual( + restored?.runtime.apiHistory, + ); + expect(JSON.stringify(restored?.runtime.apiHistory)).not.toContain( + 'Requirements', + ); + expect(loaded?.lastCompletedUuid).toBe('compression'); + }); + + it('reads source metadata before the first conversation turn and never treats read failures as an empty list', async () => { + const metadata: ChatRecord = { + ...record('sources-only', null, ''), + type: 'system', + subtype: 'session_sources_snapshot', + message: undefined, + systemPayload: { version: 1, revision: 1, sources: [] }, + }; + const filePath = await writeRecords([metadata]); + const service = new SessionService(workspaceDir, { + runtimeBaseDir: runtimeDir, + }); + expect(await service.readSessionSources(sessionId)).toEqual({ + sourcesSnapshot: { version: 1, revision: 1, sources: [] }, + }); + await fs.appendFile( + filePath, + JSON.stringify(record('first-turn', null, 'prompt')) + '\n', + ); + const restored = await service.readRestoreProjection(sessionId, { + replay: { kind: 'none' }, + }); + const live = await service.readLiveRestoreProjection(sessionId, { + replay: { kind: 'none' }, + }); + expect(restored?.runtime.sourcesSnapshot).toEqual(metadata.systemPayload); + expect(live?.sourcesSnapshot).toEqual(metadata.systemPayload); + await fs.unlink(filePath); + await fs.mkdir(filePath); + await expect(service.readSessionSources(sessionId)).resolves.toEqual({ + sourcesUnavailable: true, + }); + }); + + it('marks source projections unavailable after an identity-invalid physical record', async () => { + const filePath = await writeRecords([ + record('u1', null, 'prompt'), + { + ...record('sources', 'u1', ''), + type: 'system', + subtype: 'session_sources_snapshot', + message: undefined, + systemPayload: { version: 1, revision: 1, sources: [] }, + }, + ]); + await fs.appendFile( + filePath, + JSON.stringify({ + type: 'system', + subtype: 'session_sources_snapshot', + sessionId, + cwd: workspaceDir, + systemPayload: { version: 1, revision: 2, sources: [] }, + }) + '\n', + ); + const service = new SessionService(workspaceDir, { + runtimeBaseDir: runtimeDir, + }); + const restored = await service.readRestoreProjection(sessionId, { + replay: { kind: 'none' }, + }); + const live = await service.readLiveRestoreProjection(sessionId, { + replay: { kind: 'none' }, + }); + for (const state of [restored?.runtime, live]) { + expect(state?.sourcesUnavailable).toBe(true); + expect(state?.sourcesSnapshot).toBeUndefined(); + } + expect( + (await service.loadSession(sessionId))?.conversation.messages.map( + ({ uuid }) => uuid, + ), + ).toEqual(['u1']); + }); + + it('never resurrects an earlier source list after a truncated last snapshot', async () => { + let snapshot: SessionSourcesSnapshot = { + version: 1, + revision: 0, + sources: [], + }; + const sourceService = new SessionSourceService({ + sessionId, + workspaceCwd: () => workspaceDir, + load: async () => ({}), + persist: async (next) => { + snapshot = next; + }, + }); + await sourceService.upsert({ + title: 'Old reference', + locator: { type: 'url', url: 'https://example.com/removed' }, + }); + const first: ChatRecord = { + ...record('source-1', 'a1', ''), + type: 'system', + subtype: 'session_sources_snapshot', + message: undefined, + systemPayload: snapshot, + }; + const filePath = await writeRecords([ + record('u1', null, 'prompt'), + record('a1', 'u1', 'answer'), + first, + ]); + await fs.appendFile( + filePath, + '{"type":"system","subtype":"session_sources_snapshot","systemPayload":{"version":1,"revision":2,"sources":', + ); + const service = new SessionService(workspaceDir, { + runtimeBaseDir: runtimeDir, + }); + const strict = await service.readSessionSources(sessionId); + const loaded = await service.loadSession(sessionId); + const restored = await service.readRestoreProjection(sessionId, { + replay: { kind: 'none' }, + }); + const live = await service.readLiveRestoreProjection(sessionId, { + replay: { kind: 'none' }, + }); + for (const state of [strict, loaded, restored?.runtime, live]) { + expect(state?.sourcesUnavailable).toBe(true); + expect(state?.sourcesSnapshot).toBeUndefined(); + } + expect(loaded?.conversation.messages.map(({ uuid }) => uuid)).toEqual([ + 'u1', + 'a1', + ]); + }); + + it('keeps conversation loading available when the last source snapshot is unsupported', async () => { + const malformed: ChatRecord = { + ...record('sources', 'a1', ''), + type: 'system', + subtype: 'session_sources_snapshot', + message: undefined, + systemPayload: { + version: 9, + revision: 2, + sources: [], + } as unknown as ChatRecord['systemPayload'], + }; + await writeRecords([ + record('u1', null, 'prompt'), + record('a1', 'u1', 'answer'), + malformed, + ]); + const service = new SessionService(workspaceDir, { + runtimeBaseDir: runtimeDir, + }); + const loaded = await service.loadSession(sessionId); + const restored = await service.readRestoreProjection(sessionId, { + replay: { kind: 'none' }, + }); + const live = await service.readLiveRestoreProjection(sessionId, { + replay: { kind: 'none' }, + }); + for (const state of [loaded, restored?.runtime, live]) { + expect(state?.sourcesUnavailable).toBe(true); + expect(state?.sourcesSnapshot).toBeUndefined(); + } + expect(loaded?.conversation.messages.map(({ uuid }) => uuid)).toEqual([ + 'u1', + 'a1', + ]); + }); + async function writeRawTranscript(content: string): Promise { const chatsDir = path.join( new Storage(workspaceDir).getProjectDir(), diff --git a/packages/core/src/services/session-transcript-reader.ts b/packages/core/src/services/session-transcript-reader.ts index 591e0601979..690ddaaa4df 100644 --- a/packages/core/src/services/session-transcript-reader.ts +++ b/packages/core/src/services/session-transcript-reader.ts @@ -4,6 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + restoreSessionSources, + type SessionSourcesRestoreState, +} from './session-sources.js'; + import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as fsp from 'node:fs/promises'; @@ -258,7 +263,7 @@ export interface SessionRestoreReplayPage { goalBootstrapRecords?: GoalRecoveryRecord[]; } -export interface SessionRuntimeResumeState { +export interface SessionRuntimeResumeState extends SessionSourcesRestoreState { apiHistory: Content[]; resumeTokenCounts?: ResumeTokenCounts; uiTelemetryEvents: UiEvent[]; @@ -293,7 +298,8 @@ export interface SessionRestoreProjection { replay?: SessionRestoreReplayPage; } -export interface SessionLiveRestoreProjection { +export interface SessionLiveRestoreProjection + extends SessionSourcesRestoreState { sessionId: string; startTime: string; lastUpdated: string; @@ -375,6 +381,7 @@ interface TranscriptIndex { leafUuid: string; firstRecordUuid: string; physicalRecords: PhysicalRecordHint[]; + sourceReadComplete: boolean; runtimeUuids: string[]; replayUuids: string[]; navigationTurns: TranscriptNavigationTurnHint[]; @@ -1968,6 +1975,7 @@ async function buildIndex(params: { >(); let sequence = 0; const physicalRecords: PhysicalRecordHint[] = []; + let sourceReadComplete = true; let leafUuid: string | undefined; let firstRecordUuid: string | undefined; let firstRecordTimestamp: string | undefined; @@ -1981,9 +1989,15 @@ async function buildIndex(params: { const text = line.toString('utf8').trim(); if (text.length === 0) return; let fragmentIndex = 0; - for (const value of jsonl.parseLineTolerant(text, filePath)) { + const parsed = jsonl.parseLineTolerantWithIntegrity( + text, + filePath, + ); + sourceReadComplete &&= parsed.complete; + for (const value of parsed.records) { const record = validateTranscriptRecord(value).record; if (!record) { + sourceReadComplete = false; continue; } if (firstRecordUuid === undefined) { @@ -2219,6 +2233,7 @@ async function buildIndex(params: { leafUuid, firstRecordUuid, physicalRecords, + sourceReadComplete, runtimeUuids, replayUuids, navigationTurns, @@ -2784,6 +2799,12 @@ export class SessionTranscriptReader { ) : [], ); + const sourcesUuid = index.physicalRecords.findLast( + (record) => + record.type === 'system' && + record.subtype === 'session_sources_snapshot', + )?.uuid; + const sourceRecords: ChatRecord[] = []; const artifactUuids = selectArtifactUuids(index); const artifactSet = new Set(artifactUuids); const metadataSet = new Set( @@ -2871,6 +2892,7 @@ export class SessionTranscriptReader { ); } } + if (record.uuid === sourcesUuid) sourceRecords.push(record); if (artifactSet.has(record.uuid)) artifacts.add(record); if (goalEvidenceSet.has(record.uuid)) { goalCheckpointAccumulator?.capture(record); @@ -2933,7 +2955,8 @@ export class SessionTranscriptReader { metadataSet.has(record.uuid) || uiTelemetrySet.has(record.uuid) || fileHistorySet.has(record.uuid) || - artifactSet.has(record.uuid); + artifactSet.has(record.uuid) || + record.uuid === sourcesUuid; if (needsDeferredDispatch) { if ( record.uuid === index.firstRecordUuid && @@ -2997,7 +3020,11 @@ export class SessionTranscriptReader { const preReadSet = new Set(preReadUuids); readContext.preloadedRecords = deferredPreReadRecords; const remainingUuids = Array.from( - new Set([...selectedRuntimeUuids, ...artifactUuids]), + new Set([ + ...selectedRuntimeUuids, + ...artifactUuids, + ...(sourcesUuid ? [sourcesUuid] : []), + ]), ).filter( (uuid) => !preReadSet.has(uuid) || deferredPreReadRecords.has(uuid), ); @@ -3115,6 +3142,9 @@ export class SessionTranscriptReader { ...(restoredFileHistory ? { fileHistorySnapshots: restoredFileHistory } : {}), + ...(index.sourceReadComplete + ? restoreSessionSources(sourceRecords, sessionId) + : { sourcesUnavailable: true as const }), ...(artifactSnapshot ? { artifactSnapshot } : {}), goalRecords, ...(goalRecovery.selectedGoalRecovery.sourceUuid @@ -3219,6 +3249,12 @@ export class SessionTranscriptReader { ? undefined : replaySelection.index.replayUuids[goalStatePosition]; const goalStateSet = new Set(goalStateUuid ? [goalStateUuid] : []); + const sourcesUuid = index.physicalRecords.findLast( + (record) => + record.type === 'system' && + record.subtype === 'session_sources_snapshot', + )?.uuid; + const sourceRecords: ChatRecord[] = []; const artifactUuids = selectArtifactUuids(index); const artifactSet = new Set(artifactUuids); const selectedRuntimeUuids = index.runtimeUuids.filter( @@ -3240,6 +3276,7 @@ export class SessionTranscriptReader { const selectedReadSet = new Set([ ...selectedRuntimeUuids, ...artifactUuids, + ...(sourcesUuid ? [sourcesUuid] : []), ]); const selectedReadsStartedAt = performance.now(); try { @@ -3265,6 +3302,7 @@ export class SessionTranscriptReader { const normalized = normalizeGoalRecoveryRecord(record); if (normalized) goalRecords.push(normalized); } + if (record.uuid === sourcesUuid) sourceRecords.push(record); if (artifactSet.has(record.uuid)) artifacts.add(record); }, ); @@ -3308,6 +3346,9 @@ export class SessionTranscriptReader { startTime: index.restoreStartTime, lastUpdated: index.lastUpdated, ...(replay ? { replay } : {}), + ...(index.sourceReadComplete + ? restoreSessionSources(sourceRecords, sessionId) + : { sourcesUnavailable: true as const }), ...(artifactSnapshot ? { artifactSnapshot } : {}), ...(goalRecords.length > 0 ? { goalRecords } : {}), ...(replayGoalRecoverySourceUuid diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 611af2fe0d8..f10f37ad8ef 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -1099,6 +1099,7 @@ describe('SessionService', () => { expect(loaded?.conversation.messages).toHaveLength(2); expect(vi.mocked(jsonl.read)).toHaveBeenCalledWith( expect.stringContaining(`/chats/archive/${sessionIdB}.jsonl`), + { onIncompleteRead: expect.any(Function) }, ); expect(statSyncSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 23115daa3c3..5d57176bf12 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -4,6 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + restoreSessionSources, + type SessionSourcesRestoreState, +} from './session-sources.js'; + import { Storage } from '../config/storage.js'; import { commitUsageBeforeTranscriptDeletion, @@ -380,7 +385,7 @@ export interface ConversationRecord { /** * Data structure for resuming an existing session. */ -export interface ResumedSessionData { +export interface ResumedSessionData extends SessionSourcesRestoreState { conversation: ConversationRecord; filePath: string; /** UUID of the last completed message - new messages should use this as parentUuid */ @@ -2867,9 +2872,12 @@ export class SessionService { /** * Reads all records from a session file. */ - private async readAllRecords(filePath: string): Promise { + private async readAllRecords( + filePath: string, + onIncompleteRead?: () => void, + ): Promise { try { - return await jsonl.read(filePath); + return await jsonl.read(filePath, { onIncompleteRead }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { debugLogger.error('Error reading session file:', error); @@ -2918,6 +2926,32 @@ export class SessionService { return this.loadSessionFromState(sessionId, 'active'); } + async readSessionSources( + sessionId: string, + ): Promise { + if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) + throw new Error('Invalid source session ID'); + const filePath = this.getSessionFilePath(sessionId, 'active'); + try { + await fs.promises.stat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; + return { sourcesUnavailable: true }; + } + const { records, complete } = + await jsonl.readLinesWithIntegrity(filePath, Infinity); + if (!complete) return { sourcesUnavailable: true }; + if ( + records[0] && + !(await this.sessionBelongsToCurrentProject( + records[0].sessionId, + records[0].cwd, + )) + ) + throw new Error('Source session workspace does not match'); + return restoreSessionSources(records, sessionId); + } + async readRestoreProjection( sessionId: string, options: SelectiveSessionRestoreOptions, @@ -2979,7 +3013,10 @@ export class SessionService { ): Promise { const filePath = this.getSessionFilePath(sessionId, state); - const records = await this.readAllRecords(filePath); + let sourceReadComplete = true; + const records = await this.readAllRecords(filePath, () => { + sourceReadComplete = false; + }); if (records.length === 0) { return; } @@ -3052,6 +3089,9 @@ export class SessionService { filePath, lastCompletedUuid: lastMessage.uuid, fileHistorySnapshots, + ...(sourceReadComplete + ? restoreSessionSources(records, firstRecord.sessionId) + : { sourcesUnavailable: true as const }), ...(artifactSnapshot ? { artifactSnapshot } : {}), historyGaps: gaps.length > 0 ? gaps : undefined, }; @@ -3842,7 +3882,8 @@ export class SessionService { (record) => !( record.type === 'system' && - (record.subtype === 'parent_session' || + (record.subtype === 'session_sources_snapshot' || + record.subtype === 'parent_session' || record.subtype === 'session_source' || record.subtype === 'turn_result' || (options.source && record.subtype === 'custom_title')) diff --git a/packages/core/src/tools/record-source.test.ts b/packages/core/src/tools/record-source.test.ts new file mode 100644 index 00000000000..4a58163f728 --- /dev/null +++ b/packages/core/src/tools/record-source.test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { runWithAgentContext } from '../agents/runtime/agent-context.js'; +import { runWithTeammateIdentity } from '../agents/team/identity.js'; +import type { Config } from '../config/config.js'; +import { SessionSourceService } from '../services/session-sources.js'; +import { RecordSourceTool } from './record-source.js'; + +function tool(persist = vi.fn(async () => undefined)) { + const service = new SessionSourceService({ + sessionId: 'test', + workspaceCwd: () => '/workspace', + load: async () => ({}), + persist, + }); + const config = { getSessionSourceService: () => service } as Config; + return { tool: new RecordSourceTool(config), service, persist }; +} + +const input = { + title: 'Reference', + locator: { + type: 'workspace_file' as const, + workspacePath: 'does-not-need-to-exist.md', + }, +}; + +describe('record_source', () => { + it('acknowledges the persisted source ID without reading the file or creating an artifact', async () => { + const { tool: sourceTool, persist, service } = tool(); + const result = await sourceTool + .build(input) + .execute(AbortSignal.timeout(1000)); + const source = (await service.list()).sources[0]!; + expect(persist).toHaveBeenCalledOnce(); + expect(result.llmContent).toBe(`Reference added: ${source.id}`); + expect(result.artifacts).toBeUndefined(); + expect(result.error).toBeUndefined(); + }); + + it('returns an ordinary tool error when persistence fails and does not claim registration succeeded', async () => { + const { tool: sourceTool, service } = tool( + vi.fn(async () => { + throw new Error('disk full'); + }), + ); + const result = await sourceTool + .build(input) + .execute(AbortSignal.timeout(1000)); + expect(result.error?.message).toBe( + 'Source metadata could not be persisted', + ); + expect(result.llmContent).not.toContain('Reference added'); + expect((await service.list()).sources).toEqual([]); + }); + + it.each(['subagent', 'teammate'])( + 'rejects direct %s execution against the parent service while keeping top-level registration available', + async (context) => { + const { tool: sourceTool, service, persist } = tool(); + const execute = () => + sourceTool.build(input).execute(AbortSignal.timeout(1000)); + const result = + context === 'subagent' + ? await runWithAgentContext('workflow-subagent', execute) + : await runWithTeammateIdentity( + { + agentId: 'scribe@demo', + agentName: 'scribe', + teamName: 'demo', + isTeamLead: false, + }, + execute, + ); + + expect(result.error?.message).toBe( + 'Only the top-level session can register sources', + ); + expect(result.llmContent).not.toContain('Reference added'); + expect(persist).not.toHaveBeenCalled(); + expect((await service.list()).sources).toEqual([]); + + const parentResult = await sourceTool + .build(input) + .execute(AbortSignal.timeout(1000)); + expect(parentResult.error).toBeUndefined(); + expect(persist).toHaveBeenCalledOnce(); + expect((await service.list()).sources).toHaveLength(1); + }, + ); + + it('rejects attachment locators, unknown input fields and invalid URLs before execution', () => { + const { tool: sourceTool } = tool(); + for (const value of [ + { + title: 'Upload', + locator: { type: 'attachment' as const, attachmentId: 'id' }, + }, + { ...input, workspaceCwd: '/other' }, + { + title: 'Secret', + locator: { + type: 'url' as const, + url: 'https://name:password@example.com', + }, + }, + ]) + expect(sourceTool.validateToolParams(value)).not.toBeNull(); + }); +}); diff --git a/packages/core/src/tools/record-source.ts b/packages/core/src/tools/record-source.ts new file mode 100644 index 00000000000..2e8bdf5f2bd --- /dev/null +++ b/packages/core/src/tools/record-source.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isSubagentLikeExecutionContext } from '../agents/runtime/subagent-plan-tool-policy.js'; +import type { Config } from '../config/config.js'; +import { + validateSessionSourceInput, + type SessionSourceInput, +} from '../services/session-sources.js'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + type ToolInvocation, + type ToolResult, +} from './tools.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; +import { ToolErrorType } from './tool-error.js'; + +class RecordSourceInvocation extends BaseToolInvocation< + SessionSourceInput, + ToolResult +> { + constructor( + params: SessionSourceInput, + private readonly config: Config, + ) { + super(params); + } + getDescription(): string { + return `Add reference: ${this.params.title}`; + } + async execute(): Promise { + try { + if (isSubagentLikeExecutionContext()) { + throw new Error('Only the top-level session can register sources'); + } + const service = this.config.getSessionSourceService(); + if (!service) throw new Error('Session source service unavailable'); + if (this.params.locator.type === 'attachment') + throw new Error('The tool accepts only workspace files and URLs'); + const { source } = await service.upsert(this.params); + const message = `Reference added: ${source.id}`; + return { llmContent: message, returnDisplay: message }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Source registration failed'; + return { + llmContent: message, + returnDisplay: message, + error: { message, type: ToolErrorType.EXECUTION_FAILED }, + }; + } + } +} + +export class RecordSourceTool extends BaseDeclarativeTool< + SessionSourceInput, + ToolResult +> { + static readonly Name = ToolNames.RECORD_SOURCE; + constructor(private readonly config: Config) { + super( + RecordSourceTool.Name, + ToolDisplayNames.RECORD_SOURCE, + 'Adds a file or HTTP(S) link to this session reference list. Registration stores metadata only: it does not read or send resource contents, and does not prove the assistant used the reference. Use record_artifact for newly produced deliverables.', + Kind.Other, + { + type: 'object', + additionalProperties: false, + required: ['title', 'locator'], + properties: { + title: { type: 'string', maxLength: 200 }, + description: { type: 'string', maxLength: 1000 }, + locator: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + required: ['type', 'workspacePath'], + properties: { + type: { type: 'string', const: 'workspace_file' }, + workspacePath: { type: 'string', maxLength: 500 }, + }, + }, + { + type: 'object', + additionalProperties: false, + required: ['type', 'url'], + properties: { + type: { type: 'string', const: 'url' }, + url: { type: 'string', maxLength: 2048 }, + }, + }, + ], + }, + }, + }, + true, + false, + true, + false, + 'source reference file link', + ); + } + protected override validateToolParamValues( + params: SessionSourceInput, + ): string | null { + try { + const input = validateSessionSourceInput(params); + return input.locator.type === 'attachment' + ? 'The tool accepts only workspace files and URLs' + : null; + } catch (error) { + return error instanceof Error ? error.message : 'Invalid source'; + } + } + protected createInvocation( + params: SessionSourceInput, + ): ToolInvocation { + return new RecordSourceInvocation(params, this.config); + } +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 4756e7d7672..6afddbe7b05 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -62,6 +62,7 @@ export const ToolNames = { WORKFLOW: 'workflow', ARTIFACT: 'artifact', RECORD_ARTIFACT: 'record_artifact', + RECORD_SOURCE: 'record_source', REPORT_FINDINGS: 'report_findings', GET_GOAL: 'get_goal', UPDATE_GOAL: 'update_goal', @@ -119,6 +120,7 @@ export const ToolDisplayNames = { WORKFLOW: 'Workflow', ARTIFACT: 'Artifact', RECORD_ARTIFACT: 'RecordArtifact', + RECORD_SOURCE: 'RecordSource', REPORT_FINDINGS: 'ReportFindings', GET_GOAL: 'Goal', UPDATE_GOAL: 'UpdateGoal', diff --git a/packages/core/src/utils/conversation-branches.test.ts b/packages/core/src/utils/conversation-branches.test.ts index 03961d5d971..80ed7bf5d1f 100644 --- a/packages/core/src/utils/conversation-branches.test.ts +++ b/packages/core/src/utils/conversation-branches.test.ts @@ -188,6 +188,11 @@ describe('inspectConversationBranches', () => { 'session_artifact_snapshot', ), system('turn-result', 'conversation-leaf', 'turn_result'), + system( + 'sources-snapshot', + 'conversation-leaf', + 'session_sources_snapshot', + ), ]; expect( @@ -202,6 +207,7 @@ describe('inspectConversationBranches', () => { 'custom_title', 'session_artifact_event', 'session_artifact_snapshot', + 'session_sources_snapshot', 'turn_result', ] as const; diff --git a/packages/core/src/utils/conversation-branches.ts b/packages/core/src/utils/conversation-branches.ts index b549bb18973..4180ff00e09 100644 --- a/packages/core/src/utils/conversation-branches.ts +++ b/packages/core/src/utils/conversation-branches.ts @@ -17,6 +17,7 @@ const NEUTRAL_TAIL_SUBTYPES = new Set([ 'custom_title', 'session_artifact_event', 'session_artifact_snapshot', + 'session_sources_snapshot', 'turn_result', ]); diff --git a/packages/core/src/utils/jsonl-utils.ts b/packages/core/src/utils/jsonl-utils.ts index cb0a8ba94fd..c13c3cc91e1 100644 --- a/packages/core/src/utils/jsonl-utils.ts +++ b/packages/core/src/utils/jsonl-utils.ts @@ -33,6 +33,7 @@ const debugLogger = createDebugLogger('JSONL'); type JsonlReadOptions = { throwOnNonEnoentError?: boolean; + onIncompleteRead?: () => void; }; type JsonlReadLinesOptions = { @@ -146,7 +147,7 @@ export function _recoverObjectsFromLine(line: string): T[] { * forwarding scalars or arrays would trip property accesses in callers * (`record.type`, `record.uuid`). */ -function parseLineTolerantWithIntegrity( +export function parseLineTolerantWithIntegrity( line: string, filePath: string, ): ParsedJsonlLine { @@ -297,14 +298,15 @@ export async function read( for await (const line of rl) { const trimmed = line.trim(); if (trimmed.length === 0) continue; - for (const obj of parseLineTolerant(trimmed, filePath)) { - results.push(obj); - } + const parsed = parseLineTolerantWithIntegrity(trimmed, filePath); + if (!parsed.complete) options.onIncompleteRead?.(); + for (const obj of parsed.records) results.push(obj); } return results; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + options.onIncompleteRead?.(); debugLogger.error(`Error reading ${filePath}:`, error); if (options.throwOnNonEnoentError) { throw error; diff --git a/packages/core/src/utils/transcript-records.ts b/packages/core/src/utils/transcript-records.ts index 2552c694c5a..b3339ab2f01 100644 --- a/packages/core/src/utils/transcript-records.ts +++ b/packages/core/src/utils/transcript-records.ts @@ -127,6 +127,7 @@ const KNOWN_RECORD_SUBTYPES = new Set([ 'file_history_snapshot', 'session_source', 'session_model', + 'session_sources_snapshot', 'branch_checkpoint', 'goal_state', 'goal_runtime', @@ -255,7 +256,10 @@ function diagnostic( export function isTranscriptConversationRecord( record: Pick, ): boolean { - return !isTranscriptArtifactRecord(record); + return ( + !isTranscriptArtifactRecord(record) && + !(record.type === 'system' && record.subtype === 'session_sources_snapshot') + ); } export function isTranscriptArtifactRecord(record: { diff --git a/packages/live-host/src/main/__tests__/package-manager.test.ts b/packages/live-host/src/main/__tests__/package-manager.test.ts new file mode 100644 index 00000000000..8982e00750d --- /dev/null +++ b/packages/live-host/src/main/__tests__/package-manager.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + determinePackageManagerEnv, + PM, +} from 'app-builder-lib/out/node-module-collector/index.js'; + +it('packages Live Host with its standalone npm dependency tree', async () => { + const packageRoot = resolve( + fileURLToPath(new URL('../../../', import.meta.url)), + ); + const environment = await determinePackageManagerEnv({ + projectDir: packageRoot, + appDir: packageRoot, + workspaceRoot: undefined, + }).value; + + assert.equal(environment.pm, PM.NPM); + assert.equal(await environment.workspaceRoot, packageRoot); +}); diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index c8751d9f6fb..7a739687df7 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -200,6 +200,10 @@ import type { DaemonSessionArtifactInput, DaemonSessionArtifactMutationResult, DaemonSessionArtifactsEnvelope, + SessionSourceInput, + SessionSourcesResult, + SessionSourceUpsertResult, + SessionSourceRemoveResult, DaemonRewindSnapshotInfo, DaemonRewindResult, ForkSessionRequest, @@ -6121,6 +6125,41 @@ export class DaemonClient { this.transport.dispose(); } + listSessionSources( + sessionId: string, + clientId?: string, + ): Promise { + return this.jsonRequest( + `/session/${urlEncode(sessionId)}/sources`, + 'GET /session/:id/sources', + { clientId, mode: 'rest' }, + ); + } + + upsertSessionSource( + sessionId: string, + source: SessionSourceInput, + clientId?: string, + ): Promise { + return this.jsonRequest( + `/session/${urlEncode(sessionId)}/sources`, + 'POST /session/:id/sources', + { method: 'POST', body: source, clientId, mode: 'rest' }, + ); + } + + removeSessionSource( + sessionId: string, + sourceId: string, + clientId?: string, + ): Promise { + return this.jsonRequest( + `/session/${urlEncode(sessionId)}/sources/${urlEncode(sourceId)}`, + 'DELETE /session/:id/sources/:sourceId', + { method: 'DELETE', clientId, mode: 'rest' }, + ); + } + // -- Session artifacts --------------------------------------------------- async listSessionArtifacts( diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 2bd0067d4f9..75a21f52539 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -50,6 +50,10 @@ import type { DaemonSessionArtifactInput, DaemonSessionArtifactMutationResult, DaemonSessionArtifactsEnvelope, + SessionSourceInput, + SessionSourcesResult, + SessionSourceUpsertResult, + SessionSourceRemoveResult, DaemonSessionState, DaemonSession, DaemonSessionStatsStatus, @@ -849,6 +853,26 @@ export class DaemonSessionClient { return this.client.heartbeat(this.sessionId, this.clientId); } + listSources(): Promise { + return this.client.listSessionSources(this.sessionId, this.clientId); + } + + upsertSource(source: SessionSourceInput): Promise { + return this.client.upsertSessionSource( + this.sessionId, + source, + this.clientId, + ); + } + + removeSource(sourceId: string): Promise { + return this.client.removeSessionSource( + this.sessionId, + sourceId, + this.clientId, + ); + } + artifacts(): Promise { return this.client.listSessionArtifacts(this.sessionId, this.clientId); } diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index a026b4c8934..d0c64cb5b91 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -49,6 +49,7 @@ export const DAEMON_KNOWN_EVENT_TYPE_VALUES = [ 'session_metadata_updated', 'session_recording_degraded', 'artifact_changed', + 'source_changed', MID_TURN_MESSAGE_INJECTED_EVENT, PENDING_PROMPT_ADDED_EVENT, PENDING_PROMPT_STARTED_EVENT, @@ -303,6 +304,17 @@ export interface DaemonSessionMetadataUpdatedData { [key: string]: unknown; } +export interface DaemonSourceChangedData { + sessionId: string; + revision: number; + [key: string]: unknown; +} + +export type DaemonSourceChangedEvent = DaemonEventEnvelope< + 'source_changed', + DaemonSourceChangedData +>; + export interface DaemonArtifactChangedData { sessionId: string; change: DaemonSessionArtifactChange; @@ -1276,6 +1288,7 @@ export type DaemonTurnEvent = DaemonTurnCompleteEvent | DaemonTurnErrorEvent; export type KnownDaemonEvent = | DaemonSessionEvent + | DaemonSourceChangedEvent | DaemonControlEvent | DaemonStreamLifecycleEvent | DaemonMcpGuardrailEvent @@ -1681,6 +1694,13 @@ export function asKnownDaemonEvent( return isSessionRecordingDegradedData(event.data) ? (event as DaemonSessionRecordingDegradedEvent) : undefined; + case 'source_changed': + return isRecord(event.data) && + typeof event.data['sessionId'] === 'string' && + Number.isInteger(event.data['revision']) && + Number(event.data['revision']) >= 0 + ? (event as DaemonSourceChangedEvent) + : undefined; case 'artifact_changed': return isArtifactChangedData(event.data) ? (event as DaemonArtifactChangedEvent) @@ -2225,6 +2245,7 @@ export function reduceDaemonSessionEvent( case 'settings_reloaded': case 'extensions_changed': case 'artifact_changed': + case 'source_changed': case MID_TURN_MESSAGE_INJECTED_EVENT: case PENDING_PROMPT_ADDED_EVENT: case PENDING_PROMPT_STARTED_EVENT: diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 4406e59c867..3e62b14fe7b 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -223,6 +223,7 @@ export type { DaemonUiSessionApprovalModeChangedEvent, DaemonUiSessionAvailableCommandsEvent, DaemonUiSessionMetadataChangedEvent, + DaemonUiSessionSourceChangedEvent, DaemonUiShellOutputEvent, DaemonUiStateResyncRequiredEvent, DaemonUiReplayCompleteEvent, @@ -252,6 +253,8 @@ export { export type { DaemonAgentChangedData, DaemonAgentChangedEvent, + DaemonSourceChangedData, + DaemonSourceChangedEvent, DaemonArtifactChangedData, DaemonArtifactChangedEvent, DaemonApprovalModeChangedData, @@ -825,6 +828,13 @@ export type { DaemonSessionArtifactRestoreState, DaemonSessionArtifactRetention, DaemonSessionArtifactsEnvelope, + SessionSource, + SessionSourceLocator, + SessionSourceInput, + SessionSourcesResult, + SessionSourcesSnapshot, + SessionSourceUpsertResult, + SessionSourceRemoveResult, DaemonSessionArtifactSource, DaemonSessionArtifactStatus, DaemonSessionArtifactStorage, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index f80825ac8ca..8733d5f436c 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1282,6 +1282,7 @@ export interface DaemonBranchPoint { } export interface DaemonPersistedBranchedSession { + sourceWarnings?: string[]; sessionId: string; displayName: string; forkedFrom: { sessionId: string; displayName: string }; @@ -1300,6 +1301,7 @@ export interface SideTaskSessionRequest { } export interface DaemonSideTaskSession extends DaemonRestoredSession { + sourceWarnings?: string[]; displayName: string; parentSessionId: string; } @@ -1661,6 +1663,45 @@ export interface SessionMetadataResult { type OpenStringUnion = T | (string & {}); /** Known artifact kinds mirrored from the daemon/core contract. */ +export type SessionSourceLocator = + | { type: 'workspace_file'; workspacePath: string } + | { type: 'attachment'; attachmentId: string } + | { type: 'url'; url: string }; + +export interface SessionSourceInput { + title: string; + locator: SessionSourceLocator; + description?: string; +} + +export interface SessionSource extends SessionSourceInput { + id: string; + kind: 'file' | 'link'; + workspaceCwd?: string; + createdAt: string; + updatedAt: string; +} + +export interface SessionSourcesResult { + revision: number; + sources: SessionSource[]; +} + +export interface SessionSourcesSnapshot extends SessionSourcesResult { + version: 1; +} + +export interface SessionSourceUpsertResult { + revision: number; + source: SessionSource; + change: 'created' | 'updated' | 'unchanged'; +} + +export interface SessionSourceRemoveResult { + revision: number; + removed: boolean; +} + export type KnownDaemonSessionArtifactKind = | 'file' | 'link' diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index 22249843947..754e6ba550d 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -121,6 +121,7 @@ export type { DaemonUiToolProvenance, // Session-meta events DaemonUiSessionMetadataChangedEvent, + DaemonUiSessionSourceChangedEvent, DaemonUiSessionApprovalModeChangedEvent, DaemonUiSessionAvailableCommandsEvent, DaemonUiStateResyncRequiredEvent, diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index f1f77af1247..c93cc9d6df1 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -372,6 +372,19 @@ export function normalizeDaemonEvent( case 'extensions_changed': return normalizeExtensionsChanged(event, base); + case 'source_changed': { + const sessionId = getString(event.data, 'sessionId'); + const revision = isRecord(event.data) + ? event.data['revision'] + : undefined; + return sessionId && + typeof revision === 'number' && + Number.isInteger(revision) && + revision >= 0 + ? [{ ...base, type: 'session.source.changed', sessionId, revision }] + : []; + } + case 'artifact_changed': return normalizeArtifactChanged(event, base); diff --git a/packages/sdk-typescript/src/daemon/ui/terminal.ts b/packages/sdk-typescript/src/daemon/ui/terminal.ts index e5961ca96ec..67e8654d929 100644 --- a/packages/sdk-typescript/src/daemon/ui/terminal.ts +++ b/packages/sdk-typescript/src/daemon/ui/terminal.ts @@ -58,6 +58,8 @@ export function daemonUiEventToTerminalText(event: DaemonUiEvent): string { `metadata: ${event.displayName ?? '(no display name)'}`, '36', ); + case 'session.source.changed': + return ''; case 'session.artifact.changed': return terminalLine( 'artifact', diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 45ff728efe1..deeb2c16f39 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -483,6 +483,7 @@ function applyDaemonTranscriptEvent( break; case 'session.metadata.changed': case 'session.artifact.changed': + case 'session.source.changed': case 'session.available_commands': // Intentional no-op against `blocks[]`. break; diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index ade558dc24f..822a7ae896d 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -38,6 +38,7 @@ export type DaemonUiEventType = // Session-meta events | 'session.metadata.changed' | 'session.artifact.changed' + | 'session.source.changed' | 'session.approval_mode.changed' | 'session.available_commands' | 'session.state_resync_required' @@ -423,6 +424,12 @@ export interface DaemonUiSessionMetadataChangedEvent extends DaemonUiEventBase { displayName?: string; } +export interface DaemonUiSessionSourceChangedEvent extends DaemonUiEventBase { + type: 'session.source.changed'; + sessionId: string; + revision: number; +} + export interface DaemonUiSessionArtifactChangedEvent extends DaemonUiEventBase { type: 'session.artifact.changed'; sessionId: string; @@ -724,6 +731,7 @@ export type DaemonUiEvent = // Session-meta events | DaemonUiSessionMetadataChangedEvent | DaemonUiSessionArtifactChangedEvent + | DaemonUiSessionSourceChangedEvent | DaemonUiSessionApprovalModeChangedEvent | DaemonUiSessionAvailableCommandsEvent | DaemonUiStateResyncRequiredEvent diff --git a/packages/sdk-typescript/test/unit/SessionSources.test.ts b/packages/sdk-typescript/test/unit/SessionSources.test.ts new file mode 100644 index 00000000000..dfecbf147e8 --- /dev/null +++ b/packages/sdk-typescript/test/unit/SessionSources.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import { DaemonClient } from '../../src/daemon/DaemonClient.js'; +import { + asKnownDaemonEvent, + reduceDaemonSessionEvent, + createDaemonSessionViewState, +} from '../../src/daemon/events.js'; +import { + createDaemonTranscriptState, + normalizeDaemonEvent, + reduceDaemonTranscriptEvents, +} from '../../src/daemon/ui/index.js'; +import type { DaemonTransport } from '../../src/daemon/DaemonTransport.js'; + +describe('session sources', () => { + it('uses owner-routed REST and bound client identity with an ACP transport', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const restFetch = vi.fn( + async (url: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return new Response( + JSON.stringify({ revision: 1, sources: [], removed: true }), + { headers: { 'content-type': 'application/json' } }, + ); + }, + ); + const transport: DaemonTransport = { + type: 'acp-http', + supportsReplay: false, + connected: true, + restFetch, + fetch: vi.fn(), + subscribeEvents: vi.fn(), + dispose: vi.fn(), + }; + const client = new DaemonClient({ baseUrl: 'http://daemon', transport }); + const input = { + title: 'Requirements', + locator: { type: 'url' as const, url: 'https://example.com/#section' }, + }; + await client.listSessionSources('session/1', 'client-1'); + await client.upsertSessionSource('session/1', input, 'client-1'); + await client.removeSessionSource('session/1', 'source/1', 'client-1'); + expect(transport.fetch).not.toHaveBeenCalled(); + expect(calls.map((call) => call.url)).toEqual([ + 'http://daemon/session/session%2F1/sources', + 'http://daemon/session/session%2F1/sources', + 'http://daemon/session/session%2F1/sources/source%2F1', + ]); + expect( + calls.map((call) => + new Headers(call.init?.headers).get('x-qwen-client-id'), + ), + ).toEqual(['client-1', 'client-1', 'client-1']); + expect(calls[1]?.init?.body).toBe(JSON.stringify(input)); + expect(calls[2]?.init?.method).toBe('DELETE'); + }); + + it('recognizes invalidation and never creates conversation bubbles', () => { + const event = { + type: 'source_changed', + id: 7, + data: { sessionId: 'session-a', revision: 2 }, + }; + expect(asKnownDaemonEvent(event)?.type).toBe('source_changed'); + const normalized = normalizeDaemonEvent(event); + expect(normalized).toMatchObject([ + { type: 'session.source.changed', sessionId: 'session-a', revision: 2 }, + ]); + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState(), + normalized, + ); + expect(state.blocks).toEqual([]); + expect(state.lastEventId).toBe(7); + expect( + reduceDaemonSessionEvent(createDaemonSessionViewState(), event) + .unrecognizedKnownEventCount, + ).toBe(0); + expect(normalizeDaemonEvent({ ...event, data: { revision: 2 } })).toEqual( + [], + ); + for (const revision of ['invalid', -1, 1.5]) { + expect( + normalizeDaemonEvent({ + ...event, + data: { sessionId: 'session-a', revision }, + }), + ).toEqual([]); + } + }); +}); diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 2d7f986c7a1..812a2dcea5e 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -25,6 +25,8 @@ import { type DaemonWorkspaceMcpServerStatus, type DaemonWorkspaceGitStatus, type GoalSnapshotV2, + type SessionSource, + type SessionSourcesResult, } from '@qwen-code/sdk/daemon'; import type { WebShellApi } from './App'; import { DEFAULT_SESSION_ACTION_ITEMS } from './components/sidebar/WebShellSidebar'; @@ -129,6 +131,12 @@ type ChatEditorTestProps = { metadata?: { inputAnnotations?: DaemonInputAnnotation[] }, ) => boolean | void; onCancel?: () => void; + onAttachmentPreview?: (file: { + name: string; + attachmentId?: string; + mimeType?: string; + text?: string; + }) => void; onInputTextChange?: (text: string) => void; onAttachmentsChange?: (hasAttachments: boolean) => void; onStartNewSessionSuggestion?: () => void; @@ -437,6 +445,9 @@ const { mimeType: 'text/plain', }), listAttachments: vi.fn().mockResolvedValue([]), + listSources: vi + .fn<() => Promise>() + .mockResolvedValue({ revision: 0, sources: [] }), getTasks: vi.fn().mockResolvedValue({ v: 1, sessionId: 'session-1', @@ -651,6 +662,7 @@ const { | undefined, workspaceEventSignals: { artifactsVersion: 0, + sourcesVersion: 0, extensionsVersion: 0, skillsVersion: 0, lastSkillMutation: undefined as DaemonSkillToggleMutation | undefined, @@ -2484,36 +2496,376 @@ describe('task activity key', () => { ).toBeNull(); }); - it('lists current-session attachments in the environment panel', async () => { + it.each([ + { items: ['sources'] as const }, + { items: ['attachments'] as const }, + { items: ['sources', 'attachments'] as const }, + ])( + 'lists current-session attachments under Sources with $items', + async ({ items }) => { + mockConnection.capabilities.features = ['session_attachment_list']; + mockSessionActions.listAttachments.mockResolvedValue([ + { + type: 'resource', + attachmentId: 'notes.txt', + mimeType: 'text/plain', + size: 5, + }, + ]); + const { container } = renderApp({ environmentPanel: { items } }); + await flush(); + + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await flush(); + + expect(mockSessionActions.listAttachments).toHaveBeenCalled(); + const panel = container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ); + expect(panel?.textContent).toContain('Sources'); + expect( + panel?.querySelectorAll('[data-testid="sources-section"]'), + ).toHaveLength(1); + expect(panel?.textContent).not.toContain('Attachments'); + }, + ); + + it('refreshes the open source title and detail when its metadata changes', async () => { + const { source, container, rerender } = await renderOpenSource(); + expect( + container.querySelector('[role="tab"][title="Old source title"]'), + ).not.toBeNull(); + mockSessionActions.listSources.mockResolvedValue({ + revision: 2, + sources: [ + { + ...source, + title: 'New source title', + description: 'Updated description', + }, + ], + }); + testState.workspaceEventSignals = { + ...testState.workspaceEventSignals, + sourcesVersion: 1, + }; + rerender(); + await flush(); + expect( + container.querySelector('[role="tab"][title="New source title"]'), + ).not.toBeNull(); + expect( + container.querySelector('aside[aria-label="Right panel"]')?.textContent, + ).toContain('Updated description'); + expect(container.textContent).not.toContain('Old source title'); + }); + + it.each([ + { change: 'trust', background: false }, + { change: 'removal', background: false }, + { change: 'trust', background: true }, + { change: 'removal', background: true }, + ])( + 'prunes source tabs after $change and closes only an active source (background=$background)', + async ({ change, background }) => { + const { container, rerender } = await renderOpenSource(); + if (background) { + await act(async () => + testState.latestChatEditorProps?.onAttachmentPreview?.({ + name: 'kept.txt', + text: 'Keep open', + }), + ); + } + if (change === 'trust') { + mockWorkspace.capabilities = { + ...mockWorkspace.capabilities, + workspaces: [ + { + id: 'primary', + cwd: '/tmp/project', + primary: true, + trusted: false, + }, + ], + }; + } else { + mockSessionActions.listSources.mockResolvedValue({ + revision: 2, + sources: [], + }); + testState.workspaceEventSignals = { + ...testState.workspaceEventSignals, + sourcesVersion: 1, + }; + } + rerender(); + await flush(); + expect( + container.querySelector('[role="tab"][title="Old source title"]'), + ).toBeNull(); + const panel = container.querySelector('aside[aria-label="Right panel"]'); + const stored = JSON.parse( + localStorage.getItem('qwen-code-web-shell-right-panel-state') ?? '{}', + )['/tmp/project\0session-1']; + expect(stored.open).toBe(background); + if (background) { + expect(panel).not.toBeNull(); + expect(stored.activeTabId).toContain('kept.txt'); + } else { + expect(panel).toBeNull(); + expect(stored.activeTabId).toBeNull(); + } + }, + ); + + it('opens URL sources without a workspace in a standalone session', async () => { + mockConnection.workspaceCwd = undefined; + mockConnection.sessionContext = { kind: 'standalone' }; + const { container } = await renderOpenSource(); + const link = container.querySelector( + 'aside[aria-label="Right panel"] a[href="https://example.com"]', + ); + expect(link).not.toBeNull(); + expect(link?.textContent).toBe('Open original'); + expect(mockWorkspaceActions.readWorkspaceFile).not.toHaveBeenCalled(); + }); + + it('keeps standalone workspace sources unavailable without reading files', async () => { + mockConnection.workspaceCwd = undefined; + mockConnection.sessionContext = { kind: 'standalone' }; + const { container } = await renderOpenSource({ + kind: 'file', + workspaceCwd: '/tmp/project', + locator: { type: 'workspace_file', workspacePath: 'secret.txt' }, + }); + expect( + container.querySelector('aside[aria-label="Right panel"]')?.textContent, + ).toContain('This reference is no longer available in this workspace.'); + expect(mockWorkspaceActions.readWorkspaceFile).not.toHaveBeenCalled(); + expect(mockWorkspaceActions.stat).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + 'does not make an unsafe source URL clickable (standalone=%s)', + async (standalone) => { + if (standalone) { + mockConnection.workspaceCwd = undefined; + mockConnection.sessionContext = { kind: 'standalone' }; + } + const { container } = await renderOpenSource({ + locator: { type: 'url', url: 'javascript:alert(1)' }, + }); + const panel = container.querySelector('aside[aria-label="Right panel"]'); + expect(panel?.textContent).toContain('javascript:alert(1)'); + expect(panel?.querySelector('a[href]')).toBeNull(); + }, + ); + + it.each([true, false])( + 'keeps HTML source and ordinary attachment previews independent (source first=%s)', + async (sourceFirst) => { + mockConnection.capabilities.features = ['session_attachment_list']; + mockSessionActions.listAttachments.mockResolvedValue([ + { + type: 'resource', + attachmentId: 'page.html', + mimeType: 'text/html', + size: 20, + }, + ]); + mockSessionActions.readAttachment.mockResolvedValue({ + data: btoa('

Page

'), + mimeType: 'text/html', + }); + const { container, unmount } = renderApp({ + environmentPanel: { items: ['sources'] }, + }); + await flush(); + await act(async () => + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(), + ); + await flush(); + const openSource = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + const button = container.querySelector( + '[data-testid="sources-section"] button[title="page.html"]', + ); + expect(button).not.toBeNull(); + await act(async () => button!.click()); + await flush(); + expect( + container.querySelector('button[aria-label="Preview"]'), + ).toBeNull(); + }; + const openOrdinary = async () => { + await act(async () => + testState.latestChatEditorProps?.onAttachmentPreview?.({ + name: 'page.html', + attachmentId: 'page.html', + mimeType: 'text/html', + }), + ); + await flush(); + expect( + container.querySelector('button[aria-label="Preview"]'), + ).not.toBeNull(); + }; + if (sourceFirst) { + await openSource(); + await openOrdinary(); + } else { + await openOrdinary(); + await openSource(); + } + const stored = JSON.parse( + localStorage.getItem('qwen-code-web-shell-right-panel-state') ?? '{}', + )['/tmp/project\0session-1']; + expect(stored.tabs).toHaveLength(2); + expect( + new Set(stored.tabs.map((tab: { id: string }) => tab.id)).size, + ).toBe(2); + await openSource(); + expect(container.querySelector('iframe')).toBeNull(); + await openOrdinary(); + expect( + container.querySelectorAll('[role="tab"][title="page.html"]'), + ).toHaveLength(2); + unmount(); + const restored = renderApp(); + await flush(); + const tabs = restored.container.querySelectorAll( + '[role="tab"][title="page.html"]', + ); + expect(tabs).toHaveLength(2); + for (const [index, tab] of Array.from(tabs).entries()) { + await act(async () => tab.click()); + await flush(); + expect( + Boolean( + restored.container.querySelector('button[aria-label="Preview"]'), + ), + ).toBe(!stored.tabs[index].sourcePreview); + expect(restored.container.querySelector('iframe')).toBeNull(); + } + }, + ); + + it('opens historical HTML from Sources as text and retains that policy after reload', async () => { mockConnection.capabilities.features = ['session_attachment_list']; mockSessionActions.listAttachments.mockResolvedValue([ { type: 'resource', - attachmentId: 'notes.txt', - mimeType: 'text/plain', - size: 5, + attachmentId: 'historical.html', + mimeType: 'text/html', + size: 20, }, ]); - const { container } = renderApp(); + mockSessionActions.readAttachment.mockResolvedValue({ + data: btoa('

Source only

'), + mimeType: 'text/html', + }); + const first = renderApp({ environmentPanel: { items: ['sources'] } }); await flush(); - - act(() => { - container + act(() => + first.container .querySelector( 'button[aria-label="Toggle environment information"]', ) - ?.click(); - }); + ?.click(), + ); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); await flush(); + const row = first.container.querySelector( + '[data-testid="sources-section"] button[title="historical.html"]', + ); + expect(row).not.toBeNull(); + await act(async () => row?.click()); + await flush(); + const stored = JSON.parse( + window.localStorage.getItem('qwen-code-web-shell-right-panel-state') ?? + '{}', + )['/tmp/project\0session-1']; + expect(stored?.tabs).toEqual([ + expect.objectContaining({ + kind: 'file', + attachmentId: 'historical.html', + sourcePreview: true, + }), + ]); + expect( + first.container.querySelector('button[aria-label="Preview"]'), + ).toBeNull(); + expect(first.container.querySelector('iframe')).toBeNull(); + act(() => first.unmount()); + const second = renderApp(); + await flush(); + expect(mockSessionActions.readAttachment).toHaveBeenCalledTimes(2); + expect( + second.container.querySelector('aside[aria-label="Right panel"]'), + ).not.toBeNull(); + expect( + second.container.querySelector('button[aria-label="Preview"]'), + ).toBeNull(); + expect(second.container.querySelector('iframe')).toBeNull(); + }); - expect(mockSessionActions.listAttachments).toHaveBeenCalled(); - const panel = container.querySelector( - '[data-testid="environment-panel"]:not([hidden])', + it('shows attachment listing failures in Sources with a working retry', async () => { + vi.useFakeTimers(); + mockConnection.capabilities.features = ['session_attachment_list']; + mockSessionActions.listAttachments.mockRejectedValue( + new Error('Listing unavailable'), + ); + const { container } = renderApp({ + environmentPanel: { items: ['sources'] }, + }); + await flush(); + act(() => + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(), + ); + for (const delay of [0, 1_000, 0, 1_000, 0]) { + await act(async () => vi.advanceTimersByTimeAsync(delay)); + } + expect(mockSessionActions.listAttachments).toHaveBeenCalledTimes(3); + const alert = container.querySelector( + '[data-testid="sources-section"] [role="alert"]', ); - expect(panel?.textContent).toContain('Attachments'); + expect(alert?.textContent).toContain('Listing unavailable'); + mockSessionActions.listAttachments.mockResolvedValue([ + { + type: 'resource', + attachmentId: 'recovered.txt', + mimeType: 'text/plain', + size: 2, + }, + ]); + act(() => alert?.querySelector('button')?.click()); + await act(async () => vi.advanceTimersByTimeAsync(1_000)); + expect(container.textContent).toContain('recovered.txt'); + expect( + container.querySelector('[data-testid="sources-section"] [role="alert"]'), + ).toBeNull(); }); it('retries a failed first attachment listing without flashing empty', async () => { @@ -9335,6 +9687,50 @@ describe('environment agent tasks', () => { }); }); +async function renderOpenSource(overrides: Partial = {}) { + const source: SessionSource = { + id: 'source-1', + title: 'Old source title', + kind: 'link', + locator: { type: 'url', url: 'https://example.com' }, + createdAt: '2026-09-07T00:00:00Z', + updatedAt: '2026-09-07T00:00:00Z', + ...overrides, + }; + mockConnection.capabilities.features = ['session_sources']; + mockWorkspace.capabilities = { + workspaceCwd: '/tmp/project', + workspaces: [ + { id: 'primary', cwd: '/tmp/project', primary: true, trusted: true }, + ], + } as typeof mockWorkspace.capabilities; + mockSessionActions.listSources.mockResolvedValue({ + revision: 1, + sources: [source], + }); + const view = renderApp({ environmentPanel: { items: ['sources'] } }); + await flush(); + await act(async () => + view.container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(), + ); + await flush(); + expect(mockSessionActions.listSources).toHaveBeenCalled(); + const sourceButton = view.container.querySelector( + '[data-testid="sources-section"] button[aria-label="Open source Old source title"]', + ); + expect(sourceButton).not.toBeNull(); + await act(async () => sourceButton?.click()); + await flush(); + expect( + view.container.querySelector('aside[aria-label="Right panel"]'), + ).not.toBeNull(); + return { ...view, source }; +} + function renderApp(props: React.ComponentProps = {}): { container: HTMLElement; rerender: (nextProps?: React.ComponentProps) => void; @@ -9598,6 +9994,7 @@ beforeEach(() => { testState.ownerVersion = 0; testState.workspaceEventSignals = { artifactsVersion: 0, + sourcesVersion: 0, extensionsVersion: 0, skillsVersion: 0, lastSkillMutation: undefined, @@ -9860,6 +10257,11 @@ beforeEach(() => { data: 'aGVsbG8=', mimeType: 'text/plain', }); + mockSessionActions.listSources.mockReset(); + mockSessionActions.listSources.mockResolvedValue({ + revision: 0, + sources: [], + }); mockSessionActions.getTasks.mockResolvedValue({ v: 1, sessionId: 'session-1', diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 0f9d20bf7a2..ec91873529c 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -266,6 +266,8 @@ import { projectStreamingTailMessages, useMessagesFromBlocks, } from './hooks/useMessages'; +import { useSessionSources } from './hooks/useSessionSources'; +import type { SessionSource } from '@qwen-code/sdk/daemon'; import { useSessionArtifacts } from './hooks/useSessionArtifacts'; import { useSessionArtifactsChange } from './hooks/useSessionArtifactsChange'; import { useShallowMemo, useStableArray } from './hooks/useShallowMemo'; @@ -1428,7 +1430,7 @@ const DEFAULT_RIGHT_PANEL_ITEMS: readonly WebShellRightPanelItem[] = [ 'sideTask', ]; const DEFAULT_ENVIRONMENT_PANEL_ITEMS: readonly WebShellEnvironmentPanelItem[] = - ['environment', 'subagents', 'backgroundTasks', 'attachments', 'artifacts']; + ['environment', 'sources', 'subagents', 'backgroundTasks', 'artifacts']; const ATTACHMENTS_REFRESH_INTERVAL_MS = 1000; const SESSION_AGENTS_REFRESH_INTERVAL_MS = 3000; const SESSION_AGENTS_MAX_RETRY_INTERVAL_MS = 30_000; @@ -1622,6 +1624,7 @@ type PersistedArtifactPanelTab = | 'workspaceId' | 'previewMimeType' | 'previewOnly' + | 'sourcePreview' | 'attachmentId' | 'sourceSessionId' > @@ -1707,6 +1710,8 @@ function parsePersistedArtifactPanelTab( optionalStrings.some( (key) => tab[key] !== undefined && typeof tab[key] !== 'string', ) || + (tab['sourcePreview'] !== undefined && + typeof tab['sourcePreview'] !== 'boolean') || (tab['previewOnly'] !== undefined && typeof tab['previewOnly'] !== 'boolean') || (tab['closeWithPane'] !== undefined && @@ -1740,6 +1745,7 @@ function parsePersistedArtifactPanelTab( workspaceId: tab['workspaceId'], previewMimeType: tab['previewMimeType'], previewOnly: tab['previewOnly'], + sourcePreview: tab['sourcePreview'], attachmentId: tab['attachmentId'], sourceSessionId: tab['sourceSessionId'], } as PersistedArtifactPanelTab; @@ -1860,6 +1866,8 @@ function serializeArtifactPanelTabs( return tabs.flatMap((tab): PersistedArtifactPanelTab[] => { const { id, title } = tab; switch (tab.kind) { + case 'source': + return []; case 'review': return [ { @@ -1887,6 +1895,7 @@ function serializeArtifactPanelTabs( workspaceId: tab.workspaceId, previewMimeType: tab.previewMimeType, previewOnly: tab.previewOnly, + sourcePreview: tab.sourcePreview, attachmentId: tab.attachmentId, sourceSessionId: tab.sourceSessionId, }, @@ -3004,6 +3013,9 @@ export function App({ chatHeaderEnabled && environmentHeaderItemVisible && (!renderChatHeader || Boolean(header)); + const environmentSourcesEnabled = + environmentPanelItems.includes('sources') || + environmentPanelItems.includes('attachments'); const environmentGitReplacementEnabled = environmentPanelReachable && environmentPanelItems.includes('environment'); const environmentTasksReplacementEnabled = @@ -4033,6 +4045,19 @@ export function App({ refresh: refreshArtifacts, hydrated: artifactsHydrated, } = useSessionArtifacts(); + const sourcesState = useSessionSources(); + const refreshSources = sourcesState.refresh; + const [sourceRegistrationRetries, setSourceRegistrationRetries] = useState< + Array<() => Promise> + >([]); + useEffect(() => { + setSourceRegistrationRetries([]); + }, [sourcesState.owner]); + const retrySourceRegistrations = useCallback(async () => { + setSourceRegistrationRetries([]); + await Promise.allSettled(sourceRegistrationRetries.map((retry) => retry())); + await refreshSources(); + }, [sourceRegistrationRetries, refreshSources]); const artifactsRef = useRef(artifacts); artifactsRef.current = artifacts; const [artifactPanelExtraArtifacts, setArtifactPanelExtraArtifacts] = @@ -4301,12 +4326,16 @@ export function App({ sessionAttachmentsOwnerRef.current = sessionOwnerGuard.capture(); } const sessionAttachmentsOwner = sessionAttachmentsOwnerRef.current; + const [sessionAttachmentsError, setSessionAttachmentsError] = useState<{ + owner: DaemonSessionOwnerSnapshot; + message: string; + }>(); const sessionAttachmentsBySessionRef = useRef( new Map(), ); const sessionAttachmentsSkeletonLoading = environmentPanelReachable && - environmentPanelItems.includes('attachments') && + environmentSourcesEnabled && (sessionAttachmentsLoading || Boolean( environmentPanelOpen && @@ -4320,15 +4349,13 @@ export function App({ const sessionAttachmentsRequestIdRef = useRef(0); const attachmentRetryCountRef = useRef(new Map()); const [attachmentRefreshNonce, setAttachmentRefreshNonce] = useState(0); - // The attachments panel is fed by the daemon's attachment store, never by - // parsing transcript blocks. Refetch while the panel is open whenever the - // transcript moves (a sent message is the only way the store gains - // attachments) — throttled so streaming appends do not hammer the route. + // Uploaded sources come from the daemon attachment store. Refresh on + // transcript updates while the panel is open, throttled during streaming. const transcriptRevision = blockChangeSummary?.revision ?? 0; const sessionAttachmentsRequestEligibleRef = useRef(false); sessionAttachmentsRequestEligibleRef.current = environmentPanelReachable && - environmentPanelItems.includes('attachments') && + environmentSourcesEnabled && environmentPanelOpen && connection.status === 'connected' && Boolean(connection.sessionId && logicalSessionKey) && @@ -4337,8 +4364,7 @@ export function App({ ) === true; useEffect(() => { const attachmentsSectionEnabled = - environmentPanelReachable && - environmentPanelItems.includes('attachments'); + environmentPanelReachable && environmentSourcesEnabled; const attachmentsSupported = connection.capabilities?.features.includes( SESSION_ATTACHMENT_LIST_FEATURE, @@ -4354,9 +4380,11 @@ export function App({ attachmentRetryCountRef.current.delete(logicalSessionKey); } setSessionAttachmentsLoading(false); + setSessionAttachmentsError(undefined); return; } if (!attachmentsSupported) { + setSessionAttachmentsError(undefined); attachmentRetryCountRef.current.delete(logicalSessionKey); setBoundedMapEntry( sessionAttachmentsBySessionRef.current, @@ -4387,6 +4415,7 @@ export function App({ fetchedAt: Date.now(), }; const requestId = ++sessionAttachmentsRequestIdRef.current; + setSessionAttachmentsError(undefined); const listing = sessionActions.listAttachments(); void listing .then((attachments) => { @@ -4405,7 +4434,7 @@ export function App({ setSessionAttachmentsLoading(false); } }) - .catch(() => { + .catch((error: unknown) => { if (!cancelled && sessionAttachmentsOwner.isCurrent()) { if (firstLoad) { const failures = @@ -4431,6 +4460,10 @@ export function App({ ); setSessionAttachments([]); } + setSessionAttachmentsError({ + owner: sessionAttachmentsOwner, + message: formatError(error, t('environment.unavailable')), + }); setSessionAttachmentsLoading(false); } }); @@ -4453,6 +4486,8 @@ export function App({ transcriptRevision, sessionActions, sessionAttachmentsOwner, + environmentSourcesEnabled, + t, ]); const artifactPanelOpenRef = useRef(artifactPanelOpen); artifactPanelOpenRef.current = artifactPanelOpen; @@ -4664,6 +4699,7 @@ export function App({ const pending = sideTaskCreationPromisesRef.current.get(tabId); if (pending) return pending; const creation = (async () => { + const owner = sessionOwnerGuard.capture(); const ownerCwd = connection.workspaceCwd; const parentClientId = connection.sessionId === parentSessionId @@ -4676,6 +4712,9 @@ export function App({ }, parentClientId, ); + if (owner.isCurrent() && session.sourceWarnings?.length) { + pushToast('warning', session.sourceWarnings.join(' ')); + } if (ownerCwd) { sessionCatalogController.sessionCreated(ownerCwd, session.sessionId); } @@ -4694,6 +4733,8 @@ export function App({ connection.clientId, connection.sessionId, connection.workspaceCwd, + sessionOwnerGuard, + pushToast, sessionCatalogController, workspace.client, ], @@ -4983,6 +5024,83 @@ export function App({ rememberArtifactPanelTrigger, ], ); + const openSourcePanel = useCallback( + (source: SessionSource) => { + if (!sourcesState.owner.isCurrent() || !connection.sessionId) return; + const tab: ArtifactPanelTab = { + id: `source:${connection.sessionId}:${source.id}`, + kind: 'source', + title: source.title, + source, + sourceSessionId: connection.sessionId, + workspaceCwd: connection.workspaceCwd, + workspaceId: artifactWorkspaceTarget?.workspaceId, + owner: sourcesState.owner, + sessionActions, + }; + setArtifactPanelTabs((tabs) => + tabs.some((item) => item.id === tab.id) + ? tabs.map((item) => (item.id === tab.id ? tab : item)) + : [...tabs, tab], + ); + setActiveArtifactPanelTabId(tab.id); + setArtifactPanelWidth((width) => + artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(), + ); + setArtifactPanelOpen(true); + }, + [ + sourcesState.owner, + connection.sessionId, + connection.workspaceCwd, + artifactWorkspaceTarget?.workspaceId, + sessionActions, + getDefaultReviewPanelWidth, + ], + ); + useEffect(() => { + const tabs = artifactPanelTabsRef.current; + const next = tabs.flatMap((tab) => { + if (tab.kind !== 'source') return [tab]; + const fresh = sourcesState.sources.find( + (source) => source.id === tab.source.id, + ); + if ( + !tab.owner.isCurrent() || + (tab.workspaceCwd !== undefined && + artifactWorkspaceCwd === undefined) || + tab.workspaceId !== artifactWorkspaceTarget?.workspaceId || + !sourcesState.supported || + (tab.sourceSessionId === connection.sessionId && + sourcesState.hydrated && + !fresh) + ) + return []; + return fresh && fresh !== tab.source + ? [{ ...tab, source: fresh, title: fresh.title }] + : [tab]; + }); + if (next.length === tabs.length && next.every((tab, i) => tab === tabs[i])) + return; + setArtifactPanelTabs(next); + if ( + activeArtifactPanelTabId && + !next.some((tab) => tab.id === activeArtifactPanelTabId) + ) { + setActiveArtifactPanelTabId(null); + setArtifactPanelOpen(false); + } + }, [ + activeArtifactPanelTabId, + artifactWorkspaceCwd, + artifactWorkspaceTarget?.workspaceId, + sourcesState.owner, + sourcesState.sources, + sourcesState.hydrated, + sourcesState.supported, + connection.sessionId, + ]); + const openReviewPanel = useCallback( ( changes: readonly TurnOutputFileChange[], @@ -5208,6 +5326,7 @@ export function App({ file: AttachmentPreviewRequest, workspaceCwd = connection.workspaceCwd, sourceSessionId = connection.sessionId, + sourcePreview = false, ) => { if ( onWorkspaceFileOpen && @@ -5230,7 +5349,7 @@ export function App({ resolvedFile.attachmentId !== undefined; const tab: ArtifactPanelTab = { id: previewOnly - ? `attachment:${sourceSessionId ?? ''}:${resolvedFile.attachmentId ?? workspacePath}` + ? `${sourcePreview ? 'source-attachment' : 'attachment'}:${sourceSessionId ?? ''}:${resolvedFile.attachmentId ?? workspacePath}` : `file:${workspaceCwd ?? ''}:${workspacePath}`, kind: 'file', title: resolvedFile.name, @@ -5247,6 +5366,7 @@ export function App({ : {}), ...(sourceSessionId ? { sourceSessionId } : {}), ...(previewOnly ? { previewOnly: true } : {}), + ...(sourcePreview ? { sourcePreview: true } : {}), ...(workspaceCwd ? { workspaceCwd } : {}), ...(workspaceId ? { workspaceId } : {}), }; @@ -10301,6 +10421,12 @@ export function App({ useEffect(() => { for (const notice of notices) { + if (notice.sourceRetry) { + const retry = notice.sourceRetry; + setSourceRegistrationRetries((previous) => + previous.includes(retry) ? previous : [...previous, retry], + ); + } if (shouldToastNotice(notice)) { pushToast(toastToneFromNotice(notice), notice.message); } else if (notice.category !== 'lifecycle') { @@ -11865,6 +11991,8 @@ export function App({ .branchSession(name || undefined, atRecordId) .then((result) => { if (!result.switchStarted) return; + if (result.sourceWarnings?.length) + pushToast('warning', result.sourceWarnings.join(' ')); store.dispatch([ { type: 'status', @@ -18835,6 +18963,22 @@ export function App({ : [] } attachmentsLoading={sessionAttachmentsSkeletonLoading} + attachmentsError={ + sessionAttachmentsError?.owner === sessionAttachmentsOwner && + sessionAttachmentsOwner.isCurrent() + ? sessionAttachmentsError.message + : undefined + } + onRetryAttachments={() => + setAttachmentRefreshNonce((value) => value + 1) + } + sources={sourcesState} + onOpenSource={openSourcePanel} + retrySourceRegistration={ + sourceRegistrationRetries.length + ? retrySourceRegistrations + : undefined + } artifacts={artifacts} artifactsLoading={artifactsLoading} items={environmentPanelItems} @@ -18859,7 +19003,9 @@ export function App({ openImagePanel(src, alt, source); } }} - onAttachmentPreview={openAttachmentPanel} + onAttachmentPreview={(file) => + openAttachmentPanel(file, undefined, undefined, true) + } onAttachmentPreviewError={(error) => { if (!environmentPanelOwner.isCurrent()) return; pushToast( diff --git a/packages/web-shell/client/components/TranscriptViewport.scroll.test.tsx b/packages/web-shell/client/components/TranscriptViewport.scroll.test.tsx index 284960f0cc1..38d37b53311 100644 --- a/packages/web-shell/client/components/TranscriptViewport.scroll.test.tsx +++ b/packages/web-shell/client/components/TranscriptViewport.scroll.test.tsx @@ -379,6 +379,32 @@ describe('TranscriptViewport scroll restoration and fallback', () => { }, ); + it('captures rows materialized after a boundary request starts before admitting the page', async () => { + const { click, list, row, getTranscriptPage, settleFrames, render } = + await setup(); + await click('history.openEarlier'); + settleFrames(); + const targetKey = `msg:${observed.props!.messages[0]!.id}`; + let resolve!: (value: DaemonSessionTranscriptPage) => void; + getTranscriptPage.mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + await click('history.loadEarlier'); + expect(getTranscriptPage).toHaveBeenCalledTimes(2); + observed.hideRows = true; + render(); + observed.hideRows = false; + render(); + list().scrollTop += 20; + const before = row(targetKey).getBoundingClientRect().top; + await act(async () => resolve(page(['old1', 'old2']))); + settleFrames(); + expect(row(targetKey).getBoundingClientRect().top).toBe(before); + }); + it('restores a collapse row independently of its sibling prompt sharing its source', async () => { const { click, list, row, getTranscriptPage, settleFrames } = await setup({ collapseRows: true, diff --git a/packages/web-shell/client/components/TranscriptViewport.tsx b/packages/web-shell/client/components/TranscriptViewport.tsx index ef5190fa4eb..3f5ca0c2412 100644 --- a/packages/web-shell/client/components/TranscriptViewport.tsx +++ b/packages/web-shell/client/components/TranscriptViewport.tsx @@ -127,6 +127,12 @@ export const TranscriptViewport = forwardRef< offset: row.getBoundingClientRect().top - top, }; }, [historical, pin, rows, scroller, toolSources]); + const captureRef = useRef(capture); + captureRef.current = capture; + const refreshAnchor = () => { + // Virtual rows may not exist when the scroll event starts the request. + anchor.current = captureRef.current() ?? anchor.current; + }; useImperativeHandle( ref, () => ({ @@ -235,7 +241,7 @@ export const TranscriptViewport = forwardRef< } anchor.current = saved; entryDirection.current = direction; - void viewport.load(direction); + void viewport.load(direction, refreshAnchor); }; loadWhenVisible(); }; @@ -344,7 +350,7 @@ export const TranscriptViewport = forwardRef< size="sm" onClick={() => { anchor.current = capture(); - viewport.retry(); + viewport.retry(refreshAnchor); }} > {t('history.retry')} diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx index 13074288551..832354ad261 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx @@ -876,6 +876,51 @@ describe('ArtifactPanel code review artifacts', () => { expect(mockWorkspaceActions.readWorkspaceFile).not.toHaveBeenCalled(); }); + it('keeps HTML source attachments in text mode without an executable preview', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => + root.render( + + Attachment page', + previewMimeType: 'text/html', + previewOnly: true, + sourcePreview: true, + }, + ]} + activeTabId="attachment:page.html" + reviewChanges={[]} + selectedReviewPath={null} + onSelectTab={() => {}} + onCloseTab={() => {}} + onOpenFilePreview={() => {}} + onClose={() => {}} + /> + , + ), + ); + await flush(); + + expect(container.querySelector('.cm-content')?.textContent).toContain( + '

Attachment page

', + ); + expect(container.querySelector('button[aria-label="Preview"]')).toBeNull(); + expect(container.querySelector('iframe')).toBeNull(); + expect(mockWorkspaceActions.readWorkspaceFile).not.toHaveBeenCalled(); + }); + it('shows a clear unsupported state for binary attachments', async () => { const container = document.createElement('div'); document.body.appendChild(container); @@ -921,6 +966,67 @@ describe('ArtifactPanel code review artifacts', () => { expect(mockWorkspaceActions.readWorkspaceFile).not.toHaveBeenCalled(); }); + it('downloads binary source attachments and releases their blob URL', async () => { + const revoke = vi.fn(); + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:source-binary'), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: revoke, + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => + root.render( + + {}} + onCloseTab={() => {}} + onOpenFilePreview={() => {}} + onClose={() => {}} + /> + , + ), + ); + await flush(); + + expect(container.textContent).toContain( + 'Preview is not available for this file type.', + ); + expect(container.querySelector('.cm-content')).toBeNull(); + expect(mockWorkspaceActions.readWorkspaceFile).not.toHaveBeenCalled(); + const download = container.querySelector( + 'a[download="report.xlsx"]', + ); + expect(download?.href).toBe('blob:source-binary'); + act(() => root.render(null)); + expect(revoke).toHaveBeenCalledWith('blob:source-binary'); + }); + it('opens PDF attachments in the browser PDF preview', async () => { Object.defineProperty(URL, 'createObjectURL', { configurable: true, diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx index 3bbcaacb82a..082b740e3c1 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx @@ -1,5 +1,6 @@ import type { DaemonSessionArtifact, + SessionSource, DaemonSessionMonitorTaskStatus, DaemonSessionShellTaskStatus, DaemonSessionTaskStatus, @@ -7,6 +8,8 @@ import type { import type { ACPToolCall, TodoItem } from '../../adapters/types'; import type { WebShellRightPanelItem } from '../../customization'; import { + useConnection, + type DaemonSessionOwnerSnapshot, type DaemonSessionActions, type DaemonScheduledTask, } from '@qwen-code/web-shell/daemon-react-sdk'; @@ -31,6 +34,7 @@ import { NetworkIcon, } from 'lucide-react'; import { Skeleton } from '../ui/skeleton'; +import { Button } from '../ui/button'; import { useCallback, useEffect, @@ -137,6 +141,17 @@ export type ImageTabSource = { }; export type ArtifactPanelTab = + | { + id: string; + kind: 'source'; + title: string; + source: SessionSource; + sourceSessionId: string; + workspaceCwd?: string; + workspaceId?: string; + owner: DaemonSessionOwnerSnapshot; + sessionActions: DaemonSessionActions; + } | { id: string; kind: 'review'; @@ -160,6 +175,7 @@ export type ArtifactPanelTab = previewData?: Blob; previewMimeType?: string; previewOnly?: boolean; + sourcePreview?: boolean; sourceSessionId?: string; /** * Set for attachment-backed previews so the tab can re-fetch its bytes @@ -477,9 +493,17 @@ export function ArtifactPanel({ [], ); const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0]; + const sourceHtmlPreview = + activeTab?.kind === 'file' && + activeTab.sourcePreview && + (/\.html?$/i.test(activeTab.workspacePath) || + normalizeArtifactMimeType( + activeTab.previewMimeType || activeTab.previewData?.type, + ) === 'text/html'); const canPreviewAttachment = activeTab?.kind === 'file' && activeTab.previewOnly === true && + !sourceHtmlPreview && /\.(?:html?|md|markdown)$/i.test(activeTab.workspacePath) && (activeTab.previewContent !== undefined || !activeTab.previewData || @@ -1010,6 +1034,23 @@ export function ArtifactPanel({ > {activeTab.loadError ?? t('common.loading')} + ) : activeTab.sourcePreview && + activeTab.previewData && + normalizeArtifactMimeType( + activeTab.previewMimeType || activeTab.previewData.type, + ) !== 'application/pdf' && + !normalizeTextMediaType( + activeTab.previewMimeType || activeTab.previewData.type, + activeTab.workspacePath, + ) ? ( + ) : ( ) + ) : activeTab.kind === 'source' ? ( + ) : activeTab.kind === 'artifact' ? ( ; +}) { + const { t } = useI18n(); + const connection = useConnection(); + const target = useArtifactWorkspaceTarget(tab.workspaceCwd); + const workspaceActions = target?.actions; + const openExternal = useExternalLinkOpener(); + const [attempt, setAttempt] = useState(0); + const [data, setData] = useState(); + const [error, setError] = useState(); + const source = tab.source; + const locator = source.locator; + const valid = + tab.owner.isCurrent() && + connection.sessionId === tab.sourceSessionId && + connection.capabilities?.features.includes('session_sources') && + target?.workspaceId === tab.workspaceId && + (Boolean(target) || + (tab.workspaceCwd === undefined && locator.type !== 'workspace_file')) && + (locator.type !== 'workspace_file' || + source.workspaceCwd === connection.workspaceCwd); + const path = + locator.type === 'workspace_file' + ? locator.workspacePath + : locator.type === 'attachment' + ? locator.attachmentId + : ''; + const isPdf = /\.pdf$/i.test(path); + useEffect(() => { + let cancelled = false; + setData(undefined); + setError(undefined); + if (!valid || locator.type === 'url') return; + const load = async () => { + if (locator.type === 'attachment') { + const attachment = await tab.sessionActions.readAttachment( + locator.attachmentId, + ); + if (cancelled || !tab.owner.isCurrent()) return; + const bytes = Uint8Array.from(atob(attachment.data), (character) => + character.charCodeAt(0), + ); + setData(new Blob([bytes], { type: attachment.mimeType })); + } else if (isPdf && workspaceActions) { + const blob = await readWorkspaceFileAsBlob( + workspaceActions.readFileBytes, + path, + 'application/pdf', + { + statFile: workspaceActions.stat, + isCancelled: () => cancelled || !tab.owner.isCurrent(), + }, + ); + if (!cancelled && tab.owner.isCurrent()) setData(blob); + } + }; + void load().catch((err: unknown) => { + if (!cancelled && tab.owner.isCurrent()) + setError(extractErrorDetail(err)); + }); + return () => { + cancelled = true; + }; + }, [ + attempt, + valid, + locator, + path, + isPdf, + tab.owner, + tab.sessionActions, + workspaceActions, + ]); + if (valid && locator.type === 'url') + return ( +
+

{source.title}

+ {source.description &&

{source.description}

} +

{locator.url}

+ {isSafeHref(locator.url) && ( + openExternal(event, locator.url)} + > + {t('sources.openOriginal')} + + )} +
+ ); + if (!valid || (!target && locator.type !== 'attachment')) + return ( +
+ {t('sources.unavailable')} +
+ ); + const unsupported = + locator.type === 'workspace_file' && + !isPdf && + isDownloadOnlyWorkspaceArtifact({ workspacePath: path }); + return ( +
+
+ + {path} + + +
+ {error ? ( +
+ {error} +
+ ) : unsupported ? ( +
+

{t('attachment.previewUnsupported')}

+ +
+ ) : (locator.type === 'attachment' || isPdf) && !data ? ( +
+ {t('common.loading')} +
+ ) : data && + data.type !== 'application/pdf' && + !normalizeTextMediaType(data.type, path) ? ( + + ) : ( + + )} +
+ ); +} + +function SourceBlobPreview({ + data, + title, + image, +}: { + data: Blob; + title: string; + image: boolean; +}) { + const [url, setUrl] = useState(); + useEffect(() => { + const objectUrl = URL.createObjectURL(data); + setUrl(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + }, [data]); + return url ? ( +
+ {image ? ( + {title} + ) : ( + + )} + + + +
+ ) : null; +} + function WorkspaceFilePreview({ workspacePath, artifactVersion, diff --git a/packages/web-shell/client/components/artifacts/SourcePreview.test.tsx b/packages/web-shell/client/components/artifacts/SourcePreview.test.tsx new file mode 100644 index 00000000000..6d32403295c --- /dev/null +++ b/packages/web-shell/client/components/artifacts/SourcePreview.test.tsx @@ -0,0 +1,206 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { SessionSource } from '@qwen-code/sdk/daemon'; +import type { DaemonSessionActions } from '@qwen-code/web-shell/daemon-react-sdk'; +import { ArtifactPanel } from './ArtifactPanel'; +import { I18nProvider } from '../../i18n'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +const mock = vi.hoisted(() => ({ + current: true, + trusted: true, + standalone: false, + connection: { + sessionId: 'session-a', + workspaceCwd: '/workspace', + capabilities: { features: ['session_sources'] }, + }, + actions: { + readWorkspaceFile: vi.fn(), + readFileBytes: vi.fn(), + stat: vi.fn(), + }, + sessionActions: { readAttachment: vi.fn() }, +})); +vi.mock('@qwen-code/web-shell/daemon-react-sdk', async (original) => ({ + ...(await original>()), + useConnection: () => ({ + ...mock.connection, + workspaceCwd: mock.standalone ? undefined : mock.connection.workspaceCwd, + }), +})); +vi.mock('../terminal/TerminalPanel', () => ({ TerminalPanel: () => null })); +const target = { + workspaceCwd: '/workspace', + workspaceId: 'owner-a', + actions: mock.actions, +}; +vi.mock('./useArtifactWorkspaceTarget', () => ({ + useArtifactWorkspaceTarget: (cwd: string) => + mock.trusted && cwd ? { ...target } : undefined, +})); +let container: HTMLDivElement; +let root: Root; +const owner = { isCurrent: () => mock.current }; +const source = (locator: SessionSource['locator']): SessionSource => ({ + id: 'source-a', + title: 'Reference', + locator, + kind: locator.type === 'url' ? 'link' : 'file', + ...(locator.type === 'workspace_file' ? { workspaceCwd: '/workspace' } : {}), + createdAt: '2026-09-07T00:00:00Z', + updatedAt: '2026-09-07T00:00:00Z', +}); +async function render(value: SessionSource) { + await act(async () => + root.render( + + {}} + onCloseTab={() => {}} + onOpenFilePreview={() => {}} + onClose={() => {}} + /> + , + ), + ); +} +beforeEach(() => { + mock.current = true; + mock.trusted = true; + mock.standalone = false; + mock.connection.workspaceCwd = '/workspace'; + Object.values(mock.actions).forEach((fn) => fn.mockReset()); + mock.sessionActions.readAttachment.mockReset(); + mock.actions.stat.mockResolvedValue({ + type: 'file', + sizeBytes: 20, + modifiedMs: 1, + }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe('source preview', () => { + it.each(['text/plain', 'text/html'])( + 'previews standalone attachment bytes as %s without workspace access', + async (mimeType) => { + mock.standalone = true; + mock.sessionActions.readAttachment.mockResolvedValue({ + data: btoa(''), + mimeType, + }); + await render( + source({ + type: 'attachment', + attachmentId: + mimeType === 'text/html' ? 'reference.html' : 'reference.txt', + }), + ); + expect(mock.sessionActions.readAttachment).toHaveBeenCalledOnce(); + await vi.waitFor(async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(container.textContent).toContain('STANDALONE_SOURCE_BYTES'); + }); + expect(container.querySelector('iframe')).toBeNull(); + expect(mock.actions.stat).not.toHaveBeenCalled(); + expect(mock.actions.readWorkspaceFile).not.toHaveBeenCalled(); + expect(mock.actions.readFileBytes).not.toHaveBeenCalled(); + }, + ); + + it('rejects standalone attachment access after its owner is revoked', async () => { + mock.standalone = true; + mock.current = false; + await render(source({ type: 'attachment', attachmentId: 'reference.txt' })); + expect(container.textContent).toContain('no longer available'); + expect(mock.sessionActions.readAttachment).not.toHaveBeenCalled(); + }); + + it('opens URL metadata without fetching it', async () => { + await render(source({ type: 'url', url: 'https://example.com/docs#part' })); + expect( + container.querySelector('a[href="https://example.com/docs#part"]') + ?.textContent, + ).toBe('Open original'); + expect(container.querySelector('iframe')).toBeNull(); + expect(mock.actions.readWorkspaceFile).not.toHaveBeenCalled(); + expect(mock.sessionActions.readAttachment).not.toHaveBeenCalled(); + }); + it('renders source HTML as text without executing an iframe', async () => { + mock.actions.readWorkspaceFile.mockResolvedValue({ + content: '', + truncated: false, + }); + await render( + source({ type: 'workspace_file', workspacePath: 'input.html' }), + ); + expect(mock.actions.readWorkspaceFile).toHaveBeenCalledWith('input.html'); + expect(container.querySelector('iframe')).toBeNull(); + expect(container.textContent).toContain('window.shouldNotRun'); + }); + it('rejects a changed workspace and revoked owner without reading', async () => { + mock.connection.workspaceCwd = '/different'; + await render( + source({ type: 'workspace_file', workspacePath: 'input.html' }), + ); + expect(container.textContent).toContain('no longer available'); + expect(mock.actions.stat).not.toHaveBeenCalled(); + mock.connection.workspaceCwd = '/workspace'; + mock.trusted = false; + await render(source({ type: 'attachment', attachmentId: 'image.png' })); + expect(mock.sessionActions.readAttachment).not.toHaveBeenCalled(); + }); + it('offers download for unsupported attachments and revokes blob URLs on removal', async () => { + const create = vi.fn(() => 'blob:source-test'); + const revoke = vi.fn(); + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: create, + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: revoke, + }); + mock.sessionActions.readAttachment.mockResolvedValue({ + data: 'AAEC', + mimeType: 'application/octet-stream', + }); + await render(source({ type: 'attachment', attachmentId: 'data.bin' })); + expect(mock.sessionActions.readAttachment).toHaveBeenCalledOnce(); + expect(container.querySelector('a[download]')?.getAttribute('href')).toBe( + 'blob:source-test', + ); + await act(async () => root.render(null)); + expect(revoke).toHaveBeenCalledWith('blob:source-test'); + }); +}); diff --git a/packages/web-shell/client/components/messages/toolFormatting.ts b/packages/web-shell/client/components/messages/toolFormatting.ts index d7b5ad0b0af..be782d59431 100644 --- a/packages/web-shell/client/components/messages/toolFormatting.ts +++ b/packages/web-shell/client/components/messages/toolFormatting.ts @@ -60,6 +60,7 @@ export const TOOL_DISPLAY_NAMES: Record = { workflow: 'Workflow', artifact: 'Artifact', record_artifact: 'RecordArtifact', + record_source: 'RecordSource', report_findings: 'ReportFindings', web_search: 'WebSearch', image_gen: 'ImageGen', diff --git a/packages/web-shell/client/components/panels/EnvironmentPanel.module.css b/packages/web-shell/client/components/panels/EnvironmentPanel.module.css index c497364bec7..d08e57f81e2 100644 --- a/packages/web-shell/client/components/panels/EnvironmentPanel.module.css +++ b/packages/web-shell/client/components/panels/EnvironmentPanel.module.css @@ -359,3 +359,11 @@ button.row:disabled { font-size: 12px; line-height: 1.5; } + +.sourceTitle { + cursor: default; +} + +.sourceTitle:hover { + background: transparent; +} diff --git a/packages/web-shell/client/components/panels/EnvironmentPanel.test.tsx b/packages/web-shell/client/components/panels/EnvironmentPanel.test.tsx index 9399e53112b..2d74dd95ddd 100644 --- a/packages/web-shell/client/components/panels/EnvironmentPanel.test.tsx +++ b/packages/web-shell/client/components/panels/EnvironmentPanel.test.tsx @@ -6,6 +6,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { I18nProvider } from '../../i18n'; import { EnvironmentPanel } from './EnvironmentPanel'; +import type { SourcesState } from './SourcesSection'; vi.mock('../BranchPickerPopover', async () => { const { createElement } = await import('react'); @@ -89,6 +90,53 @@ function toggleSection(view: HTMLElement, label: string): void { act(() => button?.click()); } +const uploadedFiles = [ + { + type: 'resource' as const, + attachmentId: 'notes.txt', + mimeType: 'text/plain', + size: 5, + }, + { + type: 'resource' as const, + attachmentId: 'historical.html', + mimeType: 'text/html', + size: 12, + }, +]; + +function sourceState(): SourcesState { + return { + supported: true, + sources: [ + { + id: 'registered', + title: 'Reference notes', + kind: 'file', + locator: { type: 'attachment', attachmentId: 'notes.txt' }, + createdAt: '2026-09-07T00:00:00Z', + updatedAt: '2026-09-07T00:00:00Z', + }, + { + id: 'link', + title: 'Website', + kind: 'link', + locator: { type: 'url', url: 'https://example.com' }, + createdAt: '2026-09-07T00:00:00Z', + updatedAt: '2026-09-07T00:00:00Z', + }, + ], + owner: { isCurrent: () => true }, + revision: 2, + hydrated: true, + loading: false, + error: null, + refresh: vi.fn(), + upsert: vi.fn(), + remove: vi.fn(), + }; +} + describe('EnvironmentPanel', () => { it('shows supported workspace and Git context', () => { const view = mount(); @@ -562,7 +610,7 @@ describe('EnvironmentPanel', () => { expect(onOpenTask).toHaveBeenCalledOnce(); }); - it('lists uploaded images and files under the attachments section', async () => { + it('lists uploaded images and files under Sources without source support', async () => { const onImagePreview = vi.fn(); const onAttachmentPreview = vi.fn(); const onReadImage = vi.fn(async () => 'data:image/png;base64,AQID'); @@ -586,10 +634,10 @@ describe('EnvironmentPanel', () => { onAttachmentPreview, }); - const header = Array.from( - view.querySelectorAll('button[aria-expanded]'), - ).find((button) => button.textContent?.includes('Attachments')); - expect(header?.textContent).toContain('Attachments'); + const section = view.querySelector('[data-testid="sources-section"]'); + expect(section?.textContent).toContain('Sources'); + expect(section?.textContent).not.toContain('Attachments'); + expect(section?.querySelector('[aria-label="Add source"]')).toBeNull(); expect(view.textContent).toContain('notes.txt'); expect(view.querySelector('img')).toBeNull(); expect(onReadImage).not.toHaveBeenCalled(); @@ -652,6 +700,288 @@ describe('EnvironmentPanel', () => { expect(onAttachmentPreviewError).toHaveBeenCalledWith(error); }); + it.each([ + { items: ['sources'] as const }, + { items: ['sources', 'attachments'] as const }, + ])('merges registered and historical files once with $items', ({ items }) => { + const sources = sourceState(); + const onOpenSource = vi.fn(); + const view = mount({ + sources, + items, + attachments: uploadedFiles, + onOpenSource, + }); + const section = view.querySelector('[data-testid="sources-section"]'); + expect( + view.querySelectorAll('[data-testid="sources-section"]'), + ).toHaveLength(1); + expect(section?.querySelector('h3')?.textContent).toBe('Sources 3'); + expect(section?.querySelectorAll('li')).toHaveLength(3); + expect(section?.textContent).toContain('Reference notes'); + expect(section?.textContent).toContain('historical.html'); + const row = section?.querySelector( + '[aria-label="Open source Reference notes"]', + ); + act(() => row?.click()); + expect(onOpenSource).toHaveBeenCalledWith(sources.sources[0]); + expect(sources.upsert).not.toHaveBeenCalled(); + expect(section?.querySelector('[aria-label^="Remove"]')).toBeNull(); + }); + + it('does not repeat the filename when the registered title matches its location', () => { + const state = sourceState(); + const view = mount({ + sources: { + ...state, + sources: [{ ...state.sources[0]!, title: 'notes.txt' }], + }, + attachments: uploadedFiles, + }); + expect( + view.querySelector('[aria-label="Open source notes.txt"]')?.textContent, + ).toBe('notes.txt'); + }); + + it.each([1, 2, 3])( + 'shows all %i unique sources without an expand or collapse button', + (count) => { + const state = sourceState(); + const view = mount({ + sources: { ...state, sources: state.sources.slice(0, count) }, + attachments: count === 3 ? [...uploadedFiles, uploadedFiles[1]!] : [], + }); + const section = view.querySelector('[data-testid="sources-section"]'); + + expect(section?.querySelector('h3')?.textContent).toBe( + `Sources ${count}`, + ); + expect(section?.querySelectorAll('li')).toHaveLength(count); + expect(section?.textContent).not.toContain('View all'); + expect(section?.textContent).not.toContain('Collapse'); + }, + ); + + it('expands four unique sources and collapses them back to the first three', () => { + const view = mount({ + sources: sourceState(), + attachments: [ + ...uploadedFiles, + uploadedFiles[1]!, + { + type: 'resource', + attachmentId: 'fourth.txt', + mimeType: 'text/plain', + size: 4, + }, + ], + }); + const section = view.querySelector('[data-testid="sources-section"]')!; + const rows = () => + Array.from(section.querySelectorAll('li'), (row) => row.textContent); + const toggle = Array.from(section.querySelectorAll('button')).find( + (button) => button.textContent === 'View all', + )!; + + expect(section.querySelector('h3')?.textContent).toBe('Sources 4'); + expect(rows()).toEqual(['Reference notes', 'Website', 'historical.html']); + expect(toggle).toBeDefined(); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + + act(() => toggle.click()); + + expect(rows()).toEqual([ + 'Reference notes', + 'Website', + 'historical.html', + 'fourth.txt', + ]); + expect(toggle.textContent).toBe('Collapse'); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + + act(() => toggle.click()); + + expect(rows()).toEqual(['Reference notes', 'Website', 'historical.html']); + expect(toggle.textContent).toBe('View all'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + }); + + it('bounds the default source title without shortening its location', async () => { + const state = sourceState(); + const view = mount({ sources: state }); + await act(async () => + view + .querySelector('[aria-label="Add source"]') + ?.click(), + ); + const location = document.querySelector( + 'input[placeholder="docs/requirements.md"]', + )!; + const path = 'a'.repeat(250); + act(() => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!.call(location, path); + location.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => { + document + .querySelector('form')! + .dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ); + }); + expect(state.upsert).toHaveBeenCalledWith({ + title: 'a'.repeat(200), + locator: { type: 'workspace_file', workspacePath: path }, + }); + }); + + it('releases a floating panel after source capability disappears while adding', async () => { + const state = sourceState(); + const onDismiss = vi.fn(); + const view = mount({ sources: state, floating: true, onDismiss }); + await act(async () => + view + .querySelector('[aria-label="Add source"]') + ?.click(), + ); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + expect( + Array.from(document.querySelectorAll('option')).map( + (option) => option.value, + ), + ).toEqual(['workspace_file', 'url']); + act(() => + root?.render( + + + , + ), + ); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + act(() => + document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })), + ); + expect(onDismiss).toHaveBeenCalledOnce(); + }); + + it.each([false, true])( + 'closes the source dialog when hidden (floating=%s)', + async (floating) => { + const state = sourceState(); + const onDismiss = vi.fn(); + const view = mount({ sources: state, floating, onDismiss }); + await act(async () => + view + .querySelector('[aria-label="Add source"]')! + .click(), + ); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + const renderHidden = (hidden: boolean) => + act(() => + root!.render( + + , + ), + ); + renderHidden(true); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(document.body.style.pointerEvents).not.toBe('none'); + renderHidden(false); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + act(() => + document.body.dispatchEvent( + new Event('pointerdown', { bubbles: true }), + ), + ); + expect(onDismiss).toHaveBeenCalledTimes(floating ? 1 : 0); + }, + ); + + it('keeps the legacy attachments option limited to uploaded files', () => { + const view = mount({ + sources: sourceState(), + items: ['attachments'], + attachments: uploadedFiles, + }); + const section = view.querySelector('[data-testid="sources-section"]'); + expect(section?.querySelector('h3')?.textContent).toBe('Sources 2'); + expect(section?.textContent).toContain('notes.txt'); + expect(section?.textContent).not.toContain('Reference notes'); + expect(section?.textContent).not.toContain('Website'); + expect(section?.querySelector('[aria-label="Add source"]')).toBeNull(); + }); + + it('retains uploaded files after registration is removed without registering them again', () => { + const sources = sourceState(); + const onAttachmentPreview = vi.fn(); + const view = mount({ sources, attachments: uploadedFiles }); + act(() => + root?.render( + + + , + ), + ); + expect(view.textContent).not.toContain('Reference notes'); + expect(view.textContent).toContain('notes.txt'); + const row = view.querySelector('[title="notes.txt"]'); + act(() => row?.click()); + expect(onAttachmentPreview).toHaveBeenCalledWith({ + name: 'notes.txt', + mimeType: 'text/plain', + attachmentId: 'notes.txt', + }); + expect(sources.upsert).not.toHaveBeenCalled(); + }); + + it('keeps either material list visible during partial loading and errors', () => { + const sources = sourceState(); + const onRetryAttachments = vi.fn(); + const view = mount({ + sources: { + ...sources, + loading: true, + error: 'Source listing unavailable', + }, + attachments: uploadedFiles, + attachmentsLoading: true, + attachmentsError: 'Upload listing unavailable', + onRetryAttachments, + }); + expect(view.textContent).toContain('Reference notes'); + expect(view.textContent).toContain('historical.html'); + expect(view.textContent).toContain('Source listing unavailable'); + expect(view.textContent).toContain('Upload listing unavailable'); + expect(view.querySelector('[role="status"]')).toBeNull(); + const alerts = view.querySelectorAll('[role="alert"]'); + act(() => alerts[1]?.querySelector('button')?.click()); + expect(onRetryAttachments).toHaveBeenCalledOnce(); + }); + it('lists artifacts in a separate expanded section', () => { const onOpenArtifact = vi.fn(); const view = mount({ @@ -696,7 +1026,7 @@ describe('EnvironmentPanel', () => { artifactsLoading: true, }); - expect(view.textContent).toContain('Attachments'); + expect(view.textContent).toContain('Sources'); expect(view.textContent).toContain('Artifacts'); expect( view.querySelectorAll('[data-testid="environment-file-list-skeleton"]'), diff --git a/packages/web-shell/client/components/panels/EnvironmentPanel.tsx b/packages/web-shell/client/components/panels/EnvironmentPanel.tsx index cb21c5b2a9b..c91d9992a8e 100644 --- a/packages/web-shell/client/components/panels/EnvironmentPanel.tsx +++ b/packages/web-shell/client/components/panels/EnvironmentPanel.tsx @@ -31,8 +31,13 @@ import { BranchPickerPopover } from '../BranchPickerPopover'; import { FileTypeIcon } from '../FileTypeIcon'; import { Skeleton } from '../ui/skeleton'; import styles from './EnvironmentPanel.module.css'; +import { SourcesSection, type SourcesState } from './SourcesSection'; +import type { SessionSource } from '@qwen-code/sdk/daemon'; interface EnvironmentPanelProps { + sources?: SourcesState; + onOpenSource?: (source: SessionSource) => void; + retrySourceRegistration?: () => Promise; floating?: boolean; hidden?: boolean; workspaceCwd?: string; @@ -44,6 +49,8 @@ interface EnvironmentPanelProps { agentTasks?: readonly EnvironmentAgentTask[]; attachments?: readonly DaemonSessionAttachmentReference[]; attachmentsLoading?: boolean; + attachmentsError?: string; + onRetryAttachments?: () => void; artifacts?: readonly DaemonSessionArtifact[]; artifactsLoading?: boolean; items?: readonly WebShellEnvironmentPanelItem[]; @@ -67,7 +74,7 @@ export type EnvironmentAgentTask = DaemonSessionAgentTaskStatus & { }; const DEFAULT_ENVIRONMENT_PANEL_ITEMS: readonly WebShellEnvironmentPanelItem[] = - ['environment', 'subagents', 'backgroundTasks', 'attachments', 'artifacts']; + ['environment', 'sources', 'subagents', 'backgroundTasks', 'artifacts']; const AGENT_COLORS: Readonly> = { red: '#e5484d', blue: 'var(--agent-blue-500)', @@ -134,6 +141,9 @@ function agentColorValue(color: string | undefined): string { } export function EnvironmentPanel({ + sources, + onOpenSource, + retrySourceRegistration, floating = false, hidden = false, workspaceCwd, @@ -145,6 +155,8 @@ export function EnvironmentPanel({ agentTasks, attachments, attachmentsLoading = false, + attachmentsError, + onRetryAttachments, artifacts, artifactsLoading = false, items = DEFAULT_ENVIRONMENT_PANEL_ITEMS, @@ -173,9 +185,9 @@ export function EnvironmentPanel({ const [environmentExpanded, setEnvironmentExpanded] = useState(true); const [agentsExpanded, setAgentsExpanded] = useState(true); const [tasksExpanded, setTasksExpanded] = useState(true); - const [attachmentsExpanded, setAttachmentsExpanded] = useState(true); const [artifactsExpanded, setArtifactsExpanded] = useState(true); const [branchPickerOpen, setBranchPickerOpen] = useState(false); + const [sourceDialogOpen, setSourceDialogOpen] = useState(false); const activeBranch = branch ?? gitStatus?.branch; useEffect(() => { @@ -185,7 +197,14 @@ export function EnvironmentPanel({ }, [activeBranch, environmentExpanded, gitWorkspaceCwd, hidden]); useEffect(() => { - if (!floating || hidden || !onDismiss || branchPickerOpen) return; + if ( + !floating || + hidden || + !onDismiss || + branchPickerOpen || + sourceDialogOpen + ) + return; const dismissOnOutsidePointerDown = (event: PointerEvent) => { if ( event @@ -208,7 +227,7 @@ export function EnvironmentPanel({ document.addEventListener('pointerdown', dismissOnOutsidePointerDown); return () => document.removeEventListener('pointerdown', dismissOnOutsidePointerDown); - }, [branchPickerOpen, floating, hidden, onDismiss]); + }, [branchPickerOpen, sourceDialogOpen, floating, hidden, onDismiss]); const gitDetails = [ gitStatus?.operation @@ -318,6 +337,25 @@ export function EnvironmentPanel({ )} + {(items.includes('sources') || items.includes('attachments')) && ( +