Repository navigation
fix(core): auto-record artifact file writes #7914
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
b9ae027
866fe07
8a65624
d31994d
ac37767
16af7b8
2674aa4
f6afd48
a96c93b
9379f5b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,8 @@ import { isAnyAutoMemPath, isTeamAutoMemPath } from '../memory/paths.js'; | |
| import { checkTeamMemorySecrets } from '../memory/team-memory-secret-guard.js'; | ||
| import type { | ||
| FileDiff, | ||
| ToolArtifact, | ||
| ToolArtifactKind, | ||
| ToolCallConfirmationDetails, | ||
| ToolEditConfirmationDetails, | ||
| ToolInvocation, | ||
|
|
@@ -53,18 +55,23 @@ import { CommitAttributionService } from '../services/commitAttribution.js'; | |
| import { createDebugLogger } from '../utils/debugLogger.js'; | ||
|
|
||
| const debugLogger = createDebugLogger('WRITE_FILE'); | ||
| const ARTIFACT_LIKE_EXTENSIONS = new Set([ | ||
| '.htm', | ||
| '.html', | ||
| '.ipynb', | ||
| '.jpeg', | ||
| '.jpg', | ||
| '.pdf', | ||
| '.png', | ||
| '.svg', | ||
| '.webp', | ||
| const ARTIFACT_KIND_BY_EXTENSION = new Map<string, ToolArtifactKind>([ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Auto-recording changes the rate at which the store's 200-artifact cap is reached, and eviction gives no protection to the artifacts the user actually cares about.
Reaching 100 tool artifacts used to require 100 deliberate registration calls. It now requires 100 Measured on the real store: seed one published artifact and one Two things keep this from being worse, and are why I've filed it as a Suggestion: Worth either narrowing this map to the motivating kinds ( — claude-opus-5[1m] via Claude Code /review
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declined. Eviction policy lives in 中文说明拒绝。淘汰策略位于 |
||
| ['.htm', 'html'], | ||
| ['.html', 'html'], | ||
| ['.ipynb', 'notebook'], | ||
| ['.jpeg', 'image'], | ||
| ['.jpg', 'image'], | ||
| ['.pdf', 'pdf'], | ||
| ['.png', 'image'], | ||
| ['.svg', 'image'], | ||
| ['.webp', 'image'], | ||
| ]); | ||
|
|
||
| type WorkspaceToolArtifact = ToolArtifact & { | ||
| storage: 'workspace'; | ||
| workspacePath: string; | ||
| }; | ||
|
|
||
| /** | ||
| * Parameters for the WriteFile tool | ||
| */ | ||
|
|
@@ -520,8 +527,10 @@ class WriteFileToolInvocation extends BaseToolInvocation< | |
| // pre-write placeholder. Best-effort: a stat failure here does | ||
| // not undo the successful write — the next Read will re-stat | ||
| // and either see fresh content or treat the entry as stale. | ||
| let postWriteSizeBytes: number | undefined; | ||
| try { | ||
| const postWriteStats = fs.statSync(file_path); | ||
| postWriteSizeBytes = postWriteStats.size; | ||
| this.config.getFileReadCache().recordWrite(file_path, postWriteStats); | ||
| } catch { | ||
| // Non-fatal: leaving a stale entry is preferable to failing | ||
|
|
@@ -561,12 +570,15 @@ class WriteFileToolInvocation extends BaseToolInvocation< | |
| `User modified the \`content\` to be: ${content}`, | ||
| ); | ||
| } | ||
| const artifactReminder = buildRecordArtifactReminder( | ||
| const artifact = buildWorkspaceArtifactMetadata( | ||
| this.config, | ||
| file_path, | ||
| postWriteSizeBytes, | ||
| ); | ||
| if (artifactReminder) { | ||
| llmSuccessMessageParts.push(artifactReminder); | ||
| if (artifact) { | ||
| llmSuccessMessageParts.push( | ||
| formatRecordArtifactReminder(artifact.workspacePath), | ||
| ); | ||
| } | ||
|
|
||
| // Log file operation for telemetry (without diff_stat to avoid double-counting) | ||
|
|
@@ -601,6 +613,7 @@ class WriteFileToolInvocation extends BaseToolInvocation< | |
| return { | ||
| llmContent: llmSuccessMessageParts.join(' '), | ||
| returnDisplay: displayResult, | ||
| ...(artifact ? { artifacts: [artifact] } : {}), | ||
| }; | ||
| } catch (error) { | ||
| // Capture detailed error information for debugging | ||
|
|
@@ -644,24 +657,62 @@ class WriteFileToolInvocation extends BaseToolInvocation< | |
| } | ||
|
|
||
| /** | ||
| * Builds the `record_artifact` hint appended to a successful write. | ||
| * Builds the artifact-recording note appended to a successful write. | ||
| * | ||
| * Exported because the string it produces is a CONTRACT with the daemon's | ||
| * Exported because the workspacePath it produces is a CONTRACT with the daemon's | ||
| * `GET /file` route: the `workspacePath` computed here is later resolved by | ||
| * `resolveWithinWorkspace` against the bound workspace root. The two sides live | ||
| * in different packages and drifted apart once already (a worktree session | ||
| * emitted a worktree-relative path that the route resolved against the | ||
| * emitted a session-cwd-relative path that the route resolved against the | ||
| * workspace root, so every artifact preview 404'd). `workspace-file-read.test.ts` | ||
| * pins the round-trip; keep this exported so it can keep doing so. | ||
| */ | ||
| export function buildRecordArtifactReminder( | ||
| config: Config, | ||
| filePath: string, | ||
| ): string | null { | ||
| const workspacePath = getRecordArtifactWorkspacePath(config, filePath); | ||
| if (!workspacePath) { | ||
| return null; | ||
| } | ||
| return formatRecordArtifactReminder(workspacePath); | ||
| } | ||
|
qwen-code-dev-bot marked this conversation as resolved.
Resolved
|
||
|
|
||
| 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.` | ||
| ); | ||
| } | ||
|
Comment on lines
+678
to
+684
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This is an unconditional statement of completed fact, but only the daemon/ACP surface actually consumes
The previous wording was a conditional imperative — "If this file is a reusable user-facing artifact, call Worth either gating the sentence on a session that has an artifact consumer, or phrasing it as a capability rather than a finished action, e.g. "recorded for clients that display an artifacts panel". — claude-opus-5[1m] via Claude Code /review
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declined. Gating the sentence on a session that has an artifact consumer would require plumbing surface awareness into the write path; softening the wording would churn ~6 test assertions for a non-blocking preference. The maintainer's round-4 verification confirmed comfort with merging as-is. 中文说明拒绝。按会话类型门控需要在写入路径中引入场景感知;软化措辞会导致约 6 个测试断言的变动,且属于非阻断偏好。维护者第 4 轮验证已确认可按现状合并。 |
||
|
|
||
| function buildWorkspaceArtifactMetadata( | ||
| config: Config, | ||
| filePath: string, | ||
| sizeBytes?: number, | ||
| ): WorkspaceToolArtifact | null { | ||
| const workspacePath = getRecordArtifactWorkspacePath(config, filePath); | ||
| if (!workspacePath) { | ||
| return null; | ||
| } | ||
| return { | ||
| title: path.basename(filePath), | ||
| kind: inferWorkspaceArtifactKind(filePath), | ||
| storage: 'workspace', | ||
| workspacePath, | ||
| mimeType: getSpecificMimeType(filePath), | ||
| sizeBytes, | ||
| }; | ||
| } | ||
|
|
||
| function getRecordArtifactWorkspacePath( | ||
| config: Config, | ||
| filePath: string, | ||
| ): string | null { | ||
| if (!config.isRecordArtifactEnabled()) { | ||
| return null; | ||
| } | ||
| if (!ARTIFACT_LIKE_EXTENSIONS.has(path.extname(filePath).toLowerCase())) { | ||
| if (!ARTIFACT_KIND_BY_EXTENSION.has(path.extname(filePath).toLowerCase())) { | ||
|
Comment on lines
+726
to
+733
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This helper anchors the emitted path at
Reproduced with this PR's own That 200-with-the-wrong-file is verbatim what the contract test's comment calls "worse than a 404". To be clear about attribution: this arithmetic is not introduced here — the pre-existing helper computed the same path. What changes is the consequence. Before, it was a hint the model could ignore; now emission is unconditional and Worth anchoring at the session's bound workspace root rather than — claude-opus-5[1m] via Claude Code /review
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declined. The reviewer confirmed this path arithmetic is pre-existing, not introduced by this PR. Anchoring at the bound workspace root and auditing the CLI 中文说明拒绝。评审者确认此路径计算是既有逻辑,非本 PR 引入。锚定到绑定的工作区根目录并审计 CLI |
||
| return null; | ||
| } | ||
| // The daemon's file-read route resolves workspacePath against the | ||
|
|
@@ -683,11 +734,13 @@ export function buildRecordArtifactReminder( | |
| ) { | ||
| return null; | ||
| } | ||
| const workspacePath = relativePath.split(path.sep).join('/'); | ||
| return relativePath.split(path.sep).join('/'); | ||
| } | ||
|
|
||
| function inferWorkspaceArtifactKind(filePath: string): ToolArtifactKind { | ||
| return ( | ||
| `If this file is a reusable user-facing artifact, call ` + | ||
| `record_artifact with workspacePath "${workspacePath}" before telling ` + | ||
| `the user it is available in the artifacts panel.` | ||
| ARTIFACT_KIND_BY_EXTENSION.get(path.extname(filePath).toLowerCase()) ?? | ||
| 'file' | ||
| ); | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.