mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(artifacts): record files the agent creates or edits (#14292)
* docs(design): propose session artifact create-and-update lifecycle Land the bilingual design for #14282 so maintainer review can happen before implementation PRs touch core tools and acp-bridge. * docs(design): address review findings in the artifact lifecycle design Hash the bytes actually written, keep a mismatch sticky by stamping the agent's hash as the baseline, sync host-registered artifacts content-only, stop resurrecting deleted auto-recorded artifacts, include toolCallId in Web Shell turn attribution, and list every downstream consumer of tool artifacts. * feat(artifacts): record files the agent creates or edits Agent writes through write_file, edit, and notebook_edit share one auto-record policy, so a workspace file is created or refreshed as the same session artifact, including SQL, Python, Markdown, and JSON. * fix(artifacts): apply the latest-write turn rule to batched merges Share the predicate between mergeArtifact and mergeBatchArtifact so two producer writes for one path in a single batch attribute the card to the last call. Clarify that updateOnly inputs without a workspacePath are dropped, refresh the record_as_artifact JSDoc extension list, and note in the design why candidate building cannot fail a write and how credential-bearing files are handled. * fix(artifacts): address suggestion-level review comments Let edit and notebook_edit opt out of creating an artifact the same way write_file does, log unmatched workspace updates, and correct the design where it disagreed with the code. * fix(core): keep edit tool declarations inside the size budget The record_as_artifact parameter pushed edit and notebook_edit past the declaration ceilings. Shorten that parameter text, and trim the notebook description that is not part of the load-bearing contract. --------- Co-authored-by: 易良 <[email protected]>
This commit is contained in:
1 parent
c6d97a4c7c
commit
2c99fcbd11
20 files changed
+1656
-168
No files matched your search
@@ -0,0 +1,256 @@
|
||||
# Session Artifact Create and Update Lifecycle
|
||||
|
||||
[English](artifact-create-update-lifecycle.md) | [简体中文](artifact-create-update-lifecycle.zh-CN.md)
|
||||
|
||||
- Status: In implementation (PR 1)
|
||||
- Date: 2026-10-15
|
||||
- Issue: [#14282](https://github.com/QwenLM/qwen-code/issues/14282)
|
||||
- Related designs: [session-artifacts-persistence-v2-design.md](session-artifacts-persistence-v2-design.md), [session-artifacts-directory-expansion.md](../session-artifacts-directory-expansion.md), [web-shell-tool-file-preview.md](../web-shell-tool-file-preview.md)
|
||||
|
||||
## 1. Background and problem
|
||||
|
||||
Artifacts today follow a "register once on creation" model: `write_file` records files with certain extensions automatically, and everything else depends on the model calling `record_artifact`. Common scenarios fall through:
|
||||
|
||||
- A user uploads a file into the workspace and asks the agent to change it. The agent uses `edit`, no artifact is produced, and the file never shows up in the artifact list.
|
||||
- When a recorded artifact is later modified by `edit`, its baseline is not reset. The next list refresh marks it `changed`, so the agent's own edit looks like an external modification.
|
||||
- When `write_file` creates a file and `edit` modifies it later, the artifact stays on the creating turn and is not visible in the turn that changed it.
|
||||
|
||||
How a host registers non-file resources (for example data tables) as artifacts is out of scope. qwen-code only needs a reliable create/update path for workspace files.
|
||||
|
||||
## 2. Current behavior (verified on `main`)
|
||||
|
||||
### 2.1 Pipeline
|
||||
|
||||
```text
|
||||
Tool ToolResult.artifacts
|
||||
→ persisted with the tool result in the transcript (record.metadata.artifacts)
|
||||
→ ACP tool_call_update._meta.artifacts (cli: tool-call-emitter.ts)
|
||||
→ bridge extractSessionUpdateArtifacts (acp-bridge: bridgeClient.ts; field allowlist, strips _meta.artifacts before forwarding)
|
||||
→ SessionArtifactStore.upsertMany (acp-bridge: sessionArtifacts.ts; serialized mutation queue)
|
||||
expandWorkspaceDirectoryInput → normalizeInput (stat + hash, sets the qwen.workspace.* baseline)
|
||||
→ created if absent; otherwise ownership check → mergeArtifact → updated if public fields changed
|
||||
→ artifact_changed SSE → Web Shell refetches the list
|
||||
```
|
||||
|
||||
Other entry points: PostToolUse hook `hookSpecificOutput.artifacts`, client `POST /session/:id/artifacts` (with a trusted `clientId`), and slash-command `_meta.sessionArtifacts`.
|
||||
|
||||
### 2.2 Which tools produce artifacts
|
||||
|
||||
| Tool | Produces | Notes |
|
||||
| ------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `write_file` | Partly | Only extensions in `ARTIFACT_KIND_BY_EXTENSION` (`.csv/.htm/.html/.ipynb/.jpeg/.jpg/.pdf/.png/.svg/.webp` and office documents); `record_as_artifact=false` opts out |
|
||||
| `record_artifact` | Yes | Explicit model call |
|
||||
| `artifact` / `image_gen` / omni tools | Yes | Published HTML, generated images, media derivatives |
|
||||
| `edit` (including file creation) | No | Every return path carries only `llmContent` + `returnDisplay` |
|
||||
| `notebook_edit` | No | No artifact references at all |
|
||||
| `run_shell_command` / MCP tools | No | Not addressed here (see Section 6) |
|
||||
|
||||
### 2.3 Store semantics
|
||||
|
||||
- Identity: `stableSessionArtifactId(sessionId, 'workspace:<canonicalPath>')`; one path is one artifact.
|
||||
- Baseline: `normalizeInput` stats and hashes the file inside the queue and writes `qwen.workspace.sha256 / mtimeMs / sizeBytes`. List/get refresh lazily with a 5-second TTL and set `changed` when live content drifts from the baseline, **without publishing an event**. An entry without a fingerprint baseline has the current content stamped as its baseline on refresh.
|
||||
- The baseline comes only from the store's own read: `normalizeMetadata` strips `qwen.workspace.*` keys from input, and the live `upsertMany` path calls `normalizeInput` without `workspaceExpected`; only restore paths pass persisted fingerprints as expected values. An external change between the write and the registration is absorbed into the baseline.
|
||||
- Ownership: `denyCrossClientMutation` rejects upserts from a different `clientId` against `source: 'client'` entries that carry a `clientId`. Tool inputs carry no `clientId`, so **an agent edit to a host-registered file is rejected**.
|
||||
- Merge: in `mergeArtifact`, `status` and `sizeBytes` come from the incoming input; `mergeMetadata` keeps the existing metadata when `incoming.source !== existing.source` (or the source is a hook), so **new fingerprints are not adopted**.
|
||||
- Display fields (`title`, `description`, `toolCallId`, `toolName`, `source`, `hookEventName`) refresh only when `shouldRefreshWorkspaceDisplay` is true: `record_artifact` (non-hook), a registration without `toolName`, or an exact match of `toolName`, `source`, and `hookEventName`. The code comment explicitly forbids auto-records from clobbering the user-visible title.
|
||||
- Deletion: `shouldSuppressTombstonedUpsert` only blocks client sources from resurrecting; a tool or hook artifact the user deleted is recreated by the next registration of the same path.
|
||||
- Eviction: above 200 entries the store evicts, in order, the oldest missing entry, entries over their source reservation, then the oldest non-`clientRetained` entry. If every remaining candidate is `clientRetained`, a last tier evicts the oldest entry anyway.
|
||||
- Vanish-forgetting (`isVanishedAutoRecordedWorkspaceArtifact`) and presence recheck (`shouldRecheckWriteFilePresence`) are both hard-coded to `toolName === 'write_file'`.
|
||||
|
||||
### 2.4 Web Shell
|
||||
|
||||
- `canDownloadArtifact` already accepts both `available` and `changed`, `ArtifactPanel` previews them the same way, and a test pins this. The only missing piece is a hint for `changed`.
|
||||
- Turn attribution (`getArtifactsByTurn`): paths from `record_artifact` arguments and `reportedArtifacts` are matched first; **only when none match** does `artifact.toolCallId` place the artifact in a single turn.
|
||||
|
||||
### 2.5 Downstream consumers of `ToolResult.artifacts`
|
||||
|
||||
| Consumer | Behavior |
|
||||
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Bridge live ingestion (`sessionUpdate` → `upsertAndPublishArtifacts`) | Writes to the artifact store |
|
||||
| Transcript recording and `session/load` replay (`transcript-replay.ts`) | Tool results are persisted as is; without an artifact snapshot, or when restore fails, `seedSessionUpdates` re-ingests them in order |
|
||||
| `toolResultBoundaryArtifact` | Counts artifact kinds for diagnostics only |
|
||||
| Managed runtime provider protocol | Drops `artifacts` when the result exceeds the size limit |
|
||||
| Media policy collection | Only for tools with a `mediaPolicyDescriptor`; `write_file` / `edit` / `notebook_edit` have none |
|
||||
|
||||
### 2.6 Gaps
|
||||
|
||||
| # | Gap | Consequence |
|
||||
| --- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
|
||||
| G1 | Only `write_file` auto-records; `edit` / `notebook_edit` do not | Uploaded-then-edited files and files created with `edit` are missing |
|
||||
| G2 | Editing a recorded artifact with `edit` sends no upsert | No baseline reset, no event; the artifact later turns `changed` |
|
||||
| G3 | `mergeMetadata` does not adopt fingerprints across sources | Artifacts registered by a hook or an anonymous client fall back to `changed` after a tool rewrite |
|
||||
| G4 | Host registrations with a `clientId` reject tool upserts | Host-registered files edited by the agent do not sync and turn `changed` |
|
||||
| G5 | `shouldRefreshWorkspaceDisplay` requires the same `toolName` to advance `toolCallId` | Created by `write_file`, edited by `edit`: the artifact stays on the creating turn (raised by a reviewer) |
|
||||
| G6 | Web Shell ignores `toolCallId` when a `record_artifact` reference exists | Even with `toolCallId` advanced, explicitly registered artifacts never show in the modifying turn |
|
||||
| G7 | Vanish/recheck rules are hard-coded to `write_file` | Artifacts created by `edit` / `notebook_edit` are not forgotten or rechecked correctly |
|
||||
| G8 | A tool artifact the user deleted is resurrected by the next registration | Becomes frequent once `edit` auto-records |
|
||||
|
||||
## 3. Goals and non-goals
|
||||
|
||||
Goals:
|
||||
|
||||
1. Deliverables the agent **creates or modifies** in the workspace with `write_file` / `edit` / `notebook_edit` enter the artifact list under one policy.
|
||||
2. When the agent modifies an **already recorded** file (any extension, any registration source, including host registrations with a `clientId`), the same id emits `updated`, the content baseline syncs, and status stays `available`. The host's title, retention, and ownership are unchanged.
|
||||
3. An artifact appears in the most recent turn that modified it.
|
||||
4. Existing artifacts coexist safely: identity rules unchanged, no migration, no workspace scan, no bulk reset.
|
||||
|
||||
Non-goals:
|
||||
|
||||
- No extension setting; the built-in set only gains the four extensions listed in 4.2. Source files outside the allowlist (such as `.ts`) never create artifacts when modified.
|
||||
- No revision trail, content snapshots, or shell output detection (split into separate issues, see Section 6).
|
||||
- No coverage of files inside remote execution workers.
|
||||
|
||||
## 4. Design
|
||||
|
||||
### 4.1 Lifecycle rules
|
||||
|
||||
| Event | Store behavior | status | Event |
|
||||
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------- | ----------------- |
|
||||
| Agent writes an unrecorded file matching the policy | Create and set the baseline | available | created |
|
||||
| Agent writes a **recorded** file (any extension, any source) | Update, sync the content baseline, advance `toolCallId` to this call | available | updated |
|
||||
| Agent writes an unrecorded file outside the policy | Ignore (no stat, no hash) | – | – |
|
||||
| Agent writes a path the **user deleted** | Treated as `updateOnly`, not resurrected; explicit `record_artifact` still recreates it | – | – |
|
||||
| File changes again between write and registration | Absorbed into the baseline, as today (see 4.3) | available | created / updated |
|
||||
| `write_file` rewrites identical content | Handled like any other write (no content comparison) | available | created / updated |
|
||||
| True no-op edit / failed write | No candidate | – | – |
|
||||
| User or other means modify the file (including agent shell commands) | Detected on lazy refresh | changed | None (as today) |
|
||||
| File deleted | Auto-recorded artifacts are forgotten; explicit ones become missing | missing | removed / none |
|
||||
| `write_file` with `record_as_artifact=false` | Blocks creation only; recorded artifacts still sync | – | – |
|
||||
|
||||
Semantics: `changed` only means "drifted from the content most recently written by an agent file tool".
|
||||
|
||||
### 4.2 Shared auto-record policy
|
||||
|
||||
Extract the extension map, workspace path resolution, skipped directories, display-safety checks, and MIME/kind inference from `write-file.ts` into `packages/core/src/tools/artifact/workspace-auto-record.ts`. `write_file`, `edit` (including creation), and `notebook_edit` call it **after the write is committed**:
|
||||
|
||||
- Returns `null`: artifacts disabled, outside the workspace, in a skipped directory, or an unsafe path or title.
|
||||
- Built-in extension match: returns a normal candidate.
|
||||
- No match, or `record_as_artifact=false`: returns a candidate with `updateOnly: true`.
|
||||
|
||||
The built-in extension set gains `.sql`, `.py`, `.md`, and `.json`, all with kind `file`. They are the most common deliverables in data and analysis work; adding them to the defaults instead of a setting avoids a new settings surface. Existing skip rules (dot directories, `node_modules`, `dist`, and so on) still apply. `record_as_artifact=false` on `write_file`, `edit`, and `notebook_edit` skips creating an artifact; an artifact the session already has for the path is still refreshed. Artifact registration problems must not turn a successful file write into a failed tool call: building the candidate only resolves paths (a failed realpath falls back to the lexical path), and the store registers it after the tool result is returned. Only candidates for built-in extensions append a hint to `llmContent`; `updateOnly` candidates do not.
|
||||
|
||||
### 4.3 Ingestion field: `updateOnly`
|
||||
|
||||
```ts
|
||||
interface ToolArtifact {
|
||||
// ...existing
|
||||
/** Only refresh an artifact the session already has; never create one. */
|
||||
updateOnly?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
- Add the field explicitly to the `artifactPayloadFields` allowlist in `bridgeClient.ts` and to `SessionArtifactInput`, or it is filtered out.
|
||||
- It never enters the artifact journal/snapshot and never appears on the public artifact, but it is persisted with the tool result in the transcript. Snapshot-less replay re-applies it in order, and as with `write_file` today the store reads the current file as the baseline.
|
||||
|
||||
The baseline still comes from the store's own read; tools do not send an expected fingerprint. An earlier draft added `expectedSha256` (the hash of the bytes the tool wrote) so that a change between the write and the registration would stay `changed`. It was dropped: in daemon mode the bridge advertises the `fs.writeTextFile` client capability, so agent writes are delegated over ACP to the bridge's filesystem adapter and the agent never holds the bytes that reach disk. The field would be missing on the main path. Reading disk bytes in the store also means files whose encoding the tools preserve (GBK, UTF-8 BOM, CRLF) stay `available` with no tool-side handling. The cost is that a change inside the short window between the write and the store's read is absorbed into the baseline, exactly as today.
|
||||
|
||||
### 4.4 Store changes (`sessionArtifacts.ts`)
|
||||
|
||||
The auto-record producer set is `{write_file, edit, notebook_edit}` with a tool source.
|
||||
|
||||
1. **Short-circuit `updateOnly` first**: inside the serialized queue and before `expandWorkspaceDirectoryInput`, resolve the identity with the same path normalization as `normalizeInput` and check existence with the same lookup as `upsertMany` (id, then the published workspace target). If absent, drop it: no create, delete, eviction, publish, or stat/hash, and write `action=dropped reason="unmatched workspace update"`. If present, continue through normal `normalizeInput`.
|
||||
2. **Content-only sync across ownership**: inputs from the producer set skip `denyCrossClientMutation`, so a client registration with a `clientId` accepts the agent's write. The merge only changes content fields: the fingerprint baseline, `status`, `sizeBytes`, and `toolCallId`. `title`, `description`, `source`, `clientId`, retention, and `clientRetained` never change, because `mergeArtifact` keeps the existing `source` and owner, the display refresh rule rejects the tool name, and `mergeRetention` never overrides an explicit retention. Any other tool input (such as `record_artifact`, or a hook input that carries a producer `toolName`) is still denied. This is the accepted permission boundary.
|
||||
3. **Cross-source fingerprint adoption**: for inputs from the producer set, `mergeMetadata` adopts `isAdoptableContentFingerprintKey` keys without requiring the same source, and adds no other keys. Other inputs, hook-sourced inputs, and publisher-trust rules are unchanged.
|
||||
4. **Advance turn attribution by file identity**: decouple "advance `toolCallId`" from "refresh title/description". When a producer-set input merges into a workspace entry, `toolCallId` advances to this call. `title`, `description`, `toolName`, `source`, and `hookEventName` still follow the existing `shouldRefreshWorkspaceDisplay` rule, so auto-records never overwrite the user-visible title. `toolName` keeps the creator, so forgetting rules still depend on who created the artifact.
|
||||
5. **No resurrection after deletion**: a non-`updateOnly` candidate from the producer set that hits a tombstone is treated as `updateOnly` (and dropped because the entry does not exist). Resurrection rules for explicit `record_artifact`, hooks, and client registrations are unchanged.
|
||||
6. **Generalize vanish/recheck predicates**: replace `toolName === 'write_file'` with `toolName ∈ {write_file, edit, notebook_edit}`. Like today, the predicate does not look at the source, so a hook entry whose `toolName` is in that set is forgotten when its file vanishes, the same way a `write_file` hook entry already was. Those entries are also re-stated on every list/get, without the 5-second presence TTL. Client registrations and explicit `record_artifact` entries keep their own `toolName`, so they behave as today.
|
||||
|
||||
Rejected alternatives: making `edit` / `notebook_edit` masquerade as `toolName: 'write_file'` (couples behavior to a name that is not true), and relaxing `shouldRefreshWorkspaceDisplay` wholesale (auto-records would overwrite user titles).
|
||||
|
||||
### 4.5 Web Shell (in the first implementation PR)
|
||||
|
||||
- Download rules are unchanged (`changed` is already downloadable).
|
||||
- Add a "Modified since recorded" hint to the meta line of `changed` cards (not "outside this session", because the agent's own shell commands also cause `changed`). Existing tests already pin the `changed` download behavior.
|
||||
- `getArtifactsByTurn`: for workspace artifacts, **also** add the turn of `artifact.toolCallId` alongside `record_artifact` / `reportedArtifacts` matches (today it is used only when nothing matches). Explicitly registered files then also appear in the most recent modifying turn; for today's entries `toolCallId` and the registering call share a turn, so results do not change. Published and link artifacts keep the old rule, because their `toolCallId` does not follow later writes. The `artifactsByTurn` type does not change.
|
||||
|
||||
### 4.6 Uploaded files
|
||||
|
||||
- Files uploaded into the workspace and then modified by the agent are created by 4.2 on the first modification when they match the built-in extensions (including the new `.sql`, `.py`, `.md`, `.json`); the host does not need to register them on upload.
|
||||
- Types outside the built-in set: agent modifications never create an artifact for them, and they sync through `updateOnly` only if they were already registered some other way.
|
||||
- Files that are uploaded but never modified are inputs; qwen-code does not record them on its own.
|
||||
|
||||
### 4.7 Compatibility with existing artifacts
|
||||
|
||||
**No data migration, no workspace scan or backfill, and no change to existing artifact ids.** Artifact persistence stays V2 journal/snapshot with no new persisted fields.
|
||||
|
||||
| Scenario | Behavior after upgrade | Handling |
|
||||
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| Existing artifact, untouched after upgrade | Same as today | None |
|
||||
| Workspace file that was never recorded | Not backfilled | Only a later agent write (matching the policy) or explicit registration creates it |
|
||||
| Existing `.sql/.py/.md/.json` files in old sessions | Not backfilled; created only when the agent modifies them after the upgrade | Expected behavior change from widening the defaults |
|
||||
| Existing artifact modified again by the agent | Same id emits `updated`, baseline synced, `toolCallId` advanced | This is the fix |
|
||||
| Old entry already `changed` (usually a past unsynced edit) | Stays `changed` (downloadable) until the next agent write | **No bulk reset**: an old missed sync cannot be told apart from a real external edit |
|
||||
| Host registrations (with a `clientId`) | Agent edits sync content only; title, retention, ownership, and tombstones unchanged | Accepted in 4.4 step 2 |
|
||||
| Hook / `record_artifact` registrations | Identity, title, retention unchanged; agent edits adopt new fingerprints | None |
|
||||
| `write_file` artifacts the user deleted | **Behavior change**: today a rewrite resurrects them; after the upgrade it does not | Use `record_artifact` to recreate explicitly when needed |
|
||||
| Published / managed / link / external_url | A producer write does not update a published entry; its content fields stay frozen | None |
|
||||
| Snapshot-less replay of old transcripts | Ids stay the same. Replay has no saved fingerprint, so it hashes the current file and stamps that baseline; a previously `changed` entry comes back `available` | Not a bulk conversion of a stored snapshot |
|
||||
| Core and acp-bridge versions | Supported only when the daemon and the agent come from the same install. `QWEN_CLI_ENTRY` can point at another tree; a split install is unsupported, and an older bridge drops `updateOnly` | Do not split the install |
|
||||
| Old Web Shell + new daemon | No "Modified since recorded" hint. The daemon still advances `toolCallId`, so the old shell's fallback shows the card on the latest write's turn only, not on both the registering turn and the modifying turn | None |
|
||||
|
||||
Constraints implementation and review must preserve:
|
||||
|
||||
1. Identity keys unchanged: `workspace:<canonicalPath>` (and managed/url).
|
||||
2. No workspace scan or backfill.
|
||||
3. Never bulk-convert stored `changed` entries to `available` on upgrade or snapshot restore. Snapshot-less replay has no saved fingerprint, so it baselines the current file instead.
|
||||
4. `updateOnly` never removes or evicts an existing entry.
|
||||
5. Cross-ownership sync touches content fields only, never title, retention, or ownership.
|
||||
6. Contract test: restore a V2 snapshot written before the upgrade and assert the same count and ids; `edit` one entry and assert the same id with action `updated`; another untouched entry that was modified externally stays `changed`.
|
||||
|
||||
## 5. Phasing
|
||||
|
||||
### 5.1 PR 1: consistent create/update (the body of this design)
|
||||
|
||||
- core: shared policy module; `write_file` calls it; `edit` and `notebook_edit` return artifacts; `ToolArtifact.updateOnly`; tool descriptions updated.
|
||||
- acp-bridge: allowlist the new field; `updateOnly` short-circuit; content-only sync across ownership; cross-source fingerprint adoption; advance `toolCallId` by file identity; no resurrection after deletion; generalized vanish/recheck predicates.
|
||||
- web-shell: `changed` hint, turn attribution that includes `toolCallId` for workspace artifacts, and regression tests.
|
||||
- Tests: unit tests for the three tools and the policy; bridge passthrough of `updateOnly`; store tests for `updateOnly` (unknown dropped without stat, known updated), cross-source and cross-ownership sync keeping title, retention, and ownership, `write_file` create + `edit` modify advancing `toolCallId` while keeping the title, no resurrection after deletion, forgetting artifacts created by `edit` and `notebook_edit`, and concurrent queued updates; the V2 compatibility contract test.
|
||||
|
||||
Four default extensions added and no settings; the PR delivers value on its own.
|
||||
|
||||
### 5.2 Follow-up PR: show repeated modifications per turn
|
||||
|
||||
Extend the existing transcript-based reference grouping to completed `write_file` / `edit` / `notebook_edit` output paths, matched against the authoritative artifact list by canonical path. The same artifact appears in every turn that modified it, deduplicated within a turn, with an "Updated" badge after the first. Because it is derived from the transcript, it survives refresh and replay without collapsing onto the latest `toolCallId`, and **the store data model does not change**. Cards show current content and do not imply historical snapshots.
|
||||
|
||||
## 6. Split out of scope (separate issues)
|
||||
|
||||
- A bounded per-artifact revision trail (tool call, hash, size) and revision content snapshots.
|
||||
- Detecting and attributing files created or modified by foreground shell commands.
|
||||
- Mapping artifacts from MCP tool result `_meta`.
|
||||
|
||||
## 7. Risks
|
||||
|
||||
- **Treating every write as a new artifact**: bounded by "everything outside the built-in policy is `updateOnly`", with tests.
|
||||
- **Noise in coding sessions and eviction**: the new `.md`, `.json`, and `.py` are common in ordinary coding (README, `package.json`, source files) and become artifacts once the agent modifies them. The 200-entry cap is the risk itself rather than a mitigation: past the cap the oldest non-`clientRetained` entries are evicted, and if every remaining entry is `clientRetained` the oldest of those is evicted too. Mitigation: skip-directory rules unchanged; `record_as_artifact=false` on `write_file`, `edit`, and `notebook_edit` opts out of creating one; no resurrection after deletion. This is the cost of widening defaults instead of adding a setting, and review should confirm it is acceptable.
|
||||
- **Credential-bearing files**: the new extensions also match files such as `certs/service-account.json` or `config/credentials.json`, including gitignored ones, that no skip rule excludes. Recording only lists the path, title, size, and fingerprints in the session; content is not copied or uploaded on this path, and previews and downloads go through the workspace file route the session can already read. No name-based skip is added; `record_as_artifact=false` opts out per call.
|
||||
- **Permission boundary**: content-only sync across ownership bypasses `denyCrossClientMutation`. It is limited to tool inputs from the producer set and to content fields, with tests pinning that title, retention, and ownership never change.
|
||||
- **Write-to-registration window**: a change between the agent's write and the store's read is absorbed into the baseline, as today (see 4.3).
|
||||
- **Performance**: `updateOnly` candidates for unrecorded files short-circuit before stat; recorded files are stat'ed and hashed on each modification (files over 100MB keep today's size-based baseline). Artifacts created by the producer set skip the 5-second presence TTL and are stat'ed on every list/get, so a deleted file is forgotten on the next list. Snapshot-less replay hashes once per recorded write.
|
||||
- **Transcript and diagnostics**: every `edit` of any file adds one candidate (path, title, kind) to the transcript tool result, and `toolResultBoundaryArtifact` diagnostics report an extra `file` kind.
|
||||
- **Journal growth**: one event per update, using the existing snapshot compaction cadence.
|
||||
|
||||
## 8. Validation
|
||||
|
||||
Before implementation, write an E2E plan in `.qwen/e2e-tests/` and dry-run it against the global `qwen` CLI to record the baseline.
|
||||
|
||||
1. Upload `orders.csv` and ask the agent to add a column with `edit` → artifact created, shown in this turn, previewable and downloadable.
|
||||
2. Modify again next turn → same id emits `updated`, status `available`, shown in the latest turn.
|
||||
3. `write_file` creates `report.html`, then `edit` modifies it → same id, `toolCallId` advanced, title unchanged.
|
||||
4. Upload one GBK, one UTF-8 BOM, and one CRLF CSV and have the agent modify each → all `available`, never `changed` (the store hashes the bytes on disk).
|
||||
5. The host registers `data.xlsx` (with a `clientId`) first, then the agent modifies it → synced, status `available`, title, retention, and ownership unchanged.
|
||||
6. `record_artifact` registers `notes.md`, then a later turn `edit`s it → the artifact appears in both the registering turn and the modifying turn.
|
||||
7. Upload `orders.sql` and ask the agent to add a column with `edit` → artifact created without any host registration; same for `.py`, `.md`, `.json`.
|
||||
8. Unrecorded `src/foo.ts` (outside the built-in set) edited → no artifact, and the store does not stat it.
|
||||
9. The user deletes an auto-recorded artifact and the agent modifies the file again → not resurrected; a later `record_artifact` for the path → recreated.
|
||||
10. External modification, or modification through an agent shell command → `changed` with the hint, still downloadable.
|
||||
11. Delete the artifact snapshot and `session/load` (snapshot-less replay) → the same ids as before replay. Status follows a fresh hash of the current file, so an entry that was `changed` comes back `available`.
|
||||
12. In a worktree / multi-workspace setup, write and edit the same file → same id.
|
||||
13. V2 compatibility contract (constraint 6 in 4.7).
|
||||
|
||||
Each code PR runs the affected package unit tests and `npm run build && npm run typecheck`, completes the self-audit, and attaches E2E evidence.
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
1. Should external modifications (`changed`) publish an event so hosts notice them in real time?
|
||||
2. When a host saves user edits through `/file/write`, should that reset the baseline? It requires reaching the session-scoped store from a workspace-scoped route, which raises route-ownership questions, so it is not included for now.
|
||||
3. The content-only sync across ownership in step 2 of 4.4 needs maintainer confirmation of the permission boundary.
|
||||
@@ -0,0 +1,256 @@
|
||||
# Session Artifact 创建与更新生命周期
|
||||
|
||||
[English](artifact-create-update-lifecycle.md) | [简体中文](artifact-create-update-lifecycle.zh-CN.md)
|
||||
|
||||
- 状态:实现中(PR 1)
|
||||
- 日期:2026-10-15
|
||||
- 关联 issue:[#14282](https://github.com/QwenLM/qwen-code/issues/14282)
|
||||
- 相关设计:[session-artifacts-persistence-v2-design.md](session-artifacts-persistence-v2-design.md)、[session-artifacts-directory-expansion.md](../session-artifacts-directory-expansion.md)、[web-shell-tool-file-preview.md](../web-shell-tool-file-preview.md)
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
当前 artifact 基本是"新建时登记一次"的模型:`write_file` 写出特定扩展名的文件时自动登记,其余情况依赖模型主动调用 `record_artifact`。常见场景因此落空:
|
||||
|
||||
- 用户上传一个文件到工作区,让 AI 修改;AI 用 `edit` 修改,不产生 artifact,产物区找不到这个文件。
|
||||
- 已登记的产物被后续 `edit` 修改时不重设基线;之后列表刷新把它标成 `changed`,agent 自己的修改看起来像外部篡改。
|
||||
- 一个文件先由 `write_file` 创建、再由 `edit` 修改时,产物仍停留在创建它的 turn,修改它的 turn 里看不到。
|
||||
|
||||
业务侧如何把非文件资源(例如数据表)登记为 artifact,不在本设计范围内;qwen-code 只需保证工作区文件的创建/更新链路可靠。
|
||||
|
||||
## 2. 现状(基于 `main` 已核实)
|
||||
|
||||
### 2.1 链路
|
||||
|
||||
```text
|
||||
工具 ToolResult.artifacts
|
||||
→ 写入 transcript 的工具结果(record.metadata.artifacts)
|
||||
→ ACP tool_call_update._meta.artifacts (cli: tool-call-emitter.ts)
|
||||
→ bridge extractSessionUpdateArtifacts (acp-bridge: bridgeClient.ts;按字段白名单透传,转发给客户端前剥离 _meta.artifacts)
|
||||
→ SessionArtifactStore.upsertMany (acp-bridge: sessionArtifacts.ts;串行 mutation 队列)
|
||||
expandWorkspaceDirectoryInput → normalizeInput(stat + hash,建立 qwen.workspace.* 基线)
|
||||
→ 不存在则 created;存在则归属检查 → mergeArtifact → 公开字段有变化则 updated
|
||||
→ artifact_changed SSE → web-shell 全量刷新列表
|
||||
```
|
||||
|
||||
其他入口:PostToolUse hook 的 `hookSpecificOutput.artifacts`、客户端 `POST /session/:id/artifacts`(带可信 `clientId`)、slash 命令的 `_meta.sessionArtifacts`。
|
||||
|
||||
### 2.2 哪些工具会产出 artifact
|
||||
|
||||
| 工具 | 是否产出 | 说明 |
|
||||
| ------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `write_file` | 部分 | 仅 `ARTIFACT_KIND_BY_EXTENSION` 中的扩展名(`.csv/.htm/.html/.ipynb/.jpeg/.jpg/.pdf/.png/.svg/.webp` 和 office 文档);`record_as_artifact=false` 可关闭 |
|
||||
| `record_artifact` | 是 | 模型显式调用 |
|
||||
| `artifact` / `image_gen` / omni 工具 | 是 | 发布型 HTML、生成图片、媒体派生物 |
|
||||
| `edit`(包括用 edit 新建文件) | 否 | 所有返回路径都只有 `llmContent` + `returnDisplay` |
|
||||
| `notebook_edit` | 否 | 无任何 artifact 引用 |
|
||||
| `run_shell_command` / MCP 工具 | 否 | 本设计不处理(见第 6 节) |
|
||||
|
||||
### 2.3 Store 语义
|
||||
|
||||
- 身份:`stableSessionArtifactId(sessionId, 'workspace:<canonicalPath>')`,同一路径即同一个 artifact。
|
||||
- 基线:`normalizeInput` 在队列内 stat 并 hash 文件,写入 `qwen.workspace.sha256 / mtimeMs / sizeBytes`;list/get 按 5 秒 TTL 懒刷新,实时内容偏离基线即置 `changed`,**不发布事件**。没有指纹基线的条目在刷新时会把当前内容直接写成基线。
|
||||
- 基线只来自 store 自己的读取:`normalizeMetadata` 剔除输入里的 `qwen.workspace.*` 键;实时 `upsertMany` 调用 `normalizeInput` 时不传 `workspaceExpected`,只有 restore 路径用已持久化的指纹作为期望值。写入与登记之间的外部改动会被直接吸收为基线。
|
||||
- 归属:`denyCrossClientMutation` 拒绝对 `source: 'client'` 且带 `clientId` 的条目做不同 `clientId` 的 upsert。工具来源的输入不带 `clientId`,因此**宿主登记的文件被 agent 修改后的 upsert 会被拒绝**。
|
||||
- 合并:`mergeArtifact` 中 `status`、`sizeBytes` 取本次输入;`mergeMetadata` 在 `incoming.source !== existing.source`(或来源为 hook)时保留旧 metadata,**不采纳新指纹**。
|
||||
- 展示字段(`title`、`description`、`toolCallId`、`toolName`、`source`、`hookEventName`)只在 `shouldRefreshWorkspaceDisplay` 为真时刷新:`record_artifact`(非 hook)、无 `toolName` 的登记,或 `toolName` / `source` / `hookEventName` **三者都相同**。注释明确要求自动登记不得覆盖用户可见标题。
|
||||
- 删除:`shouldSuppressTombstonedUpsert` 只阻止 client 来源复活;被用户删除的 tool / hook 产物,下一次同路径登记会重新创建。
|
||||
- 驱逐:超过 200 条时依次淘汰最老的 missing 条目、超出来源配额的条目、最老的非 `clientRetained` 条目。如果剩下的全都是 `clientRetained`,最后一档会淘汰其中最老的一条。
|
||||
- "文件消失即遗忘"(`isVanishedAutoRecordedWorkspaceArtifact`)和存在性复查(`shouldRecheckWriteFilePresence`)都写死 `toolName === 'write_file'`。
|
||||
|
||||
### 2.4 Web Shell
|
||||
|
||||
- `canDownloadArtifact` 已同时接受 `available` 和 `changed`,`ArtifactPanel` 对两者的预览一致,并有测试覆盖。缺的只是 `changed` 的提示。
|
||||
- turn 归属(`getArtifactsByTurn`):先匹配 `record_artifact` 参数和 `reportedArtifacts` 中的路径;**只有一个都没匹配上时**,才按 `artifact.toolCallId` 归属到一个 turn。
|
||||
|
||||
### 2.5 `ToolResult.artifacts` 的下游消费方
|
||||
|
||||
| 消费方 | 行为 |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| bridge 实时摄入(`sessionUpdate` → `upsertAndPublishArtifacts`) | 写入 artifact store |
|
||||
| transcript 记录与 `session/load` 回放(`transcript-replay.ts`) | 工具结果原样持久化;没有 artifact 快照或快照恢复失败时,`seedSessionUpdates` 按顺序重新摄入 |
|
||||
| `toolResultBoundaryArtifact` | 只统计 artifact 的 kind,用于诊断 |
|
||||
| managed runtime provider 协议 | 超出大小上限时丢弃 `artifacts` |
|
||||
| media policy 收集 | 只对带 `mediaPolicyDescriptor` 的工具生效;`write_file` / `edit` / `notebook_edit` 都没有 |
|
||||
|
||||
### 2.6 问题清单
|
||||
|
||||
| # | 问题 | 后果 |
|
||||
| --- | ----------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| G1 | 只有 `write_file` 自动登记,`edit` / `notebook_edit` 不登记 | 上传后被修改的文件、用 edit 新建的文件找不到 |
|
||||
| G2 | 已登记产物被 `edit` 修改时,没有任何 upsert | 不重设基线、不发事件;之后被标成 `changed` |
|
||||
| G3 | `mergeMetadata` 跨 source 不采纳指纹 | hook 或匿名 client 登记的产物被工具改写后,仍回到 `changed` |
|
||||
| G4 | 带 `clientId` 的宿主登记拒绝工具 upsert | 宿主登记的文件被 agent 修改后不同步,变成 `changed` |
|
||||
| G5 | `shouldRefreshWorkspaceDisplay` 要求 `toolName` 相同才推进 `toolCallId` | `write_file` 创建、`edit` 修改时,产物停在创建的 turn(由评论者指出) |
|
||||
| G6 | Web Shell 有 `record_artifact` 引用时不看 `toolCallId` | 即使推进 `toolCallId`,显式登记过的产物也不会出现在修改它的 turn |
|
||||
| G7 | 遗忘/复查规则写死 `write_file` | 由 `edit` / `notebook_edit` 创建的产物无法被正确遗忘或复查 |
|
||||
| G8 | 用户删除的 tool 产物会被下一次登记复活 | `edit` 自动登记后会频繁发生 |
|
||||
|
||||
## 3. 目标与非目标
|
||||
|
||||
目标:
|
||||
|
||||
1. Agent 用 `write_file` / `edit` / `notebook_edit` 在工作区内**创建或修改**的交付物,按同一策略进入 artifact 列表。
|
||||
2. Agent 修改**已登记**的文件(任意扩展名、任意登记来源,包括带 `clientId` 的宿主登记)时,同一 id 发 `updated`,内容基线同步,状态保持 `available`。宿主登记的标题、retention 与归属不变。
|
||||
3. 产物出现在最近一次修改它的 turn。
|
||||
4. 已有会话里的 artifact 安全共存:身份规则不变,不迁移、不扫盘、不批量重置。
|
||||
|
||||
非目标:
|
||||
|
||||
- 不新增扩展名配置项;内置集合只追加 4.2 列出的四个扩展名。白名单之外的源码(如 `.ts`)被修改时不新建 artifact。
|
||||
- 不做 revision 轨迹、内容快照、shell 产物检测(拆为独立 issue,见第 6 节)。
|
||||
- 不覆盖远程 execution worker 内的文件。
|
||||
|
||||
## 4. 方案
|
||||
|
||||
### 4.1 生命周期规则
|
||||
|
||||
| 事件 | Store 行为 | status | 事件 |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------- | --------- | ----------------- |
|
||||
| Agent 写入匹配策略且未登记的文件 | 新建,建立基线 | available | created |
|
||||
| Agent 写入**已登记**的文件(任意扩展名、任意登记来源) | 更新,同步内容基线,`toolCallId` 推进到本次调用 | available | updated |
|
||||
| Agent 写入未登记且不匹配策略的文件 | 忽略(不 stat、不 hash) | – | – |
|
||||
| Agent 写入**被用户删除过**的路径 | 按 `updateOnly` 处理,不复活;显式 `record_artifact` 仍可重建 | – | – |
|
||||
| 写入后、登记前文件又被改动 | 与今天一致,被吸收为基线(见 4.3) | available | created / updated |
|
||||
| `write_file` 写入与原内容相同的内容 | 与普通写入相同处理(不比较内容) | available | created / updated |
|
||||
| 真正的 no-op edit / 写入失败 | 不产生候选 | – | – |
|
||||
| 用户或其他方式修改(包括 agent 的 shell 命令) | 懒刷新时发现 | changed | 无(不变) |
|
||||
| 文件被删除 | 自动登记的产物被遗忘;显式登记的标为 missing | missing | removed / 无 |
|
||||
| `write_file` 设置 `record_as_artifact=false` | 只阻止新建;已登记的产物仍同步 | – | – |
|
||||
|
||||
语义:`changed` 只表示"偏离了最近一次 agent 文件工具写入的内容"。
|
||||
|
||||
### 4.2 共享自动登记策略
|
||||
|
||||
把 `write-file.ts` 中的扩展名映射、工作区路径解析、跳过目录、展示安全检查、MIME/kind 推断抽到 `packages/core/src/tools/artifact/workspace-auto-record.ts`。`write_file`、`edit`(含新建)、`notebook_edit` 在**写入成功提交之后**调用:
|
||||
|
||||
- 返回 `null`:artifact 功能关闭、不在工作区、位于跳过目录、路径或标题不安全。
|
||||
- 匹配内置扩展名:返回普通候选。
|
||||
- 不匹配,或 `record_as_artifact=false`:返回 `updateOnly: true` 的候选。
|
||||
|
||||
内置扩展名集合在现有基础上追加 `.sql`、`.py`、`.md`、`.json`,kind 均为 `file`。它们是数据与分析场景最常见的交付物;不做成配置项,是为了不引入新的 settings 面。现有的跳过规则(点目录、`node_modules`、`dist` 等)继续生效。`write_file`、`edit`、`notebook_edit` 上的 `record_as_artifact=false` 不新建产物;该路径上会话里已经有的产物仍会刷新。artifact 登记出错不得让一次成功的文件写入变成失败的工具调用:构造候选只做路径解析(realpath 失败时退回字面路径),store 在工具结果返回之后才登记。只有内置扩展名的候选在 `llmContent` 追加提示,`updateOnly` 候选不追加。
|
||||
|
||||
### 4.3 入站字段:`updateOnly`
|
||||
|
||||
```ts
|
||||
interface ToolArtifact {
|
||||
// ...existing
|
||||
/** 只刷新会话里已有的 artifact,从不新建。 */
|
||||
updateOnly?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
- 该字段要在 `bridgeClient.ts` 的 `artifactPayloadFields` 白名单中显式加入,并加到 `SessionArtifactInput`,否则会被过滤掉。
|
||||
- 它不进入 artifact 的 journal/snapshot,也不出现在公开 artifact 上;但会随工具结果写入 transcript。无快照回放时按原顺序重放,与今天的 `write_file` 一样,store 以当前文件内容为基线。
|
||||
|
||||
基线仍来自 store 自己的读取,工具不传期望指纹。早期草案曾加入 `expectedSha256`(工具写出字节的 hash),让写入与登记之间的改动保持 `changed`;现已去掉:daemon 模式下 bridge 声明了 `fs.writeTextFile` 客户端能力,agent 的写盘经 ACP 委托给 bridge 的文件系统适配器,agent 手里没有最终落盘的字节,主路径上这个字段会一直缺失。由 store 读取磁盘字节,还意味着工具保留编码的文件(GBK、UTF-8 BOM、CRLF)无需工具侧处理即可保持 `available`。代价是写入与 store 读取之间那个很短的窗口内发生的改动会被吸收为基线,与今天完全一致。
|
||||
|
||||
### 4.4 Store 改动(`sessionArtifacts.ts`)
|
||||
|
||||
自动登记生产者集合为 tool 来源的 `{write_file, edit, notebook_edit}`。
|
||||
|
||||
1. **`updateOnly` 先短路**:在串行队列内、`expandWorkspaceDirectoryInput` 之前,用与 `normalizeInput` 相同的路径规范化算出身份,并按 `upsertMany` 相同的查找方式(先 id,再 published workspace 目标)检查是否存在;不存在则直接丢弃——不建、不删、不驱逐、不发布、不 stat/hash,并写 `action=dropped reason="unmatched workspace update"`。存在时走正常的 `normalizeInput`。
|
||||
2. **只同步内容(跨归属)**:来自生产者集合的输入跳过 `denyCrossClientMutation`,因此带 `clientId` 的 client 登记会接受 agent 的写入。合并只改内容字段:指纹基线、`status`、`sizeBytes`、`toolCallId`。`title`、`description`、`source`、`clientId`、retention、`clientRetained` 一律不变:`mergeArtifact` 保留已有的 `source` 与归属,展示刷新规则不接受该工具名,`mergeRetention` 不会覆盖显式 retention。其他工具输入(如 `record_artifact`,或带着生产者 `toolName` 的 hook 输入)仍被拒绝。这是已经接受的权限边界。
|
||||
3. **跨 source 采纳指纹**:对生产者集合的输入,`mergeMetadata` 采纳 `isAdoptableContentFingerprintKey` 的键时不再要求 source 相同,且不新增其他键;其他输入、hook 来源输入、发布者信任规则不变。
|
||||
4. **按文件身份推进 turn 归属**:把"推进 `toolCallId`"与"刷新标题/描述"拆开。生产者集合的输入合并到 workspace 条目时,`toolCallId` 推进到本次调用;`title`、`description`、`toolName`、`source`、`hookEventName` 仍按现有 `shouldRefreshWorkspaceDisplay` 规则,自动登记不得覆盖用户可见标题。`toolName` 保留创建者,使遗忘规则仍按"谁创建"判定。
|
||||
5. **删除后不复活**:生产者集合的非 `updateOnly` 候选命中 tombstone 时,按 `updateOnly` 处理(因不存在而丢弃)。显式 `record_artifact`、hook 与客户端登记的复活规则不变。
|
||||
6. **泛化遗忘/复查谓词**:`toolName === 'write_file'` 改为 `toolName ∈ {write_file, edit, notebook_edit}`,与今天一样不判断 source。因此 `toolName` 落在这个集合里的 hook 条目,文件消失时会被遗忘,和今天的 `write_file` hook 条目一样;这些条目在每次 list/get 时都会重新 stat,不受 5 秒存在性 TTL 保护。client 登记和显式 `record_artifact` 保留各自的 `toolName`,行为不变。
|
||||
|
||||
不采用的替代做法:让 `edit` / `notebook_edit` 伪装成 `toolName: 'write_file'`(耦合到一个不真实的名称);整体放宽 `shouldRefreshWorkspaceDisplay`(会让自动登记覆盖用户标题)。
|
||||
|
||||
### 4.5 Web Shell(第一个实现 PR 内)
|
||||
|
||||
- 不改下载规则(`changed` 已可下载)。
|
||||
- `changed` 卡片的 meta 行增加"登记后已被修改"提示(不写"会话外",因为 agent 的 shell 命令也会造成 `changed`)。现有测试已固定 `changed` 可下载的行为。
|
||||
- `getArtifactsByTurn`:对 workspace 产物,在 `record_artifact` / `reportedArtifacts` 匹配之外,**同时**加入 `artifact.toolCallId` 所在 turn(目前只在没有匹配时才用)。显式登记过的文件因此也会出现在最近一次修改它的 turn;对今天的条目,`toolCallId` 与登记调用在同一 turn,结果不变。published 与 link 产物的 `toolCallId` 不随后续写入推进,保持旧规则。不改 `artifactsByTurn` 类型。
|
||||
|
||||
### 4.6 上传文件
|
||||
|
||||
- 上传到工作区后被 agent 修改的文件:匹配内置扩展名(含新增的 `.sql`、`.py`、`.md`、`.json`)时,第一次修改即由 4.2 新建,宿主无需在上传时额外登记。
|
||||
- 内置集合之外的类型:agent 修改它们不会新建 artifact;只有在已经以其他方式登记过时,才会经 `updateOnly` 同步。
|
||||
- 只上传、未被修改的文件是输入,qwen-code 不主动登记。
|
||||
|
||||
### 4.7 对已有 artifact 的兼容性
|
||||
|
||||
**不需要数据迁移,不会扫盘补登记,不改已有 artifact 的 id。** artifact 持久化仍为 V2 journal/snapshot,不新增持久化字段。
|
||||
|
||||
| 场景 | 升级后行为 | 处理 |
|
||||
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| 已有 artifact,升级后无人再改 | 与今天一致 | 无 |
|
||||
| 工作区里从未登记过的文件 | 不会被补登记 | 只有 agent 再次写入(且匹配策略)或显式登记才可能新建 |
|
||||
| 已有会话中的 `.sql/.py/.md/.json` 文件 | 不补登记;升级后 agent 再次修改时才新建 | 默认值放宽带来的预期行为变化 |
|
||||
| 已有 artifact 被 agent 再次修改 | 同一 id 发 `updated`,同步基线,`toolCallId` 推进 | 这是修复 |
|
||||
| 已经是 `changed` 的旧条目(多为过去 edit 未同步) | 保持 `changed`(可下载),直到下次 agent 写入 | **不批量重置**:无法区分旧缺陷与真实外部修改 |
|
||||
| 宿主(带 `clientId`)登记的产物 | agent 修改时只同步内容;标题、retention、归属、tombstone 不变 | 已在 4.4 第 2 步接受 |
|
||||
| hook / `record_artifact` 登记的产物 | 身份、标题、retention 不变;agent 修改时采纳新指纹 | 无 |
|
||||
| 用户删除过的 `write_file` 产物 | **行为变化**:今天再次写入会复活,升级后不复活 | 需要时用 `record_artifact` 显式重建 |
|
||||
| published / managed / link / external_url | 生产者写入不会更新 published 条目,其内容字段保持冻结 | 无 |
|
||||
| 旧 transcript 的无快照回放 | id 不变。回放没有保存的指纹,会对当前文件重新求 hash 并写成基线,原先 `changed` 的条目会变成 `available` | 这不是对已存快照的批量转换 |
|
||||
| core 与 acp-bridge 版本 | 只支持 daemon 与 agent 来自同一次安装。`QWEN_CLI_ENTRY` 可以指向另一份代码;拆开安装不受支持,旧 bridge 会丢掉 `updateOnly` | 不要拆开安装 |
|
||||
| 旧 web-shell + 新 daemon | 没有「登记后已被修改」提示。daemon 仍会推进 `toolCallId`,旧壳的回退只把卡片放在最近一次写入的 turn,不会同时出现在登记 turn 和修改 turn | 无 |
|
||||
|
||||
实现与评审必须保持的约束:
|
||||
|
||||
1. 身份键不变:`workspace:<canonicalPath>`(及 managed/url)。
|
||||
2. 不扫盘、不 backfill。
|
||||
3. 升级或快照恢复时,不批量把已存的 `changed` 改成 `available`。无快照回放没有保存的指纹,会把当前文件写成基线。
|
||||
4. `updateOnly` 永不删除或驱逐已有条目。
|
||||
5. 跨归属只同步内容字段,不改标题、retention、归属。
|
||||
6. 契约测试:用升级前写下的 V2 snapshot restore,断言条数与 id 不变;对其中一条 `edit`,断言同一 id、动作为 `updated`;另一条未触碰但已被外部修改的条目仍为 `changed`。
|
||||
|
||||
## 5. 分期
|
||||
|
||||
### 5.1 PR 1:一致的创建/更新(本设计主体)
|
||||
|
||||
- core:共享策略模块;`write_file` 改为调用它;`edit`、`notebook_edit` 返回 artifacts;`ToolArtifact.updateOnly`;工具描述同步。
|
||||
- acp-bridge:白名单透传新字段;`updateOnly` 短路;跨归属只同步内容;跨 source 指纹采纳;`toolCallId` 按文件身份推进;删除后不复活;遗忘/复查谓词泛化。
|
||||
- web-shell:`changed` 提示、workspace 产物的 turn 归属并入 `toolCallId`,以及回归测试。
|
||||
- 测试:三个工具与策略模块的单测;bridge 透传 `updateOnly`;store 覆盖 `updateOnly`(未知丢弃且不 stat、已知更新)、跨 source 与跨归属同步且标题/retention/归属不变、`write_file` 建 + `edit` 改后 `toolCallId` 推进且标题不变、删除后不复活、由 `edit` 与 `notebook_edit` 创建的产物被遗忘、并发排队更新;V2 兼容契约测试。
|
||||
|
||||
默认扩展名追加四项,不加配置,PR 本身即可独立交付价值。
|
||||
|
||||
### 5.2 后续 PR:按 turn 展示多次修改
|
||||
|
||||
在现有基于 transcript 的引用分组上,纳入已完成的 `write_file` / `edit` / `notebook_edit` 输出路径,与权威 artifact 列表按规范路径匹配;同一产物在每个修改它的 turn 显示,turn 内去重,后续出现带"已更新"标记。由 transcript 派生,刷新与回放后不坍缩到最新 `toolCallId`,**不改 store 数据模型**。卡片展示当前内容,不代表历史快照。
|
||||
|
||||
## 6. 拆分出去的范围(单独开 issue)
|
||||
|
||||
- 每个 artifact 的有界 revision 轨迹(工具调用、hash、大小)及 revision 内容快照。
|
||||
- 前台 shell 命令创建/修改文件的检测与归因。
|
||||
- MCP 工具结果 `_meta` 的 artifacts 映射。
|
||||
|
||||
## 7. 风险
|
||||
|
||||
- **把每次写入都当成新产物**:由"内置策略之外一律 `updateOnly`"约束,并有测试覆盖。
|
||||
- **编码场景噪音与驱逐**:新增的 `.md`、`.json`、`.py` 在普通编码会话里很常见(README、`package.json`、源码),被 agent 修改后都会成为 artifact。200 条上限不是缓解而是风险本身:超限后最老的非 `clientRetained` 条目被淘汰;如果剩下的全都是 `clientRetained`,其中最老的一条也会被淘汰。缓解:跳过目录规则不变;`write_file`、`edit`、`notebook_edit` 上的 `record_as_artifact=false` 可以不新建;删除后不复活。这是放宽默认值而非新增配置的代价,评审时需确认可接受。
|
||||
- **含凭据的文件**:新增扩展名也会命中 `certs/service-account.json`、`config/credentials.json` 这类文件(包括被 gitignore 的),现有跳过规则都不排除它们。登记只在会话内列出路径、标题、大小和指纹;这条路径不复制也不上传内容,预览与下载走会话本来就能读取的工作区文件路由。不增加按文件名的跳过规则;`record_as_artifact=false` 可逐次关闭。
|
||||
- **权限边界**:跨归属内容同步绕过了 `denyCrossClientMutation`。范围限定为生产者集合的 tool 输入和内容字段,并有测试固定不改标题、retention、归属。
|
||||
- **写入到登记的窗口**:agent 写入与 store 读取之间的改动会被吸收为基线,与今天一致(见 4.3)。
|
||||
- **性能**:未登记文件的 `updateOnly` 候选在 stat 之前短路;已登记文件每次修改 stat + hash(超过 100MB 的文件保持今天按大小建立基线的行为)。生产者集合创建的产物跳过 5 秒存在性 TTL,每次 list/get 都会 stat,所以文件被删后下一次 list 就会遗忘。无快照回放时,每条写入记录都会 hash 一次。
|
||||
- **transcript 与诊断**:任何文件的每次 `edit` 都会在 transcript 工具结果中多一条候选记录(路径、标题、kind),`toolResultBoundaryArtifact` 的诊断也会多报 `file` kind。
|
||||
- **journal 增长**:每次更新一条事件,沿用现有 snapshot 压缩节奏。
|
||||
|
||||
## 8. 验证
|
||||
|
||||
实现前在 `.qwen/e2e-tests/` 写 E2E 计划,并先对全局 `qwen` CLI dry-run 记录基线。
|
||||
|
||||
1. 上传 `orders.csv`,让 AI 用 `edit` 加一列 → 产物新建、出现在本 turn、可预览可下载。
|
||||
2. 下一轮再改 → 同一 id 发 `updated`,状态 `available`,产物出现在最新 turn。
|
||||
3. 先 `write_file` 建 `report.html`,再 `edit` 修改 → 同一 id,`toolCallId` 推进,标题不变。
|
||||
4. 上传 GBK 编码、UTF-8 BOM、CRLF 换行的 CSV 各一份,让 agent 修改 → 均为 `available`,不出现 `changed`(store 对磁盘字节求 hash)。
|
||||
5. 宿主先登记 `data.xlsx`(带 `clientId`),再让 agent 修改 → 同步更新,状态 `available`,标题、retention、归属不变。
|
||||
6. 先 `record_artifact` 登记 `notes.md`,再在后续 turn `edit` → 产物同时出现在登记 turn 与修改 turn。
|
||||
7. 上传 `orders.sql`,让 agent 用 `edit` 加字段 → 产物新建,宿主未做任何登记;`.py`、`.md`、`.json` 同理。
|
||||
8. 未登记的 `src/foo.ts`(不在内置集合)被 `edit` → 不产生 artifact,store 不 stat。
|
||||
9. 用户删除一个自动登记的产物,agent 再次修改该文件 → 不复活;之后 `record_artifact` 该路径 → 重建。
|
||||
10. 外部修改或 agent 用 shell 修改文件 → `changed`,带提示,仍可下载。
|
||||
11. 删除 artifact 快照后 `session/load`(无快照回放)→ id 与回放前一致。状态按当前文件重新求 hash,原先 `changed` 的条目会变成 `available`。
|
||||
12. worktree / 多 workspace 下 write 与 edit 同一文件 → 同一 id。
|
||||
13. V2 兼容契约(4.7 第 6 条)。
|
||||
|
||||
每个代码 PR 运行相关包的单测、`npm run build && npm run typecheck`,完成自审,并附 E2E 证据。
|
||||
|
||||
## 9. 开放问题
|
||||
|
||||
1. 外部修改(`changed`)是否需要主动发布事件,让 host 实时感知?
|
||||
2. 宿主通过 `/file/write` 保存用户修改时,是否应重设基线?这需要从 workspace 作用域路由定位到 session 作用域 store,牵涉路由归属,暂不纳入。
|
||||
3. 4.4 第 2 步的跨归属内容同步需要维护者确认权限边界。
|
||||
@@ -2647,6 +2647,67 @@ describe('BridgeClient — artifact ingress', () => {
|
||||
).toEqual({ toolName: 'record_artifact' });
|
||||
});
|
||||
|
||||
it('forwards the update-only flag of file-write tool artifacts', async () => {
|
||||
const sessionId = 'sess:update-only-artifacts';
|
||||
const upsertMany = vi.fn().mockResolvedValue({ changes: [] });
|
||||
const fakeEntry = {
|
||||
sessionId,
|
||||
events: { publish: vi.fn().mockReturnValue(true) },
|
||||
artifactWorkspaceReady: true,
|
||||
deferredArtifactBatches: [] as BridgeClientDeferredArtifactBatch[],
|
||||
deferredArtifactInputCount: 0,
|
||||
artifacts: {
|
||||
inputBatchLimit: () => 400,
|
||||
upsertMany,
|
||||
},
|
||||
pendingPermissionIds: new Set<string>(),
|
||||
pendingInteractions: new Map(),
|
||||
midTurnMessageQueue: [] as MidTurnQueueEntry[],
|
||||
settledMidTurnMessageIds: [] as string[],
|
||||
promptActive: true,
|
||||
};
|
||||
const client = new BridgeClient(
|
||||
((sid: string) => (sid === sessionId ? fakeEntry : undefined)) as never,
|
||||
noPermissionFlow as never,
|
||||
{ request: noPermissionFlow } as never,
|
||||
0,
|
||||
Infinity,
|
||||
);
|
||||
|
||||
await client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-edit',
|
||||
status: 'completed',
|
||||
content: [],
|
||||
_meta: {
|
||||
toolName: 'edit',
|
||||
artifacts: [
|
||||
{
|
||||
title: 'index.ts',
|
||||
storage: 'workspace',
|
||||
workspacePath: 'src/index.ts',
|
||||
updateOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as Parameters<BridgeClient['sessionUpdate']>[0]);
|
||||
|
||||
expect(upsertMany).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
workspacePath: 'src/index.ts',
|
||||
toolCallId: 'call-edit',
|
||||
toolName: 'edit',
|
||||
updateOnly: true,
|
||||
}),
|
||||
],
|
||||
{ trustedPublisher: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('does not store artifact metadata from non-tool session updates', async () => {
|
||||
const sessionId = 'sess:non-tool-artifacts';
|
||||
const publish = vi.fn().mockReturnValue(true);
|
||||
|
||||
@@ -415,6 +415,7 @@ function artifactPayloadFields(
|
||||
sizeBytes: artifact['sizeBytes'] as number | undefined,
|
||||
metadata: artifact['metadata'] as SessionArtifactInput['metadata'],
|
||||
retention: artifact['retention'] as SessionArtifactInput['retention'],
|
||||
updateOnly: artifact['updateOnly'] as boolean | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SessionArtifactAuthorizationError,
|
||||
SessionArtifactStore,
|
||||
SessionArtifactValidationError,
|
||||
type SessionArtifactInput,
|
||||
} from './sessionArtifacts.js';
|
||||
import {
|
||||
rebuildSessionArtifactSnapshot,
|
||||
@@ -9171,3 +9172,408 @@ describe('SessionArtifactStore', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionArtifactStore workspace auto-record lifecycle', () => {
|
||||
let workspace: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-artifacts-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const sha256 = (content: string) =>
|
||||
createHash('sha256').update(content).digest('hex');
|
||||
|
||||
function createStore(sessionId: string) {
|
||||
return new SessionArtifactStore({
|
||||
sessionId,
|
||||
workspaceCwd: workspace,
|
||||
persistence: {
|
||||
recordEvent: async () => {},
|
||||
recordSnapshot: async () => {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function writeWorkspaceFile(workspacePath: string, content: string) {
|
||||
const filePath = path.join(workspace, workspacePath);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content);
|
||||
}
|
||||
|
||||
function fileWrite(
|
||||
workspacePath: string,
|
||||
toolName: string,
|
||||
toolCallId: string,
|
||||
extra: Partial<SessionArtifactInput> = {},
|
||||
): SessionArtifactInput {
|
||||
return {
|
||||
title: path.basename(workspacePath),
|
||||
storage: 'workspace',
|
||||
workspacePath,
|
||||
source: 'tool',
|
||||
toolName,
|
||||
toolCallId,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
it('drops an update-only write for an unknown file without inspecting it', async () => {
|
||||
const store = createStore('auto-record-update-only-unknown');
|
||||
await writeWorkspaceFile('src/index.ts', 'export {};\n');
|
||||
const lstat = vi.spyOn(fs, 'lstat');
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
|
||||
const result = await store.upsertMany([
|
||||
fileWrite('src/index.ts', 'edit', 'call-1', { updateOnly: true }),
|
||||
]);
|
||||
|
||||
expect(
|
||||
lstat.mock.calls.some(([entry]) => String(entry).endsWith('index.ts')),
|
||||
).toBe(false);
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('reason="unmatched workspace update"'),
|
||||
);
|
||||
await expect(store.list()).resolves.toMatchObject({ artifacts: [] });
|
||||
});
|
||||
|
||||
it('refreshes a recorded file through an update-only write', async () => {
|
||||
const store = createStore('auto-record-update-only-known');
|
||||
await writeWorkspaceFile('src/main.py', 'print(1)\n');
|
||||
const recorded = await store.upsertMany([
|
||||
fileWrite('src/main.py', 'record_artifact', 'record-1', {
|
||||
title: 'Main script',
|
||||
}),
|
||||
]);
|
||||
const artifactId = recorded.changes[0]!.artifactId;
|
||||
|
||||
await writeWorkspaceFile('src/main.py', 'print(2)\n');
|
||||
const result = await store.upsertMany([
|
||||
fileWrite('src/main.py', 'edit', 'edit-1', { updateOnly: true }),
|
||||
]);
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
expect.objectContaining({
|
||||
action: 'updated',
|
||||
artifactId,
|
||||
artifact: expect.objectContaining({
|
||||
title: 'Main script',
|
||||
status: 'available',
|
||||
toolName: 'record_artifact',
|
||||
toolCallId: 'edit-1',
|
||||
metadata: expect.objectContaining({
|
||||
'qwen.workspace.sha256': sha256('print(2)\n'),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps one artifact when write_file creates a file and edit changes it', async () => {
|
||||
const store = createStore('auto-record-write-then-edit');
|
||||
await writeWorkspaceFile('report.html', '<p>v1</p>');
|
||||
const created = await store.upsertMany([
|
||||
fileWrite('report.html', 'write_file', 'write-1'),
|
||||
]);
|
||||
const artifactId = created.changes[0]!.artifactId;
|
||||
|
||||
await writeWorkspaceFile('report.html', '<p>v2</p>');
|
||||
const edited = await store.upsertMany([
|
||||
fileWrite('report.html', 'edit', 'edit-1', { title: 'ignored.html' }),
|
||||
]);
|
||||
|
||||
expect(edited.changes).toEqual([
|
||||
expect.objectContaining({
|
||||
action: 'updated',
|
||||
artifactId,
|
||||
artifact: expect.objectContaining({
|
||||
title: 'report.html',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'edit-1',
|
||||
status: 'available',
|
||||
metadata: expect.objectContaining({
|
||||
'qwen.workspace.sha256': sha256('<p>v2</p>'),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
await expect(
|
||||
store.upsertMany([fileWrite('report.html', 'edit', 'edit-1')]),
|
||||
).resolves.toMatchObject({ changes: [] });
|
||||
});
|
||||
|
||||
it('attributes a batched create and edit of one file to the edit', async () => {
|
||||
const store = createStore('auto-record-batched-write-then-edit');
|
||||
await writeWorkspaceFile('report.html', '<p>v2</p>');
|
||||
|
||||
const result = await store.upsertMany([
|
||||
fileWrite('report.html', 'write_file', 'write-1'),
|
||||
fileWrite('report.html', 'edit', 'edit-1', { title: 'ignored.html' }),
|
||||
]);
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
expect.objectContaining({
|
||||
action: 'created',
|
||||
artifact: expect.objectContaining({
|
||||
title: 'report.html',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'edit-1',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes concurrent writes to one file into a single artifact', async () => {
|
||||
const store = createStore('auto-record-concurrent');
|
||||
await writeWorkspaceFile('orders.csv', 'id\n1\n');
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
store.upsertMany([fileWrite('orders.csv', 'write_file', 'write-1')]),
|
||||
store.upsertMany([fileWrite('orders.csv', 'edit', 'edit-1')]),
|
||||
]);
|
||||
|
||||
expect(first.changes.map((change) => change.action)).toEqual(['created']);
|
||||
expect(second.changes.map((change) => change.action)).toEqual(['updated']);
|
||||
const listed = await store.list();
|
||||
expect(listed.artifacts).toHaveLength(1);
|
||||
expect(listed.artifacts[0]).toMatchObject({
|
||||
toolCallId: 'edit-1',
|
||||
status: 'available',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['hook', { source: 'hook', hookEventName: 'PostToolUse' }],
|
||||
['anonymous client', { source: 'client' }],
|
||||
] as const)(
|
||||
're-baselines a %s artifact when an agent writes the file',
|
||||
async (_label, owner) => {
|
||||
const store = createStore(`auto-record-cross-source-${owner.source}`);
|
||||
await writeWorkspaceFile('data.json', '{"v":1}');
|
||||
const recorded = await store.upsertMany([
|
||||
{
|
||||
title: 'Dataset',
|
||||
workspacePath: 'data.json',
|
||||
toolName: 'write_file',
|
||||
metadata: { owner: 'original' },
|
||||
...owner,
|
||||
},
|
||||
]);
|
||||
const artifactId = recorded.changes[0]!.artifactId;
|
||||
|
||||
await writeWorkspaceFile('data.json', '{"v":2}');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(Date.now() + 6_000));
|
||||
const result = await store.upsertMany([
|
||||
fileWrite('data.json', 'edit', 'edit-1', {
|
||||
metadata: { owner: 'agent', note: 'from the edit' },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
expect.objectContaining({ action: 'updated', artifactId }),
|
||||
]);
|
||||
vi.setSystemTime(new Date(Date.now() + 6_000));
|
||||
const [artifact] = (await store.list()).artifacts;
|
||||
expect(artifact).toMatchObject({
|
||||
title: 'Dataset',
|
||||
source: owner.source,
|
||||
status: 'available',
|
||||
toolCallId: 'edit-1',
|
||||
metadata: {
|
||||
owner: 'original',
|
||||
'qwen.workspace.sha256': sha256('{"v":2}'),
|
||||
},
|
||||
});
|
||||
expect(artifact?.metadata).not.toHaveProperty('note');
|
||||
},
|
||||
);
|
||||
|
||||
it('syncs only the content of a client-owned artifact the agent edits', async () => {
|
||||
const store = createStore('auto-record-client-owned');
|
||||
await writeWorkspaceFile('upload/data.csv', 'a\n1\n');
|
||||
const recorded = await store.upsertMany([
|
||||
{
|
||||
title: 'Uploaded data',
|
||||
workspacePath: 'upload/data.csv',
|
||||
source: 'client',
|
||||
clientId: 'client-a',
|
||||
retention: 'ephemeral',
|
||||
},
|
||||
]);
|
||||
const artifactId = recorded.changes[0]!.artifactId;
|
||||
|
||||
await writeWorkspaceFile('upload/data.csv', 'a,b\n1,2\n');
|
||||
const edited = await store.upsertMany([
|
||||
fileWrite('upload/data.csv', 'edit', 'edit-1'),
|
||||
]);
|
||||
|
||||
expect(edited.warnings).toBeUndefined();
|
||||
expect(edited.changes).toEqual([
|
||||
expect.objectContaining({
|
||||
action: 'updated',
|
||||
artifactId,
|
||||
artifact: expect.objectContaining({
|
||||
title: 'Uploaded data',
|
||||
source: 'client',
|
||||
clientRetained: true,
|
||||
retention: 'ephemeral',
|
||||
status: 'available',
|
||||
toolCallId: 'edit-1',
|
||||
sizeBytes: 8,
|
||||
metadata: expect.objectContaining({
|
||||
'qwen.workspace.sha256': sha256('a,b\n1,2\n'),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
const recordedByTool = await store.upsertMany([
|
||||
fileWrite('upload/data.csv', 'record_artifact', 'record-1', {
|
||||
title: 'Hijacked',
|
||||
}),
|
||||
]);
|
||||
expect(recordedByTool.changes).toEqual([]);
|
||||
expect(recordedByTool.warnings).toEqual([
|
||||
`artifact ${artifactId} is owned by a different client`,
|
||||
]);
|
||||
|
||||
await writeWorkspaceFile('upload/data.csv', 'a,b,c\n1,2,3\n');
|
||||
const hooked = await store.upsertMany([
|
||||
fileWrite('upload/data.csv', 'edit', 'hook-1', {
|
||||
source: 'hook',
|
||||
hookEventName: 'PostToolUse',
|
||||
}),
|
||||
]);
|
||||
expect(hooked.changes).toEqual([]);
|
||||
expect(hooked.warnings).toEqual([
|
||||
`artifact ${artifactId} is owned by a different client`,
|
||||
]);
|
||||
await expect(store.list()).resolves.toMatchObject({
|
||||
artifacts: [
|
||||
expect.objectContaining({
|
||||
title: 'Uploaded data',
|
||||
metadata: expect.objectContaining({
|
||||
'qwen.workspace.sha256': sha256('a,b\n1,2\n'),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not resurrect a deleted auto-recorded artifact on later writes', async () => {
|
||||
const store = createStore('auto-record-no-resurrection');
|
||||
await writeWorkspaceFile('notes.md', '# v1\n');
|
||||
const created = await store.upsertMany([
|
||||
fileWrite('notes.md', 'write_file', 'write-1'),
|
||||
]);
|
||||
const artifactId = created.changes[0]!.artifactId;
|
||||
await expect(store.remove(artifactId)).resolves.toMatchObject({
|
||||
changes: [{ action: 'removed', reason: 'explicit' }],
|
||||
});
|
||||
|
||||
await writeWorkspaceFile('notes.md', '# v2\n');
|
||||
for (const toolName of ['write_file', 'edit', 'notebook_edit']) {
|
||||
await expect(
|
||||
store.upsertMany([fileWrite('notes.md', toolName, `${toolName}-2`)]),
|
||||
).resolves.toMatchObject({ changes: [] });
|
||||
}
|
||||
await expect(store.list()).resolves.toMatchObject({ artifacts: [] });
|
||||
|
||||
const rerecorded = await store.upsertMany([
|
||||
fileWrite('notes.md', 'record_artifact', 'record-1'),
|
||||
]);
|
||||
expect(rerecorded.changes).toEqual([
|
||||
expect.objectContaining({ action: 'created', artifactId }),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['edit', 'notebook_edit'])(
|
||||
'forgets an artifact created by %s after its file is deleted',
|
||||
async (toolName) => {
|
||||
const store = createStore(`auto-record-vanish-${toolName}`);
|
||||
await writeWorkspaceFile('analysis.ipynb', '{"cells":[]}');
|
||||
const created = await store.upsertMany([
|
||||
fileWrite('analysis.ipynb', toolName, 'call-1'),
|
||||
]);
|
||||
expect(created.changes[0]?.artifactId).toEqual(expect.any(String));
|
||||
|
||||
await fs.rm(path.join(workspace, 'analysis.ipynb'));
|
||||
|
||||
await expect(store.list()).resolves.toMatchObject({ artifacts: [] });
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps restored artifacts and updates the one the agent edits again', async () => {
|
||||
const sessionId = 'auto-record-v2-compat';
|
||||
await writeWorkspaceFile('edited.csv', 'old\n');
|
||||
await writeWorkspaceFile('external.csv', 'external change\n');
|
||||
const persisted = (workspacePath: string, content: string) => ({
|
||||
id: stableSessionArtifactId(sessionId, `workspace:${workspacePath}`),
|
||||
kind: 'file' as const,
|
||||
storage: 'workspace' as const,
|
||||
source: 'tool' as const,
|
||||
status: 'available' as const,
|
||||
title: workspacePath,
|
||||
workspacePath,
|
||||
sizeBytes: Buffer.byteLength(content),
|
||||
metadata: {
|
||||
'qwen.workspace.sha256': sha256(content),
|
||||
'qwen.workspace.mtimeMs': 0,
|
||||
'qwen.workspace.sizeBytes': Buffer.byteLength(content),
|
||||
},
|
||||
retention: 'restorable' as const,
|
||||
clientRetained: false,
|
||||
createdAt: '2026-07-04T00:00:00.000Z',
|
||||
updatedAt: '2026-07-04T00:00:00.000Z',
|
||||
persistedAt: '2026-07-04T00:00:00.000Z',
|
||||
toolCallId: 'write-0',
|
||||
toolName: 'write_file',
|
||||
});
|
||||
const artifacts = [
|
||||
persisted('edited.csv', 'old\n'),
|
||||
persisted('external.csv', 'original\n'),
|
||||
];
|
||||
const store = createStore(sessionId);
|
||||
await store.restore({
|
||||
v: 2,
|
||||
sessionId,
|
||||
sequence: 1,
|
||||
artifacts,
|
||||
tombstonedIds: [],
|
||||
stickyEphemeralIds: [],
|
||||
warnings: [],
|
||||
});
|
||||
expect((await store.list()).artifacts.map((a) => a.id)).toEqual(
|
||||
artifacts.map((a) => a.id),
|
||||
);
|
||||
|
||||
await writeWorkspaceFile('edited.csv', 'new\n');
|
||||
const edited = await store.upsertMany([
|
||||
fileWrite('edited.csv', 'edit', 'edit-1'),
|
||||
]);
|
||||
|
||||
expect(edited.changes).toEqual([
|
||||
expect.objectContaining({
|
||||
action: 'updated',
|
||||
artifactId: artifacts[0]!.id,
|
||||
artifact: expect.objectContaining({
|
||||
status: 'available',
|
||||
toolCallId: 'edit-1',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect((await store.list()).artifacts).toEqual([
|
||||
expect.objectContaining({ id: artifacts[0]!.id, status: 'available' }),
|
||||
expect.objectContaining({ id: artifacts[1]!.id, status: 'changed' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -84,6 +84,12 @@ const MAX_SNAPSHOT_BACKOFF_MULTIPLIER = 4;
|
||||
const MAX_TOMBSTONED_IDS = 500;
|
||||
const MAX_STICKY_EPHEMERAL_IDS = 500;
|
||||
const MAX_WORKSPACE_HASH_BYTES = 100 * 1024 * 1024;
|
||||
// Built-in file-write tools whose results auto-record workspace artifacts.
|
||||
const WORKSPACE_AUTO_RECORD_TOOL_NAMES: ReadonlySet<string> = new Set([
|
||||
'write_file',
|
||||
'edit',
|
||||
'notebook_edit',
|
||||
]);
|
||||
const SECRET_TOKEN_VALUE_PATTERN =
|
||||
/(?:^|\s)(?:bearer\s+\S{8,}|sk-[A-Za-z0-9_-]{12,}|(?:gh[pousr]|github_pat)_[A-Za-z0-9_/-]{12,}|[a-f0-9]{40,}|[A-Za-z0-9+/]{48,}={0,2})(?:$|\s)/i;
|
||||
const RESTORE_FAILED_WARNING_PREFIX = 'artifact snapshot restore failed';
|
||||
@@ -118,6 +124,11 @@ export interface SessionArtifactInput extends ToolArtifactLike {
|
||||
toolName?: string;
|
||||
hookEventName?: string;
|
||||
clientId?: string;
|
||||
/**
|
||||
* Refresh an existing workspace artifact at `workspacePath`; never create
|
||||
* one. Dropped when `workspacePath` is absent.
|
||||
*/
|
||||
updateOnly?: boolean;
|
||||
}
|
||||
|
||||
type RestoreSessionArtifactInput = Omit<SessionArtifactInput, 'retention'> & {
|
||||
@@ -368,6 +379,12 @@ export class SessionArtifactStore {
|
||||
const warningDetails: SessionArtifactWarningDetail[] = [];
|
||||
for (const input of inputs) {
|
||||
try {
|
||||
if (await this.isUnmatchedWorkspaceUpdate(input)) {
|
||||
writeStderrLine(
|
||||
`[artifacts] session=${this.sessionId} action=dropped reason="unmatched workspace update" workspacePath=${JSON.stringify(input.workspacePath ?? '')}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const expanded = await this.expandWorkspaceDirectoryInput(input);
|
||||
for (const item of expanded.inputs) {
|
||||
try {
|
||||
@@ -443,9 +460,13 @@ export class SessionArtifactStore {
|
||||
}
|
||||
|
||||
try {
|
||||
this.denyCrossClientMutation('upsert', existing.id, existing, {
|
||||
clientId: artifact.clientId,
|
||||
});
|
||||
// Agent file writes keep a client-owned artifact's content in
|
||||
// sync; the merge never takes over its title, source, or owner.
|
||||
if (!isWorkspaceAutoRecordInput(artifact)) {
|
||||
this.denyCrossClientMutation('upsert', existing.id, existing, {
|
||||
clientId: artifact.clientId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (validationStrict) {
|
||||
throw error;
|
||||
@@ -664,8 +685,43 @@ export class SessionArtifactStore {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* An `updateOnly` input, or an auto-recorded file write whose artifact the
|
||||
* user deleted, may only refresh an artifact that already exists. Resolved
|
||||
* before the input is stat'd or hashed so unknown files cost nothing.
|
||||
*/
|
||||
private async isUnmatchedWorkspaceUpdate(
|
||||
input: SessionArtifactInput,
|
||||
): Promise<boolean> {
|
||||
const updateOnly = input.updateOnly === true;
|
||||
if (!updateOnly && !isWorkspaceAutoRecordInput(input)) {
|
||||
return false;
|
||||
}
|
||||
if (!input.workspacePath) {
|
||||
return updateOnly;
|
||||
}
|
||||
const workspacePath = normalizeWorkspacePath(
|
||||
input.workspacePath,
|
||||
await this.getRealWorkspaceCwdForValidation(),
|
||||
);
|
||||
const id = stableSessionArtifactId(
|
||||
this.sessionId,
|
||||
buildIdentityKey({ workspacePath }),
|
||||
);
|
||||
if (!updateOnly && !this.tombstonedIds.has(id)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
!this.artifacts.has(id) &&
|
||||
!this.findPublishedWorkspaceTarget({
|
||||
storage: 'workspace',
|
||||
workspacePath,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private findPublishedWorkspaceTarget(
|
||||
artifact: NormalizedArtifact,
|
||||
artifact: Pick<NormalizedArtifact, 'storage' | 'workspacePath'>,
|
||||
): StoredArtifact | undefined {
|
||||
if (artifact.storage !== 'workspace' || !artifact.workspacePath) {
|
||||
return undefined;
|
||||
@@ -2288,7 +2344,10 @@ function mergeBatchArtifact(
|
||||
toolName: refreshDisplay ? next.toolName : existing.toolName,
|
||||
source: refreshDisplay ? next.source : existing.source,
|
||||
hookEventName: refreshDisplay ? next.hookEventName : existing.hookEventName,
|
||||
toolCallId: refreshDisplay ? next.toolCallId : existing.toolCallId,
|
||||
toolCallId:
|
||||
refreshDisplay || isLatestWorkspaceWrite(existing, next)
|
||||
? next.toolCallId
|
||||
: existing.toolCallId,
|
||||
status: next.status,
|
||||
sizeBytes: mergeSizeBytes(existing, next),
|
||||
metadata: stripDerivedFromTitleMarker(
|
||||
@@ -2405,6 +2464,9 @@ function mergeArtifact(
|
||||
stripExpandedFromDirectoryMarker(next.metadata),
|
||||
);
|
||||
}
|
||||
if (isLatestWorkspaceWrite(existing, incoming)) {
|
||||
next.toolCallId = incoming.toolCallId;
|
||||
}
|
||||
|
||||
coerceMergedPublishedFile(next, wasDurablyJournaled(existing));
|
||||
|
||||
@@ -2573,10 +2635,12 @@ function mergeMetadata(
|
||||
existing: DaemonSessionArtifact,
|
||||
incoming: NormalizedArtifact,
|
||||
): Record<string, string | number | boolean | null> | undefined {
|
||||
// A file write from another producer only re-baselines the content.
|
||||
const fingerprintsOnly = incoming.source !== existing.source;
|
||||
if (
|
||||
!incoming.metadata ||
|
||||
incoming.source === 'hook' ||
|
||||
incoming.source !== existing.source
|
||||
(fingerprintsOnly && !isWorkspaceAutoRecordInput(incoming))
|
||||
) {
|
||||
return existing.metadata;
|
||||
}
|
||||
@@ -2586,7 +2650,7 @@ function mergeMetadata(
|
||||
if (isAdoptableContentFingerprintKey(key) && merged[key] !== value) {
|
||||
merged[key] = value;
|
||||
changed = true;
|
||||
} else if (!Object.hasOwn(merged, key)) {
|
||||
} else if (!fingerprintsOnly && !Object.hasOwn(merged, key)) {
|
||||
merged[key] = value;
|
||||
changed = true;
|
||||
}
|
||||
@@ -2623,11 +2687,41 @@ function countByRetentionSource(
|
||||
return counts;
|
||||
}
|
||||
|
||||
function isWorkspaceAutoRecordInput(
|
||||
artifact: Pick<SessionArtifactInput, 'source' | 'toolName'>,
|
||||
): boolean {
|
||||
return (
|
||||
(artifact.source ?? 'tool') === 'tool' &&
|
||||
isWorkspaceAutoRecordToolName(artifact.toolName)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest file write owns the turn attribution even when it may not
|
||||
* refresh the display fields of an artifact another producer created.
|
||||
*/
|
||||
function isLatestWorkspaceWrite(
|
||||
existing: Pick<NormalizedArtifact, 'storage'>,
|
||||
incoming: NormalizedArtifact,
|
||||
): boolean {
|
||||
return (
|
||||
existing.storage === 'workspace' &&
|
||||
isWorkspaceAutoRecordInput(incoming) &&
|
||||
Boolean(incoming.toolCallId)
|
||||
);
|
||||
}
|
||||
|
||||
function isWorkspaceAutoRecordToolName(toolName: string | undefined): boolean {
|
||||
return (
|
||||
toolName !== undefined && WORKSPACE_AUTO_RECORD_TOOL_NAMES.has(toolName)
|
||||
);
|
||||
}
|
||||
|
||||
function isVanishedAutoRecordedWorkspaceArtifact(
|
||||
artifact: StoredArtifact,
|
||||
): boolean {
|
||||
return (
|
||||
artifact.toolName === 'write_file' &&
|
||||
isWorkspaceAutoRecordToolName(artifact.toolName) &&
|
||||
artifact.workspacePath !== undefined &&
|
||||
artifact.status === 'missing' &&
|
||||
artifact.missingFromStatError !== true &&
|
||||
@@ -2657,7 +2751,7 @@ function needsWorkspaceStatusRefresh(
|
||||
|
||||
function shouldRecheckWriteFilePresence(artifact: StoredArtifact): boolean {
|
||||
return (
|
||||
artifact.toolName === 'write_file' &&
|
||||
isWorkspaceAutoRecordToolName(artifact.toolName) &&
|
||||
(artifact.status === 'available' ||
|
||||
(artifact.status === 'missing' &&
|
||||
artifact.missingFromStatError !== true &&
|
||||
|
||||
@@ -246,7 +246,7 @@ export type { WriteFileTool, WriteFileToolParams } from './tools/write-file.js';
|
||||
export {
|
||||
buildRecordArtifactReminder,
|
||||
buildWorkspaceArtifactMetadata,
|
||||
} from './tools/write-file.js';
|
||||
} from './tools/artifact/workspace-auto-record.js';
|
||||
export {
|
||||
resolveBoundWorkspaceRoot,
|
||||
toCanonicalWorkspaceArtifactPath,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { ToolArtifact, ToolArtifactKind } from '../tools.js';
|
||||
import {
|
||||
ARTIFACT_TITLE_MAX_LENGTH,
|
||||
ARTIFACT_WORKSPACE_PATH_MAX_LENGTH,
|
||||
hasControlCharacter,
|
||||
hasUnsafeDisplayPayload,
|
||||
} from '../record-artifact.js';
|
||||
import {
|
||||
OFFICE_DOCUMENT_EXTENSIONS,
|
||||
pathHasSkippedDirectoryComponent,
|
||||
} from '../../utils/workspace-artifact-directory.js';
|
||||
import { toCanonicalWorkspaceArtifactPath } from '../../utils/workspace-artifact-path.js';
|
||||
import { getSpecificMimeType } from '../../utils/fileUtils.js';
|
||||
import { createDebugLogger } from '../../utils/debugLogger.js';
|
||||
|
||||
const debugLogger = createDebugLogger('WORKSPACE_AUTO_RECORD');
|
||||
|
||||
const ARTIFACT_KIND_BY_EXTENSION = new Map<string, ToolArtifactKind>([
|
||||
['.csv', 'file'],
|
||||
['.htm', 'html'],
|
||||
['.html', 'html'],
|
||||
['.ipynb', 'notebook'],
|
||||
['.jpeg', 'image'],
|
||||
['.jpg', 'image'],
|
||||
['.json', 'file'],
|
||||
['.md', 'file'],
|
||||
['.pdf', 'pdf'],
|
||||
['.png', 'image'],
|
||||
['.py', 'file'],
|
||||
['.sql', 'file'],
|
||||
['.svg', 'image'],
|
||||
['.webp', 'image'],
|
||||
]);
|
||||
for (const ext of OFFICE_DOCUMENT_EXTENSIONS) {
|
||||
ARTIFACT_KIND_BY_EXTENSION.set(ext, 'document');
|
||||
}
|
||||
|
||||
export type WorkspaceToolArtifact = ToolArtifact & {
|
||||
storage: 'workspace';
|
||||
workspacePath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the session-artifact candidate for a file a built-in tool just wrote.
|
||||
* Artifact-like extensions yield a normal candidate; any other workspace file,
|
||||
* or one written with `recordAsArtifact: false`, yields an `updateOnly`
|
||||
* candidate that only refreshes an artifact the session already has.
|
||||
*/
|
||||
export function buildWorkspaceArtifactCandidate(
|
||||
config: Config,
|
||||
filePath: string,
|
||||
options: { recordAsArtifact?: boolean; sizeBytes?: number } = {},
|
||||
): WorkspaceToolArtifact | null {
|
||||
const recorded = resolveRecordedWorkspaceFile(config, filePath);
|
||||
if (!recorded) {
|
||||
return null;
|
||||
}
|
||||
const title = path.basename(recorded.filePath);
|
||||
// The daemon store rejects titles and paths that are too long, carry control
|
||||
// characters, or contain markup; skip the artifact rather than tell the model
|
||||
// it was recorded when it will be dropped.
|
||||
if (
|
||||
title.length > ARTIFACT_TITLE_MAX_LENGTH ||
|
||||
hasControlCharacter(title) ||
|
||||
hasUnsafeDisplayPayload(title) ||
|
||||
recorded.workspacePath.length > ARTIFACT_WORKSPACE_PATH_MAX_LENGTH ||
|
||||
hasControlCharacter(recorded.workspacePath) ||
|
||||
hasUnsafeDisplayPayload(recorded.workspacePath)
|
||||
) {
|
||||
debugLogger.debug('workspace artifact skipped (safety checks)', {
|
||||
path: filePath,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const kind = ARTIFACT_KIND_BY_EXTENSION.get(
|
||||
path.extname(recorded.filePath).toLowerCase(),
|
||||
);
|
||||
return {
|
||||
title,
|
||||
kind: kind ?? 'file',
|
||||
storage: 'workspace',
|
||||
workspacePath: recorded.workspacePath,
|
||||
mimeType:
|
||||
getSpecificMimeType(recorded.filePath) ??
|
||||
(recorded.filePath.toLowerCase().endsWith('.ipynb')
|
||||
? 'application/x-ipynb+json'
|
||||
: undefined),
|
||||
sizeBytes: options.sizeBytes,
|
||||
...(kind === undefined || options.recordAsArtifact === false
|
||||
? { updateOnly: true }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The candidate for an artifact-like file, or null for any other file. */
|
||||
export function buildWorkspaceArtifactMetadata(
|
||||
config: Config,
|
||||
filePath: string,
|
||||
sizeBytes?: number,
|
||||
): WorkspaceToolArtifact | null {
|
||||
const artifact = buildWorkspaceArtifactCandidate(config, filePath, {
|
||||
sizeBytes,
|
||||
});
|
||||
return artifact && !artifact.updateOnly ? artifact : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kept for the cross-package contract test in `workspace-file-read.test.ts`:
|
||||
* the daemon's `GET /file` route resolves the `workspacePath` this produces.
|
||||
* Delegates to `buildWorkspaceArtifactMetadata` so the two agree by construction.
|
||||
*/
|
||||
export function buildRecordArtifactReminder(
|
||||
config: Config,
|
||||
filePath: string,
|
||||
): string | null {
|
||||
const artifact = buildWorkspaceArtifactMetadata(config, filePath);
|
||||
return artifact ? formatRecordArtifactReminder(artifact.workspacePath) : null;
|
||||
}
|
||||
|
||||
export function formatRecordArtifactReminder(workspacePath: string): string {
|
||||
return (
|
||||
`This file was automatically recorded as a workspace artifact with ` +
|
||||
`workspacePath "${workspacePath}". No extra artifact registration step ` +
|
||||
`is needed.`
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRecordedWorkspaceFile(
|
||||
config: Config,
|
||||
filePath: string,
|
||||
): { filePath: string; workspacePath: string } | null {
|
||||
if (!config.isRecordArtifactEnabled()) {
|
||||
return null;
|
||||
}
|
||||
let resolvedFile = filePath;
|
||||
let resolvedRoot = config.getTargetDir();
|
||||
try {
|
||||
if (!config.getFileSystemService().textFileIo) {
|
||||
resolvedFile = fs.realpathSync(filePath);
|
||||
resolvedRoot = fs.realpathSync(resolvedRoot);
|
||||
}
|
||||
} catch {
|
||||
// Keep the lexical path when the file or root cannot be realpath'd yet.
|
||||
}
|
||||
const workspacePath = toCanonicalWorkspaceArtifactPath(
|
||||
resolvedFile,
|
||||
resolvedRoot,
|
||||
);
|
||||
if (!workspacePath) {
|
||||
return null;
|
||||
}
|
||||
if (pathHasSkippedDirectoryComponent(workspacePath)) {
|
||||
return null;
|
||||
}
|
||||
return { filePath: resolvedFile, workspacePath };
|
||||
}
|
||||
@@ -110,6 +110,7 @@ describe('EditTool', () => {
|
||||
getFileReadCache: () => fileReadCache,
|
||||
getFileReadCacheDisabled: vi.fn().mockReturnValue(false),
|
||||
getFileHistoryService: () => mockFileHistoryService,
|
||||
isRecordArtifactEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
// Default to not skipping confirmation
|
||||
@@ -771,6 +772,117 @@ describe('EditTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('session artifacts', () => {
|
||||
beforeEach(() => {
|
||||
(mockConfig.isRecordArtifactEnabled as Mock).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('records an edited artifact-like file', async () => {
|
||||
const csvPath = path.join(rootDir, 'orders.csv');
|
||||
seedFile(csvPath, 'id,old\n1,a\n');
|
||||
|
||||
const result = await run(edit(csvPath, 'old', 'newer-col'));
|
||||
|
||||
expect(result.llmContent).toContain(
|
||||
'automatically recorded as a workspace artifact with workspacePath "orders.csv"',
|
||||
);
|
||||
expect(result.llmContent).toMatch(
|
||||
/recorded as a workspace artifact[^]*Showing lines/,
|
||||
);
|
||||
expect(result.artifacts).toEqual([
|
||||
{
|
||||
title: 'orders.csv',
|
||||
kind: 'file',
|
||||
storage: 'workspace',
|
||||
workspacePath: 'orders.csv',
|
||||
mimeType: 'text/csv',
|
||||
sizeBytes: fs.statSync(csvPath).size,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('records an artifact-like file created by edit', async () => {
|
||||
const notePath = path.join(rootDir, 'notes', 'summary.md');
|
||||
autoEditOnce();
|
||||
|
||||
const result = await run(edit(notePath, '', '# Summary\n'));
|
||||
|
||||
expect(result.llmContent).toMatch(/Created new file/);
|
||||
expect(result.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
title: 'summary.md',
|
||||
kind: 'file',
|
||||
workspacePath: 'notes/summary.md',
|
||||
}),
|
||||
]);
|
||||
expect(result.artifacts?.[0]).not.toHaveProperty('updateOnly');
|
||||
});
|
||||
|
||||
it('only refreshes an existing artifact when record_as_artifact is false', async () => {
|
||||
const csvPath = path.join(rootDir, 'orders.csv');
|
||||
seedFile(csvPath, 'id,old\n1,a\n');
|
||||
|
||||
const result = await run(
|
||||
edit(csvPath, 'old', 'new', { record_as_artifact: false }),
|
||||
);
|
||||
|
||||
expect(result.llmContent).not.toContain('automatically recorded');
|
||||
expect(result.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
workspacePath: 'orders.csv',
|
||||
updateOnly: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('only refreshes an existing artifact for other files', async () => {
|
||||
const sourcePath = path.join(rootDir, 'src', 'index.ts');
|
||||
fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
|
||||
seedFile(sourcePath, 'export const old = 1;\n');
|
||||
|
||||
const result = await run(edit(sourcePath));
|
||||
|
||||
expect(result.llmContent).not.toContain('automatically recorded');
|
||||
expect(result.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
workspacePath: 'src/index.ts',
|
||||
updateOnly: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not record files outside the workspace', async () => {
|
||||
const outsidePath = path.join(tempDir, 'outside.csv');
|
||||
seedFile(outsidePath, 'old\n');
|
||||
|
||||
const result = await run(edit(outsidePath));
|
||||
|
||||
expect(readText(outsidePath)).toBe('new\n');
|
||||
expect(result.artifacts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not record anything when the edit fails', async () => {
|
||||
const csvPath = path.join(rootDir, 'orders.csv');
|
||||
seedFile(csvPath, 'id,value\n');
|
||||
|
||||
const result = await run(edit(csvPath, 'missing', 'replacement'));
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.artifacts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not record anything when artifact recording is disabled', async () => {
|
||||
(mockConfig.isRecordArtifactEnabled as Mock).mockReturnValue(false);
|
||||
const csvPath = path.join(rootDir, 'orders.csv');
|
||||
seedFile(csvPath, 'id,old\n');
|
||||
|
||||
const result = await run(edit(csvPath));
|
||||
|
||||
expect(result.llmContent).not.toContain('automatically recorded');
|
||||
expect(result.artifacts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// trackEdit is best-effort: a FileHistoryService failure (disk full,
|
||||
// permissions, corrupted state) must never break the edit tool.
|
||||
it('completes the edit even when trackEdit throws', async () => {
|
||||
|
||||
@@ -68,6 +68,10 @@ import {
|
||||
maybeAugmentOldStringForDeletion,
|
||||
normalizeEditStrings,
|
||||
} from '../utils/editHelper.js';
|
||||
import {
|
||||
buildWorkspaceArtifactCandidate,
|
||||
formatRecordArtifactReminder,
|
||||
} from './artifact/workspace-auto-record.js';
|
||||
|
||||
const debugLogger = createDebugLogger('EDIT_PRIOR_READ');
|
||||
|
||||
@@ -138,6 +142,12 @@ export interface EditToolParams {
|
||||
* Initially proposed content.
|
||||
*/
|
||||
ai_proposed_content?: string;
|
||||
|
||||
/**
|
||||
* When false, do not create a session artifact for this edit. An artifact
|
||||
* the session already has for this path is still refreshed.
|
||||
*/
|
||||
record_as_artifact?: boolean;
|
||||
}
|
||||
|
||||
interface CalculatedEdit {
|
||||
@@ -737,12 +747,14 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
||||
// pre-edit placeholder. Best-effort: a stat failure here does
|
||||
// not undo the successful write — the next Read will simply
|
||||
// re-stat and treat the cache entry as stale.
|
||||
let postWriteSizeBytes: number | undefined;
|
||||
try {
|
||||
if (mutation && !committedStats)
|
||||
throw new Error('Scoped file write returned no committed stats.');
|
||||
const postWriteStats = mutation
|
||||
? committedStats!
|
||||
: fs.statSync(this.params.file_path);
|
||||
postWriteSizeBytes = postWriteStats.size;
|
||||
this.config
|
||||
.getFileReadCache()
|
||||
.recordWrite(this.params.file_path, postWriteStats);
|
||||
@@ -809,6 +821,20 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
||||
: `The file: ${this.params.file_path} has been updated.`,
|
||||
];
|
||||
|
||||
const artifact = buildWorkspaceArtifactCandidate(
|
||||
this.config,
|
||||
this.params.file_path,
|
||||
{
|
||||
recordAsArtifact: this.params.record_as_artifact,
|
||||
sizeBytes: postWriteSizeBytes,
|
||||
},
|
||||
);
|
||||
if (artifact && !artifact.updateOnly) {
|
||||
llmSuccessMessageParts.push(
|
||||
formatRecordArtifactReminder(artifact.workspacePath),
|
||||
);
|
||||
}
|
||||
|
||||
const snippetResult = extractEditSnippet(
|
||||
editData.currentContent,
|
||||
editData.newContent,
|
||||
@@ -821,6 +847,7 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
||||
return {
|
||||
llmContent: llmSuccessMessageParts.join(' '),
|
||||
returnDisplay: displayResult,
|
||||
...(artifact ? { artifacts: [artifact] } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
return this.writeFailure(error);
|
||||
@@ -902,6 +929,10 @@ Expectation for required parameters:
|
||||
description:
|
||||
'Replace all occurrences of old_string (default false).',
|
||||
},
|
||||
record_as_artifact: {
|
||||
type: 'boolean',
|
||||
description: 'Refresh existing only.',
|
||||
},
|
||||
},
|
||||
required: ['file_path', 'old_string', 'new_string'],
|
||||
type: 'object',
|
||||
|
||||
@@ -68,6 +68,7 @@ describe('NotebookEditTool', () => {
|
||||
getMemoryFileCount: () => 0,
|
||||
setMemoryFileCount: vi.fn(),
|
||||
getToolRegistry: () => ({}) as never,
|
||||
isRecordArtifactEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
tool = new NotebookEditTool(config);
|
||||
});
|
||||
@@ -186,6 +187,84 @@ describe('NotebookEditTool', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('records the edited notebook as a session artifact', async () => {
|
||||
vi.mocked(config.isRecordArtifactEnabled).mockReturnValue(true);
|
||||
const filePath = writeSeeded(
|
||||
'analysis.ipynb',
|
||||
nb45([cell('code', 'load-data', ['x = 1\n'], NO_OUTPUTS)], PYTHON),
|
||||
);
|
||||
|
||||
const result = await replaceLoadData(filePath);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.llmContent).toContain(
|
||||
'automatically recorded as a workspace artifact with workspacePath "analysis.ipynb"',
|
||||
);
|
||||
expect(result.artifacts).toEqual([
|
||||
{
|
||||
title: 'analysis.ipynb',
|
||||
kind: 'notebook',
|
||||
storage: 'workspace',
|
||||
workspacePath: 'analysis.ipynb',
|
||||
mimeType: 'application/x-ipynb+json',
|
||||
sizeBytes: fs.statSync(filePath).size,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('only refreshes a notebook when record_as_artifact is false', async () => {
|
||||
vi.mocked(config.isRecordArtifactEnabled).mockReturnValue(true);
|
||||
const filePath = writeSeeded(
|
||||
'analysis.ipynb',
|
||||
nb45([cell('code', 'load-data', ['x = 1\n'], NO_OUTPUTS)], PYTHON),
|
||||
);
|
||||
|
||||
const result = await edit({
|
||||
notebook_path: filePath,
|
||||
cell_id: 'load-data',
|
||||
new_source: 'x = 2\nprint(x)',
|
||||
record_as_artifact: false,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.llmContent).not.toContain('automatically recorded');
|
||||
expect(result.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
workspacePath: 'analysis.ipynb',
|
||||
updateOnly: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not record a notebook when artifact recording is disabled', async () => {
|
||||
const filePath = writeSeeded(
|
||||
'analysis.ipynb',
|
||||
nb45([cell('code', 'load-data', ['x = 1\n'], NO_OUTPUTS)], PYTHON),
|
||||
);
|
||||
|
||||
const result = await replaceLoadData(filePath);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.artifacts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not record a notebook when the edit fails', async () => {
|
||||
vi.mocked(config.isRecordArtifactEnabled).mockReturnValue(true);
|
||||
const filePath = writeSeeded(
|
||||
'analysis.ipynb',
|
||||
nb45([cell('code', 'load-data', ['x = 1\n'], NO_OUTPUTS)], PYTHON),
|
||||
);
|
||||
|
||||
const result = await edit({
|
||||
notebook_path: filePath,
|
||||
cell_id: 'missing-cell',
|
||||
new_source: 'x = 2\n',
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.artifacts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('blocks writing a secret into a team-memory notebook', async () => {
|
||||
const filePath = writeSeeded(
|
||||
teamMemoryPath(),
|
||||
|
||||
@@ -58,6 +58,10 @@ import type {
|
||||
import { CommitAttributionService } from '../services/commitAttribution.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { checkTeamMemorySecrets } from '../memory/team-memory-secret-guard.js';
|
||||
import {
|
||||
buildWorkspaceArtifactCandidate,
|
||||
formatRecordArtifactReminder,
|
||||
} from './artifact/workspace-auto-record.js';
|
||||
|
||||
const debugLogger = createDebugLogger('NOTEBOOK_EDIT');
|
||||
|
||||
@@ -69,6 +73,12 @@ export interface NotebookEditToolParams {
|
||||
new_source?: string;
|
||||
cell_type?: EditableNotebookCellType;
|
||||
edit_mode?: NotebookEditMode;
|
||||
|
||||
/**
|
||||
* When false, do not create a session artifact for this edit. An artifact
|
||||
* the session already has for this path is still refreshed.
|
||||
*/
|
||||
record_as_artifact?: boolean;
|
||||
}
|
||||
|
||||
interface NotebookEditModifyMetadata {
|
||||
@@ -647,8 +657,10 @@ class NotebookEditInvocation extends BaseToolInvocation<
|
||||
);
|
||||
}
|
||||
|
||||
let postWriteSizeBytes: number | undefined;
|
||||
try {
|
||||
const postWriteStats = fs.statSync(this.params.notebook_path);
|
||||
postWriteSizeBytes = postWriteStats.size;
|
||||
const cache = this.config.getFileReadCache();
|
||||
if (prepared.requiresReadAfterWrite) {
|
||||
cache.invalidate(postWriteStats);
|
||||
@@ -703,10 +715,22 @@ class NotebookEditInvocation extends BaseToolInvocation<
|
||||
diffStat,
|
||||
};
|
||||
|
||||
const artifact = buildWorkspaceArtifactCandidate(
|
||||
this.config,
|
||||
this.params.notebook_path,
|
||||
{
|
||||
recordAsArtifact: this.params.record_as_artifact,
|
||||
sizeBytes: postWriteSizeBytes,
|
||||
},
|
||||
);
|
||||
const reminder =
|
||||
artifact && !artifact.updateOnly
|
||||
? ` ${formatRecordArtifactReminder(artifact.workspacePath)}`
|
||||
: '';
|
||||
const llmContent =
|
||||
this.modifyMetadata?.modifiedNotebookContent !== undefined
|
||||
? `Notebook ${this.params.notebook_path} has been updated. Notebook content was modified by the user before approval; the final saved notebook may differ from the original ${prepared.mode} cell ${prepared.editedCellId} proposal.`
|
||||
: `Notebook ${this.params.notebook_path} has been updated. ${prepared.mode} cell ${prepared.editedCellId}.${
|
||||
? `Notebook ${this.params.notebook_path} has been updated.${reminder} Notebook content was modified by the user before approval; the final saved notebook may differ from the original ${prepared.mode} cell ${prepared.editedCellId} proposal.`
|
||||
: `Notebook ${this.params.notebook_path} has been updated.${reminder} ${prepared.mode} cell ${prepared.editedCellId}.${
|
||||
prepared.mode === 'delete'
|
||||
? ''
|
||||
: `\n\nUpdated source:\n\n---\n\n${normalizeSource(this.params.new_source ?? '')}`
|
||||
@@ -715,6 +739,7 @@ class NotebookEditInvocation extends BaseToolInvocation<
|
||||
llmContent,
|
||||
returnDisplay: displayResult,
|
||||
resultFilePaths: [this.params.notebook_path],
|
||||
...(artifact ? { artifacts: [artifact] } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -748,7 +773,7 @@ export class NotebookEditTool
|
||||
super(
|
||||
NotebookEditTool.Name,
|
||||
ToolDisplayNames.NOTEBOOK_EDIT,
|
||||
`Edits a Jupyter notebook (.ipynb) safely at the cell level. Use this instead of ${ToolNames.EDIT} or ${ToolNames.WRITE_FILE} for notebook cells. Supports replacing, inserting, and deleting cells. Always read the notebook first with ${ToolNames.READ_FILE}; then use the cell IDs shown in that output.`,
|
||||
`Edits a Jupyter notebook (.ipynb). Use this instead of ${ToolNames.EDIT} or ${ToolNames.WRITE_FILE} for cells. Always read the notebook first with ${ToolNames.READ_FILE}, then use the cell IDs from that output.`,
|
||||
Kind.Edit,
|
||||
{
|
||||
properties: {
|
||||
@@ -778,6 +803,10 @@ export class NotebookEditTool
|
||||
type: 'string',
|
||||
enum: ['replace', 'insert', 'delete'],
|
||||
},
|
||||
record_as_artifact: {
|
||||
description: 'Refresh existing only.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
required: ['notebook_path'],
|
||||
additionalProperties: false,
|
||||
|
||||
@@ -580,6 +580,8 @@ export interface ToolArtifact {
|
||||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
/** Only refresh an artifact the session already has; never create one. */
|
||||
updateOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import type { WriteFileToolParams } from './write-file.js';
|
||||
import { WriteFileTool } from './write-file.js';
|
||||
import {
|
||||
WriteFileTool,
|
||||
buildRecordArtifactReminder,
|
||||
buildWorkspaceArtifactMetadata,
|
||||
} from './write-file.js';
|
||||
} from './artifact/workspace-auto-record.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import type {
|
||||
FileDiff,
|
||||
@@ -496,6 +496,17 @@ describe('WriteFileTool', () => {
|
||||
expect(result.artifacts).toBeUndefined();
|
||||
}
|
||||
|
||||
function expectUpdateOnly(result: ToolResult, workspacePath: string) {
|
||||
expect(result.llmContent).not.toContain('automatically recorded');
|
||||
expect(result.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
storage: 'workspace',
|
||||
workspacePath,
|
||||
updateOnly: true,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
it('should return error if _getCorrectedFileContent returns an error during execute', async () => {
|
||||
const filePath = p('execute_error_file.txt');
|
||||
fs.writeFileSync(filePath, 'original', { mode: 0o000 });
|
||||
@@ -580,6 +591,10 @@ describe('WriteFileTool', () => {
|
||||
['diagram.svg', 'image'],
|
||||
['photo.webp', 'image'],
|
||||
['table.csv', 'file'],
|
||||
['query.sql', 'file'],
|
||||
['analysis.py', 'file'],
|
||||
['notes.md', 'file'],
|
||||
['data.json', 'file'],
|
||||
['table.xlsx', 'document'],
|
||||
['brief.docx', 'document'],
|
||||
['deck.pptx', 'document'],
|
||||
@@ -609,11 +624,12 @@ describe('WriteFileTool', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not record intermediate files when record_as_artifact is false', async () => {
|
||||
it('only refreshes intermediate files when record_as_artifact is false', async () => {
|
||||
const result = await writeArtifact(p('alibaba.html'), html('Alibaba'), {
|
||||
record_as_artifact: false,
|
||||
});
|
||||
expectNotRecorded(result, true);
|
||||
expect(result.llmContent).toMatch(CREATED);
|
||||
expectUpdateOnly(result, 'alibaba.html');
|
||||
});
|
||||
|
||||
it('does not record intermediate files written under .qwen/tmp', async () => {
|
||||
@@ -655,12 +671,12 @@ describe('WriteFileTool', () => {
|
||||
expectNotRecorded(result, true);
|
||||
});
|
||||
|
||||
it('does not record ordinary source files as artifacts', async () => {
|
||||
it('only refreshes ordinary source files that are already artifacts', async () => {
|
||||
const result = await writeArtifact(
|
||||
p('src', 'index.ts'),
|
||||
'export const value = 1;\n',
|
||||
);
|
||||
expectNotRecorded(result, false);
|
||||
expectUpdateOnly(result, 'src/index.ts');
|
||||
});
|
||||
|
||||
it('does not record files outside the workspace as artifacts', async () => {
|
||||
|
||||
@@ -19,8 +19,6 @@ import {
|
||||
} from '../memory/team-memory-secret-guard.js';
|
||||
import type {
|
||||
FileDiff,
|
||||
ToolArtifact,
|
||||
ToolArtifactKind,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolEditConfirmationDetails,
|
||||
ToolInvocation,
|
||||
@@ -65,38 +63,11 @@ import { getLanguageFromFilePath } from '../utils/language-detection.js';
|
||||
import { CommitAttributionService } from '../services/commitAttribution.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
ARTIFACT_TITLE_MAX_LENGTH,
|
||||
ARTIFACT_WORKSPACE_PATH_MAX_LENGTH,
|
||||
hasControlCharacter,
|
||||
hasUnsafeDisplayPayload,
|
||||
} from './record-artifact.js';
|
||||
import {
|
||||
OFFICE_DOCUMENT_EXTENSIONS,
|
||||
pathHasSkippedDirectoryComponent,
|
||||
} from '../utils/workspace-artifact-directory.js';
|
||||
import { toCanonicalWorkspaceArtifactPath } from '../utils/workspace-artifact-path.js';
|
||||
buildWorkspaceArtifactCandidate,
|
||||
formatRecordArtifactReminder,
|
||||
} from './artifact/workspace-auto-record.js';
|
||||
|
||||
const debugLogger = createDebugLogger('WRITE_FILE');
|
||||
const ARTIFACT_KIND_BY_EXTENSION = new Map<string, ToolArtifactKind>([
|
||||
['.csv', 'file'],
|
||||
['.htm', 'html'],
|
||||
['.html', 'html'],
|
||||
['.ipynb', 'notebook'],
|
||||
['.jpeg', 'image'],
|
||||
['.jpg', 'image'],
|
||||
['.pdf', 'pdf'],
|
||||
['.png', 'image'],
|
||||
['.svg', 'image'],
|
||||
['.webp', 'image'],
|
||||
]);
|
||||
for (const ext of OFFICE_DOCUMENT_EXTENSIONS) {
|
||||
ARTIFACT_KIND_BY_EXTENSION.set(ext, 'document');
|
||||
}
|
||||
|
||||
type WorkspaceToolArtifact = ToolArtifact & {
|
||||
storage: 'workspace';
|
||||
workspacePath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for the WriteFile tool
|
||||
@@ -124,9 +95,11 @@ export interface WriteFileToolParams {
|
||||
|
||||
/**
|
||||
* When false, skip the automatic session-artifact registration that
|
||||
* write_file otherwise does for artifact-like files (html, pdf, images).
|
||||
* write_file otherwise does for artifact-like files (html, pdf, images,
|
||||
* csv, markdown, json, python, and sql).
|
||||
* Use this for intermediate files that will be deleted, such as HTML
|
||||
* written only to print a PDF.
|
||||
* written only to print a PDF. An artifact the session already has for
|
||||
* this path is still refreshed.
|
||||
*/
|
||||
record_as_artifact?: boolean;
|
||||
}
|
||||
@@ -674,15 +647,11 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
`User modified the \`content\` to be: ${content}`,
|
||||
);
|
||||
}
|
||||
const artifact =
|
||||
this.params.record_as_artifact === false
|
||||
? null
|
||||
: buildWorkspaceArtifactMetadata(
|
||||
this.config,
|
||||
file_path,
|
||||
postWriteSizeBytes,
|
||||
);
|
||||
if (artifact) {
|
||||
const artifact = buildWorkspaceArtifactCandidate(this.config, file_path, {
|
||||
recordAsArtifact: this.params.record_as_artifact,
|
||||
sizeBytes: postWriteSizeBytes,
|
||||
});
|
||||
if (artifact && !artifact.updateOnly) {
|
||||
llmSuccessMessageParts.push(
|
||||
formatRecordArtifactReminder(artifact.workspacePath),
|
||||
);
|
||||
@@ -770,111 +739,8 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kept for the cross-package contract test in `workspace-file-read.test.ts`:
|
||||
* the daemon's `GET /file` route resolves the `workspacePath` this produces.
|
||||
* Delegates to `buildWorkspaceArtifactMetadata` so the two agree by construction.
|
||||
*/
|
||||
export function buildRecordArtifactReminder(
|
||||
config: Config,
|
||||
filePath: string,
|
||||
): string | null {
|
||||
const artifact = buildWorkspaceArtifactMetadata(config, filePath);
|
||||
return artifact ? formatRecordArtifactReminder(artifact.workspacePath) : null;
|
||||
}
|
||||
|
||||
function formatRecordArtifactReminder(workspacePath: string): string {
|
||||
return (
|
||||
`This file was automatically recorded as a workspace artifact with ` +
|
||||
`workspacePath "${workspacePath}". No extra artifact registration step ` +
|
||||
`is needed.`
|
||||
);
|
||||
}
|
||||
|
||||
export function buildWorkspaceArtifactMetadata(
|
||||
config: Config,
|
||||
filePath: string,
|
||||
sizeBytes?: number,
|
||||
): WorkspaceToolArtifact | null {
|
||||
const recorded = resolveRecordedWorkspaceFile(config, filePath);
|
||||
if (!recorded) {
|
||||
return null;
|
||||
}
|
||||
const title = path.basename(recorded.filePath);
|
||||
// The daemon store rejects titles and paths that are too long, carry control
|
||||
// characters, or contain markup; skip the artifact rather than tell the model
|
||||
// it was recorded when it will be dropped.
|
||||
if (
|
||||
title.length > ARTIFACT_TITLE_MAX_LENGTH ||
|
||||
hasControlCharacter(title) ||
|
||||
hasUnsafeDisplayPayload(title) ||
|
||||
recorded.workspacePath.length > ARTIFACT_WORKSPACE_PATH_MAX_LENGTH ||
|
||||
hasControlCharacter(recorded.workspacePath) ||
|
||||
hasUnsafeDisplayPayload(recorded.workspacePath)
|
||||
) {
|
||||
debugLogger.debug('workspace artifact skipped (safety checks)', {
|
||||
path: filePath,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title,
|
||||
kind: inferWorkspaceArtifactKind(recorded.filePath),
|
||||
storage: 'workspace',
|
||||
workspacePath: recorded.workspacePath,
|
||||
mimeType:
|
||||
getSpecificMimeType(recorded.filePath) ??
|
||||
(recorded.filePath.toLowerCase().endsWith('.ipynb')
|
||||
? 'application/x-ipynb+json'
|
||||
: undefined),
|
||||
sizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRecordedWorkspaceFile(
|
||||
config: Config,
|
||||
filePath: string,
|
||||
): { filePath: string; workspacePath: string } | null {
|
||||
if (!config.isRecordArtifactEnabled()) {
|
||||
return null;
|
||||
}
|
||||
let resolvedFile = filePath;
|
||||
let resolvedRoot = config.getTargetDir();
|
||||
try {
|
||||
if (!config.getFileSystemService().textFileIo) {
|
||||
resolvedFile = fs.realpathSync(filePath);
|
||||
resolvedRoot = fs.realpathSync(resolvedRoot);
|
||||
}
|
||||
} catch {
|
||||
// Keep the lexical path when the file or root cannot be realpath'd yet.
|
||||
}
|
||||
if (
|
||||
!ARTIFACT_KIND_BY_EXTENSION.has(path.extname(resolvedFile).toLowerCase())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const workspacePath = toCanonicalWorkspaceArtifactPath(
|
||||
resolvedFile,
|
||||
resolvedRoot,
|
||||
);
|
||||
if (!workspacePath) {
|
||||
return null;
|
||||
}
|
||||
if (pathHasSkippedDirectoryComponent(workspacePath)) {
|
||||
return null;
|
||||
}
|
||||
return { filePath: resolvedFile, workspacePath };
|
||||
}
|
||||
|
||||
function inferWorkspaceArtifactKind(filePath: string): ToolArtifactKind {
|
||||
return (
|
||||
ARTIFACT_KIND_BY_EXTENSION.get(path.extname(filePath).toLowerCase()) ??
|
||||
'file'
|
||||
);
|
||||
}
|
||||
|
||||
export const WRITE_FILE_ARTIFACT_DESCRIPTION =
|
||||
'Artifact-like files such as HTML, PDF, images, notebooks, and office documents are automatically registered as session artifacts. Intermediate files that exist only to produce another artifact — for example HTML written solely to print a PDF — must set record_as_artifact=false, or be written under .qwen/tmp/ so they are not registered. Delete those intermediates when done.';
|
||||
'Artifact-like files such as HTML, PDF, images, notebooks, office documents, CSV, Markdown, JSON, SQL, and Python files are automatically registered as session artifacts. Set record_as_artifact=false for intermediates, such as HTML written only to print a PDF: that does not create an artifact, but one the session already has for the path is still refreshed. Files under .qwen/tmp/ are not registered. Delete intermediates when done.';
|
||||
|
||||
/**
|
||||
* Implementation of the WriteFile tool logic
|
||||
@@ -908,7 +774,7 @@ The user has the ability to modify \`content\`. If modified, this will be stated
|
||||
},
|
||||
record_as_artifact: {
|
||||
description:
|
||||
'Set false for intermediate files that should not appear as session artifacts, such as HTML used only to print a PDF. Defaults to true for artifact-like extensions.',
|
||||
'Set false to skip creating a session artifact. An artifact the session already has for this path is still refreshed. Defaults to true for artifact-like extensions.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -688,6 +688,48 @@ describe('TurnOutputs artifact downloads', () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it('marks only changed workspace artifacts as modified since recorded', () => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<I18nProvider language="en">
|
||||
<TurnOutputs
|
||||
turnId="turn-changed"
|
||||
workspaceCwd="/primary"
|
||||
changes={[]}
|
||||
artifacts={(['available', 'changed'] as const).map(
|
||||
(status) =>
|
||||
({
|
||||
id: `${status}-artifact`,
|
||||
kind: 'file',
|
||||
storage: 'workspace',
|
||||
status,
|
||||
title: `${status}.csv`,
|
||||
workspacePath: `reports/${status}.csv`,
|
||||
}) as DaemonSessionArtifact,
|
||||
)}
|
||||
scheduledTasks={[]}
|
||||
onReviewChanges={() => {}}
|
||||
onOpenArtifact={() => {}}
|
||||
onOpenScheduledTask={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
Array.from(
|
||||
container.querySelectorAll(`.${styles.artifactMeta}`),
|
||||
(meta) => meta.textContent?.includes('Modified since recorded'),
|
||||
),
|
||||
).toEqual([false, true]);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it('opens a recorded link through the external opener instead of the panel', () => {
|
||||
const invoke = vi.fn().mockResolvedValue(undefined);
|
||||
(window as { __TAURI__?: unknown }).__TAURI__ = { core: { invoke } };
|
||||
|
||||
@@ -502,6 +502,7 @@ function ArtifactCard({
|
||||
? t('webPreview.saved')
|
||||
: getArtifactTypeLabel(artifact),
|
||||
size,
|
||||
artifact.status === 'changed' && t('turnOutputs.artifactChanged'),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
|
||||
@@ -314,6 +314,70 @@ describe('turnOutputSelectors', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('also shows a recorded workspace artifact in the turn that last wrote it', () => {
|
||||
const messages = [
|
||||
userMessage('u1', 'record notes'),
|
||||
toolGroup('tg1', [
|
||||
{
|
||||
callId: 'record-1',
|
||||
toolName: 'record_artifact',
|
||||
status: 'completed',
|
||||
args: { workspacePath: 'notes.md' },
|
||||
},
|
||||
]),
|
||||
userMessage('u2', 'tidy notes'),
|
||||
toolGroup('tg2', [
|
||||
{
|
||||
callId: 'edit-2',
|
||||
toolName: 'edit',
|
||||
status: 'completed',
|
||||
args: { file_path: 'notes.md' },
|
||||
},
|
||||
]),
|
||||
];
|
||||
const artifact = {
|
||||
id: 'notes',
|
||||
storage: 'workspace',
|
||||
workspacePath: 'notes.md',
|
||||
toolCallId: 'edit-2',
|
||||
} as DaemonSessionArtifact;
|
||||
|
||||
const byTurn = getArtifactsByTurn(messages, [artifact]);
|
||||
expect(byTurn.get('u1')).toEqual([artifact]);
|
||||
expect(byTurn.get('u2')).toEqual([artifact]);
|
||||
expect(getFileChangesByTurn(messages, byTurn).get('u2')).toEqual([
|
||||
expect.objectContaining({ path: 'notes.md', isArtifact: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a recorded published artifact out of the turn that produced it', () => {
|
||||
const messages = [
|
||||
userMessage('u1', 'publish'),
|
||||
toolGroup('tg1', [
|
||||
{ callId: 'publish-1', toolName: 'Artifact', status: 'completed' },
|
||||
]),
|
||||
userMessage('u2', 'record link'),
|
||||
toolGroup('tg2', [
|
||||
{
|
||||
callId: 'record-2',
|
||||
toolName: 'record_artifact',
|
||||
status: 'completed',
|
||||
args: { url: 'https://example.com/page' },
|
||||
},
|
||||
]),
|
||||
];
|
||||
const artifact = {
|
||||
id: 'page',
|
||||
storage: 'published',
|
||||
url: 'https://example.com/page',
|
||||
toolCallId: 'publish-1',
|
||||
} as DaemonSessionArtifact;
|
||||
|
||||
const byTurn = getArtifactsByTurn(messages, [artifact]);
|
||||
expect(byTurn.has('u1')).toBe(false);
|
||||
expect(byTurn.get('u2')).toEqual([artifact]);
|
||||
});
|
||||
|
||||
it('merges multiple edits for the same file in one turn', () => {
|
||||
const messages = [
|
||||
userMessage('u1', 'edit file'),
|
||||
|
||||
@@ -67,7 +67,12 @@ export function getArtifactsByTurn(
|
||||
artifact,
|
||||
workspaceCwd,
|
||||
);
|
||||
if (turnIds.size === 0 && artifact.toolCallId) {
|
||||
// A workspace file's toolCallId follows its latest agent write, so it
|
||||
// joins that turn as well as the turns that recorded it.
|
||||
if (
|
||||
artifact.toolCallId &&
|
||||
(turnIds.size === 0 || artifact.storage === 'workspace')
|
||||
) {
|
||||
const turnId = toolCallTurn.get(artifact.toolCallId);
|
||||
if (turnId) turnIds.add(turnId);
|
||||
}
|
||||
|
||||
@@ -1981,6 +1981,7 @@ const EN: Messages = {
|
||||
'turnOutputs.openFileTree': 'Open file tree',
|
||||
'turnOutputs.closeFileTree': 'Close file tree',
|
||||
'turnOutputs.artifactMissing': 'File not found in the workspace',
|
||||
'turnOutputs.artifactChanged': 'Modified since recorded',
|
||||
'turnOutputs.artifactUnavailable': (v) =>
|
||||
v?.path
|
||||
? `File not found in the workspace · ${v.path}`
|
||||
@@ -6330,6 +6331,7 @@ const ZH: Messages = {
|
||||
'turnOutputs.openFileTree': '打开文件树',
|
||||
'turnOutputs.closeFileTree': '关闭文件树',
|
||||
'turnOutputs.artifactMissing': '工作区中未找到该文件',
|
||||
'turnOutputs.artifactChanged': '登记后已被修改',
|
||||
'turnOutputs.artifactUnavailable': (v) =>
|
||||
v?.path ? `工作区中未找到该文件 · ${v.path}` : '工作区中未找到该文件',
|
||||
'sidebar.label': '工作区侧边栏',
|
||||
|
||||
Reference in new issue
Block a user