mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-21 14:46:19 +00:00
fix(artifacts): expand recorded directories into per-file artifacts (#9395)
* fix(artifacts): expand recorded directories into per-file artifacts A generated folder was stored as one file artifact, so Open and Download hit file APIs on a directory and returned 400. Record each file instead, and treat office documents as downloadable artifacts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(artifacts): address review on directory expansion Report depth truncation, keep later files on their own turn, and stop one bad filename or a full-content hash from breaking directory ingest. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(artifacts): tighten directory expansion after review Use the locator's realpath workspace root, map unlistable directories to validation errors, skip junk roots, and keep walk caps/warnings aligned with what was actually recorded. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(artifacts): drop unused recorded counter Directory expansion warnings are always surfaced, so the counter tripped no-unused-vars in CI. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(artifacts): close remaining directory-expansion review gaps Reject non-object metadata and overlong titles before fan-out, wrap symlink-target lstat as validation, skip junk-only over-deep peeks, and stop offering Download for missing files. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(artifacts): follow up on the latest directory-expansion suggestions Trim derived child titles, hash expanded files like direct records, only flag over-deep trees that actually contain recordable files, and show missing status on download-only cards. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(artifacts): reject worktree-root expansion and close remaining review gaps Directory expansion treated a worktree cwd as a normal subdirectory, silently dropped unrecordable names, and could overflow the metadata budget when stamping expandedFromDirectory. Align the walker, tool, and store so those cases fail or disclose cleanly, and keep the pathless download test covering the collapsed 3-card cap. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(artifacts): close junk-path, coalesce, and depth-disclosure review gaps Reject directory expansion when any path component is skip-listed, including symlink aliases into those trees. Treat unreadable over-depth directories as depth-limited, reject trim-unstable child names, and strip expandedFromDirectory when a curated same-batch record wins coalescing. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(artifacts): use bracket access for expandedFromDirectory metadata acp-bridge tsc fails under noPropertyAccessFromIndexSignature when the test reads the marker via dot access. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(artifacts): keep worktree directory expansion and close round-7 gaps Strip the bound-root worktree prefix before the skip-directory gate so ordinary subdirectories still expand in worktree sessions. Realpath every directory walk root to catch intermediate symlink aliases into skipped trees, validate parent title/description before fan-out, and make the new fixtures Windows-safe. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): refresh desktop bun.lock for Live Host frozen install Qwen Live Host CI runs `bun install --frozen-lockfile` with Bun 1.3.9; the checked-in lockfile drifted and fails on main and any PR that touches Live Host path filters. Regenerate with the CI Bun version so install stays reproducible. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7e56bd0657
commit
07405e669c
28 changed files with 4232 additions and 1938 deletions
97
docs/design/session-artifacts-directory-expansion.md
Normal file
97
docs/design/session-artifacts-directory-expansion.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Session artifacts: directory expansion and office files
|
||||
|
||||
## Problem
|
||||
|
||||
Models often register a generated folder as one workspace artifact
|
||||
(`kind: file`, `workspacePath` pointing at the directory). Clients then
|
||||
open or download it through `GET /file` or `GET /file/bytes`, which
|
||||
require a regular file and return HTTP 400.
|
||||
|
||||
Office outputs (Word / Excel / PowerPoint) were also second-class:
|
||||
they were not treated as artifact-like files, and the preview path tried
|
||||
to read them as text.
|
||||
|
||||
## Decision
|
||||
|
||||
Directories are never artifacts. If a registration points at a
|
||||
directory, the store expands it to one artifact per recordable file
|
||||
inside that directory. The original directory path is not stored.
|
||||
|
||||
Office documents are first-class file artifacts (`kind: document`) and
|
||||
are opened as downloadable binaries, not text previews.
|
||||
|
||||
The chat turn-output list shows at most 3 artifact cards, with the same
|
||||
expand/collapse control already used for edited files.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Expansion happens in the session artifact store so every ingest path
|
||||
(`record_artifact`, hooks, client POST) behaves the same.
|
||||
- Walk is recursive, skips hidden names, Excel lock files (`~$*`),
|
||||
symlinks, and well-known junk directories. Cap is 100 files and 4
|
||||
directory levels; either limit is disclosed to the model and store
|
||||
warnings instead of silently dropping files.
|
||||
- Chat grouping uses a recorded directory path as a prefix only for
|
||||
artifacts from that same `record_artifact` call (or artifacts with no
|
||||
tool call id). Later files written under the same folder stay on their
|
||||
own turn.
|
||||
- Each child uses its filename as the title, inherits tool/source
|
||||
metadata, and infers kind from its own extension.
|
||||
- A leftover directory path (empty, unlistable, or not expanded) is
|
||||
rejected; it is never marked `available`.
|
||||
- Word / Excel / PowerPoint / OpenDocument extensions map to
|
||||
`kind: document`. The client shows a type label and a download action
|
||||
instead of a CodeMirror text preview.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Directory artifacts or a folder browser kind.
|
||||
- Auto-zip at record time.
|
||||
- Auto-registering every `write_file` / shell output without
|
||||
`record_artifact`.
|
||||
|
||||
## Adjustments after #9385 triage
|
||||
|
||||
Upstream analysis (QwenLM/qwen-code@main) confirmed the original
|
||||
decision. Keep store-side expansion, `kind: document`, and the 3-card
|
||||
collapse. Fine-tune the landing, do not change the product rule.
|
||||
|
||||
### Coordinate with #9142
|
||||
|
||||
#9142 (`fix/artifact-workspace-path-contract`) stats the locator in
|
||||
`record_artifact` and **rejects** directories (`TARGET_IS_DIRECTORY`).
|
||||
That stops the 400, but it is not this issue: the user still does not
|
||||
get one card per generated file.
|
||||
|
||||
Keep #9142's path canonicalization (workspace-root-relative
|
||||
`workspacePath`, worktree prefix, no invented `w/agent/` prefixes).
|
||||
Replace its directory rejection with expansion:
|
||||
|
||||
- If the locator is a regular file, verify + canonicalize as in #9142.
|
||||
- If it is a directory, succeed and let the store expand. The tool
|
||||
result should say the directory was expanded, not `Recorded artifact`
|
||||
for the folder itself.
|
||||
- If it is empty, missing, escaped, or not a regular file, fail as in
|
||||
#9142.
|
||||
|
||||
`.xlsx` in #9142's `write_file` whitelist must be `document`, not
|
||||
`file`. `.csv` stays `file` (text-previewable).
|
||||
|
||||
### Client
|
||||
|
||||
Upstream Web Shell already has Download (`canDownloadArtifact`,
|
||||
`readWorkspaceFileAsBlob`) and `GET /stat`. Reuse those. Do not add a
|
||||
second download stack.
|
||||
|
||||
- `kind: document` and other non-text kinds (`pdf`, `image`, `video`,
|
||||
`audio`) never fall through to the `'source'` / CodeMirror preview.
|
||||
- If `GET /stat` returns `type: directory` (legacy dirty records),
|
||||
disable Open and do not call `/file` or `/file/bytes`.
|
||||
- Add `document` to `ARTIFACT_FORMAT_ICONS` and kind labels.
|
||||
|
||||
### write_file reminder
|
||||
|
||||
On current main the whitelist is `ARTIFACT_KIND_BY_EXTENSION`, not a
|
||||
plain extension set. Office/OpenDocument entries belong there as
|
||||
`document`. Do not call `record_artifact` again for a path
|
||||
`write_file` already recorded.
|
||||
|
|
@ -2352,6 +2352,13 @@ export class BridgeClient implements Client {
|
|||
): Promise<void> {
|
||||
try {
|
||||
const result = await entry.artifacts.upsertMany(artifacts, options);
|
||||
for (const warning of result.warnings ?? []) {
|
||||
writeStderrLine(
|
||||
`[artifacts] session=${entry.sessionId} action=warning reason=${JSON.stringify(
|
||||
warning,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
this.publishArtifactChanges(entry, result.changes, turn);
|
||||
} catch (error) {
|
||||
writeStderrLine(
|
||||
|
|
|
|||
|
|
@ -1420,6 +1420,9 @@ describe('SessionArtifactStore', () => {
|
|||
const logged = stderr.mock.calls.map((call) => String(call[0])).join('');
|
||||
expect(logged).toContain('action=dropped');
|
||||
expect(logged).toContain('max artifacts exceeded');
|
||||
expect(overflow.warnings?.join(' ') ?? '').toMatch(
|
||||
/dropped 1 newly created artifacts because the store is full/,
|
||||
);
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
}
|
||||
|
|
@ -1919,6 +1922,7 @@ describe('SessionArtifactStore', () => {
|
|||
{ title: 'Page', workspacePath: 'reports/index.html' },
|
||||
{ title: 'Image', workspacePath: 'screenshots/app.png' },
|
||||
{ title: 'Notebook', workspacePath: 'analysis/run.ipynb' },
|
||||
{ title: 'Sheet', workspacePath: 'data/table.xlsx' },
|
||||
{ title: 'Unknown file', workspacePath: 'artifacts/blob.unknown' },
|
||||
{ title: 'Managed item', managedId: 'ext-123' },
|
||||
]);
|
||||
|
|
@ -1927,11 +1931,438 @@ describe('SessionArtifactStore', () => {
|
|||
'html',
|
||||
'image',
|
||||
'notebook',
|
||||
'document',
|
||||
'file',
|
||||
'other',
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands a directory workspacePath into per-file artifacts', async () => {
|
||||
const dir = path.join(workspace, 'scheduler_timeline_daily');
|
||||
await fs.mkdir(path.join(dir, 'nested'), { recursive: true });
|
||||
await fs.writeFile(path.join(dir, 'day1.xlsx'), 'xlsx-1');
|
||||
await fs.writeFile(path.join(dir, 'day2.xlsx'), 'xlsx-2');
|
||||
await fs.writeFile(path.join(dir, '.hidden.xlsx'), 'hidden');
|
||||
await fs.writeFile(path.join(dir, '~$lock.xlsx'), 'lock');
|
||||
await fs.writeFile(path.join(dir, 'nested', 'day3.xlsx'), 'xlsx-3');
|
||||
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-expand',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
const result = await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: '调度实例时间线数据 - 按天拆分 (17个Excel文件)',
|
||||
kind: 'file',
|
||||
workspacePath: 'scheduler_timeline_daily',
|
||||
toolCallId: 'call-1',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
|
||||
expect(
|
||||
result.changes.map((change) => change.artifact?.workspacePath),
|
||||
).toEqual([
|
||||
'scheduler_timeline_daily/day1.xlsx',
|
||||
'scheduler_timeline_daily/day2.xlsx',
|
||||
'scheduler_timeline_daily/nested/day3.xlsx',
|
||||
]);
|
||||
expect(result.changes.map((change) => change.artifact?.kind)).toEqual([
|
||||
'document',
|
||||
'document',
|
||||
'document',
|
||||
]);
|
||||
expect(result.changes.map((change) => change.artifact?.title)).toEqual([
|
||||
'day1.xlsx',
|
||||
'day2.xlsx',
|
||||
'day3.xlsx',
|
||||
]);
|
||||
expect(
|
||||
result.changes.every(
|
||||
(change) =>
|
||||
change.artifact?.description ===
|
||||
'调度实例时间线数据 - 按天拆分 (17个Excel文件)',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.changes.every(
|
||||
(change) => change.artifact?.toolCallId === 'call-1',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const listed = await store.list();
|
||||
expect(
|
||||
listed.artifacts.some(
|
||||
(artifact) => artifact.workspacePath === 'scheduler_timeline_daily',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not mark a leftover directory workspacePath as available', async () => {
|
||||
await fs.writeFile(path.join(workspace, 'report.xlsx'), 'xlsx');
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-leftover',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await store.upsertMany(
|
||||
[{ title: 'Report', workspacePath: 'report.xlsx' }],
|
||||
{ strict: true },
|
||||
);
|
||||
await fs.rm(path.join(workspace, 'report.xlsx'));
|
||||
await fs.mkdir(path.join(workspace, 'report.xlsx'));
|
||||
await fs.writeFile(path.join(workspace, 'report.xlsx', 'inner.xlsx'), 'y');
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(Date.now() + 6_000);
|
||||
const listed = await store.list();
|
||||
expect(listed.artifacts).toHaveLength(1);
|
||||
expect(listed.artifacts[0]?.status).not.toBe('available');
|
||||
expect(listed.artifacts[0]?.workspacePath).toBe('report.xlsx');
|
||||
});
|
||||
|
||||
it('rejects directory expansion when metadata is not a plain object', async () => {
|
||||
await fs.mkdir(path.join(workspace, 'reports'));
|
||||
await fs.writeFile(path.join(workspace, 'reports', 'a.xlsx'), 'xlsx');
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-meta',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Reports',
|
||||
workspacePath: 'reports',
|
||||
metadata: ['a', 'b'] as never,
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
),
|
||||
).rejects.toMatchObject({ field: 'metadata' });
|
||||
});
|
||||
|
||||
it('rejects directory expansion when expanded metadata would exceed the budget', async () => {
|
||||
await fs.mkdir(path.join(workspace, 'reports-meta'));
|
||||
await fs.writeFile(path.join(workspace, 'reports-meta', 'a.xlsx'), 'xlsx');
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-meta-budget',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Reports',
|
||||
workspacePath: 'reports-meta',
|
||||
metadata: { note: 'n'.repeat(4080) },
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
),
|
||||
).rejects.toMatchObject({ field: 'metadata' });
|
||||
});
|
||||
|
||||
it('rejects directory expansion when the parent title exceeds 200 characters', async () => {
|
||||
await fs.mkdir(path.join(workspace, 'reports-long'));
|
||||
await fs.writeFile(path.join(workspace, 'reports-long', 'a.xlsx'), 'xlsx');
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-long-title',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'x'.repeat(201),
|
||||
workspacePath: 'reports-long',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
),
|
||||
).rejects.toMatchObject({ field: 'title' });
|
||||
});
|
||||
|
||||
it('expands an in-workspace directory symlink and rejects an escaping one', async () => {
|
||||
await fs.mkdir(path.join(workspace, 'real-reports'));
|
||||
await fs.writeFile(path.join(workspace, 'real-reports', 'a.xlsx'), 'xlsx');
|
||||
await fs.symlink(
|
||||
path.join(workspace, 'real-reports'),
|
||||
path.join(workspace, 'reports-link'),
|
||||
);
|
||||
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-out-dir-'));
|
||||
try {
|
||||
await fs.mkdir(path.join(outside, 'secret'));
|
||||
await fs.writeFile(path.join(outside, 'secret', 'x.xlsx'), 'x');
|
||||
await fs.symlink(
|
||||
path.join(outside, 'secret'),
|
||||
path.join(workspace, 'escape-dir'),
|
||||
);
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-symlink',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
const expanded = await store.upsertMany(
|
||||
[{ title: 'Linked', workspacePath: 'reports-link' }],
|
||||
{ strict: true },
|
||||
);
|
||||
expect(
|
||||
expanded.changes.map((change) => change.artifact?.workspacePath),
|
||||
).toEqual(['reports-link/a.xlsx']);
|
||||
|
||||
await expect(
|
||||
store.upsertMany([{ title: 'Escape', workspacePath: 'escape-dir' }], {
|
||||
strict: true,
|
||||
}),
|
||||
).rejects.toMatchObject({ field: 'workspacePath' });
|
||||
} finally {
|
||||
await fs.rm(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects expanding a path nested under a junk directory', async () => {
|
||||
await fs.mkdir(path.join(workspace, 'node_modules', 'react'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'node_modules', 'react', 'index.js'),
|
||||
'js',
|
||||
);
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-nested-junk',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await expect(
|
||||
store.upsertMany(
|
||||
[{ title: 'React', workspacePath: 'node_modules/react' }],
|
||||
{ strict: true },
|
||||
),
|
||||
).rejects.toMatchObject({ field: 'workspacePath' });
|
||||
});
|
||||
|
||||
it('rejects a symlink that aliases a skipped directory', async () => {
|
||||
await fs.mkdir(path.join(workspace, 'node_modules', 'react'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'node_modules', 'react', 'index.js'),
|
||||
'js',
|
||||
);
|
||||
await fs.symlink(
|
||||
path.join(workspace, 'node_modules', 'react'),
|
||||
path.join(workspace, 'deps'),
|
||||
);
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-symlink-junk',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await expect(
|
||||
store.upsertMany([{ title: 'Deps', workspacePath: 'deps' }], {
|
||||
strict: true,
|
||||
}),
|
||||
).rejects.toMatchObject({ field: 'workspacePath' });
|
||||
});
|
||||
|
||||
it('rejects a path whose intermediate symlink aliases a skipped directory', async () => {
|
||||
await fs.mkdir(path.join(workspace, '.qwen', 'sub'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspace, '.qwen', 'sub', 'secret.xlsx'),
|
||||
'xlsx',
|
||||
);
|
||||
await fs.symlink(
|
||||
path.join(workspace, '.qwen'),
|
||||
path.join(workspace, 'link'),
|
||||
);
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-intermediate-symlink-junk',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await expect(
|
||||
store.upsertMany([{ title: 'Secret', workspacePath: 'link/sub' }], {
|
||||
strict: true,
|
||||
}),
|
||||
).rejects.toMatchObject({ field: 'workspacePath' });
|
||||
});
|
||||
|
||||
it('rejects directory expansion when the parent description is invalid', async () => {
|
||||
await fs.mkdir(path.join(workspace, 'reports-desc'));
|
||||
await fs.writeFile(path.join(workspace, 'reports-desc', 'a.xlsx'), 'xlsx');
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-bad-desc',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await expect(
|
||||
store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Reports',
|
||||
workspacePath: 'reports-desc',
|
||||
description: 'x'.repeat(1001),
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
),
|
||||
).rejects.toMatchObject({ field: 'description' });
|
||||
});
|
||||
|
||||
it('keeps a curated title when a same-batch expansion precedes the explicit record', async () => {
|
||||
await fs.mkdir(path.join(workspace, 'reports-batch'));
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'reports-batch', 'q3.xlsx'),
|
||||
'xlsx',
|
||||
);
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-same-batch-order',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Reports',
|
||||
workspacePath: 'reports-batch',
|
||||
toolName: 'record_artifact',
|
||||
toolCallId: 'call-dir',
|
||||
},
|
||||
{
|
||||
title: 'Q3 Final',
|
||||
workspacePath: 'reports-batch/q3.xlsx',
|
||||
toolName: 'record_artifact',
|
||||
toolCallId: 'call-file',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
const listed = await store.list();
|
||||
expect(listed.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
title: 'Q3 Final',
|
||||
toolCallId: 'call-file',
|
||||
workspacePath: 'reports-batch/q3.xlsx',
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
listed.artifacts[0]?.metadata?.['expandedFromDirectory'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a curated title when a same-batch explicit record precedes expansion', async () => {
|
||||
await fs.mkdir(path.join(workspace, 'reports-batch-b'));
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'reports-batch-b', 'q3.xlsx'),
|
||||
'xlsx',
|
||||
);
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-same-batch-order-b',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Q3 Final',
|
||||
workspacePath: 'reports-batch-b/q3.xlsx',
|
||||
toolName: 'record_artifact',
|
||||
toolCallId: 'call-file',
|
||||
},
|
||||
{
|
||||
title: 'Reports',
|
||||
workspacePath: 'reports-batch-b',
|
||||
toolName: 'record_artifact',
|
||||
toolCallId: 'call-dir',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
const listed = await store.list();
|
||||
expect(listed.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
title: 'Q3 Final',
|
||||
toolCallId: 'call-file',
|
||||
workspacePath: 'reports-batch-b/q3.xlsx',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a curated title when a later directory expansion covers the same file', async () => {
|
||||
await fs.mkdir(path.join(workspace, 'reports'));
|
||||
await fs.writeFile(path.join(workspace, 'reports', 'q3.xlsx'), 'xlsx');
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-keep-title',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Q3 Financial Report',
|
||||
workspacePath: 'reports/q3.xlsx',
|
||||
toolName: 'record_artifact',
|
||||
toolCallId: 'call-1',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Reports',
|
||||
workspacePath: 'reports',
|
||||
toolName: 'record_artifact',
|
||||
toolCallId: 'call-2',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
|
||||
const listed = await store.list();
|
||||
expect(listed.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
title: 'Q3 Financial Report',
|
||||
toolCallId: 'call-1',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an empty directory workspacePath', async () => {
|
||||
await fs.mkdir(path.join(workspace, 'empty-dir'));
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-empty',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.upsertMany([{ title: 'Empty', workspacePath: 'empty-dir' }], {
|
||||
strict: true,
|
||||
}),
|
||||
).rejects.toMatchObject({ field: 'workspacePath' });
|
||||
});
|
||||
|
||||
it('caps expanded directory files and warns', async () => {
|
||||
const dir = path.join(workspace, 'many-files');
|
||||
await fs.mkdir(dir);
|
||||
await Promise.all(
|
||||
Array.from({ length: 105 }, (_, index) =>
|
||||
fs.writeFile(
|
||||
path.join(dir, `f${String(index).padStart(3, '0')}.txt`),
|
||||
'x',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's-dir-cap',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
const result = await store.upsertMany([
|
||||
{ title: 'Many', workspacePath: 'many-files' },
|
||||
]);
|
||||
|
||||
expect(result.changes).toHaveLength(100);
|
||||
expect(result.warnings?.[0]).toMatch(/more than 100/);
|
||||
});
|
||||
|
||||
it('rejects unsafe display markup in title and description', async () => {
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's5-markup',
|
||||
|
|
|
|||
|
|
@ -9,10 +9,16 @@ import { constants as fsConstants, promises as fs, type Stats } from 'node:fs';
|
|||
import type { FileHandle } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
collectRecordableWorkspaceFiles,
|
||||
isOfficeDocumentExtension,
|
||||
isPrototypeMetadataKey,
|
||||
isRecordableDerivedChild,
|
||||
isReservedWorkspaceMetadataKey,
|
||||
MAX_DIRECTORY_ARTIFACT_DEPTH,
|
||||
MAX_DIRECTORY_ARTIFACT_FILES,
|
||||
metadataBudgetBytes,
|
||||
SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
pathHasSkippedDirectoryComponent,
|
||||
stableSessionArtifactId,
|
||||
WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY,
|
||||
WORKSPACE_CONTENT_SHA256_METADATA_KEY,
|
||||
|
|
@ -37,6 +43,7 @@ export type DaemonSessionArtifactKind =
|
|||
| 'audio'
|
||||
| 'pdf'
|
||||
| 'notebook'
|
||||
| 'document'
|
||||
| 'other';
|
||||
|
||||
export type DaemonSessionArtifactStorage =
|
||||
|
|
@ -328,13 +335,32 @@ export class SessionArtifactStore {
|
|||
const warningDetails: SessionArtifactWarningDetail[] = [];
|
||||
for (const input of inputs) {
|
||||
try {
|
||||
normalizedResults.push(
|
||||
await this.normalizeInput(
|
||||
input,
|
||||
++this.receivedSeq,
|
||||
options.trustedPublisher === true,
|
||||
),
|
||||
);
|
||||
const expanded = await this.expandWorkspaceDirectoryInput(input);
|
||||
for (const item of expanded.inputs) {
|
||||
try {
|
||||
normalizedResults.push(
|
||||
await this.normalizeInput(
|
||||
item,
|
||||
++this.receivedSeq,
|
||||
options.trustedPublisher === true,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (validationStrict) {
|
||||
throw error;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
writeStderrLine(
|
||||
`[artifacts] session=${this.sessionId} action=dropped reason=${JSON.stringify(
|
||||
message,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (expanded.warning) {
|
||||
warnings.push(expanded.warning);
|
||||
}
|
||||
} catch (error) {
|
||||
if (validationStrict) {
|
||||
throw error;
|
||||
|
|
@ -428,9 +454,17 @@ export class SessionArtifactStore {
|
|||
.filter((change) => change.action === 'created')
|
||||
.map((change) => change.artifactId),
|
||||
);
|
||||
changes.push(
|
||||
...(await this.evictOverflow(createdIds, changes, persistenceStrict)),
|
||||
const overflowRemoved = await this.evictOverflow(
|
||||
createdIds,
|
||||
changes,
|
||||
persistenceStrict,
|
||||
);
|
||||
changes.push(...overflowRemoved.removed);
|
||||
if (overflowRemoved.droppedCreated > 0) {
|
||||
warnings.push(
|
||||
`dropped ${overflowRemoved.droppedCreated} newly created artifacts because the store is full`,
|
||||
);
|
||||
}
|
||||
|
||||
const hasStrictDurableTransition = changes.some(
|
||||
shouldCommitBeforeDurablePersistence,
|
||||
|
|
@ -789,9 +823,9 @@ export class SessionArtifactStore {
|
|||
});
|
||||
}
|
||||
const evicted = await this.evictOverflow(new Set(), []);
|
||||
if (evicted.length > 0) {
|
||||
if (evicted.removed.length > 0) {
|
||||
warnings.push('restored artifact list pruned to live limit');
|
||||
warnings.push(...(await this.persistChanges(evicted, false)));
|
||||
warnings.push(...(await this.persistChanges(evicted.removed, false)));
|
||||
}
|
||||
this.setLastRestoreWarnings(warnings);
|
||||
return warnings;
|
||||
|
|
@ -1251,6 +1285,197 @@ export class SessionArtifactStore {
|
|||
);
|
||||
}
|
||||
|
||||
private async expandWorkspaceDirectoryInput(
|
||||
input: SessionArtifactInput,
|
||||
): Promise<{ inputs: SessionArtifactInput[]; warning?: string }> {
|
||||
const workspacePath =
|
||||
typeof input.workspacePath === 'string'
|
||||
? input.workspacePath.trim()
|
||||
: undefined;
|
||||
if (!workspacePath) {
|
||||
return { inputs: [input] };
|
||||
}
|
||||
const realWorkspace = await this.getRealWorkspaceCwdForValidation();
|
||||
const normalizedPath = normalizeWorkspacePath(workspacePath, realWorkspace);
|
||||
const absolutePath = path.resolve(realWorkspace, normalizedPath);
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = await fs.lstat(absolutePath);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return { inputs: [input] };
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new SessionArtifactValidationError(
|
||||
`workspacePath could not be inspected: ${reason}`,
|
||||
'workspacePath',
|
||||
);
|
||||
}
|
||||
let walkDir = absolutePath;
|
||||
let walkRelative = normalizedPath;
|
||||
if (stat.isSymbolicLink()) {
|
||||
let realPath: string;
|
||||
try {
|
||||
realPath = await fs.realpath(absolutePath);
|
||||
} catch {
|
||||
return { inputs: [input] };
|
||||
}
|
||||
let realStat: Stats;
|
||||
try {
|
||||
realStat = await fs.lstat(realPath);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return { inputs: [input] };
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new SessionArtifactValidationError(
|
||||
`workspacePath could not be inspected: ${reason}`,
|
||||
'workspacePath',
|
||||
);
|
||||
}
|
||||
if (!realStat.isDirectory()) {
|
||||
return { inputs: [input] };
|
||||
}
|
||||
walkDir = realPath;
|
||||
// Keep the caller-facing symlink path so expansion and direct records
|
||||
// share one identity; realPath is only used to read the directory.
|
||||
walkRelative = normalizedPath;
|
||||
} else if (!stat.isDirectory()) {
|
||||
return { inputs: [input] };
|
||||
} else {
|
||||
// Intermediate symlink components are followed by path lookup, so the
|
||||
// final lstat may look like a plain directory while the real target
|
||||
// sits under a skipped tree. Canonicalize before the skip gate.
|
||||
try {
|
||||
walkDir = await fs.realpath(absolutePath);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return { inputs: [input] };
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new SessionArtifactValidationError(
|
||||
`workspacePath could not be inspected: ${reason}`,
|
||||
'workspacePath',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedRelative = path.relative(realWorkspace, walkDir);
|
||||
if (!resolvedRelative || isOutsidePath(resolvedRelative)) {
|
||||
throw new SessionArtifactValidationError(
|
||||
'workspacePath must stay inside the workspace',
|
||||
'workspacePath',
|
||||
);
|
||||
}
|
||||
if (
|
||||
pathHasSkippedDirectoryComponent(
|
||||
resolvedRelative.split(path.sep).join('/'),
|
||||
) ||
|
||||
(walkRelative && pathHasSkippedDirectoryComponent(walkRelative))
|
||||
) {
|
||||
throw new SessionArtifactValidationError(
|
||||
'workspacePath is a skipped directory and cannot be recorded',
|
||||
'workspacePath',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
input.metadata !== undefined &&
|
||||
!isPlainMetadataObject(input.metadata)
|
||||
) {
|
||||
throw new SessionArtifactValidationError(
|
||||
'metadata must be an object',
|
||||
'metadata',
|
||||
);
|
||||
}
|
||||
const childMetadata = {
|
||||
...(isPlainMetadataObject(input.metadata) ? input.metadata : {}),
|
||||
expandedFromDirectory: true as const,
|
||||
};
|
||||
if (Buffer.byteLength(JSON.stringify(childMetadata), 'utf8') > 4096) {
|
||||
throw new SessionArtifactValidationError(
|
||||
'metadata is too large to expand a directory',
|
||||
'metadata',
|
||||
);
|
||||
}
|
||||
const parentTitle = normalizeString(input.title, 'title', 200, true);
|
||||
const parentDescription = normalizeString(
|
||||
input.description,
|
||||
'description',
|
||||
1000,
|
||||
false,
|
||||
);
|
||||
|
||||
let collected: Awaited<ReturnType<typeof collectRecordableWorkspaceFiles>>;
|
||||
try {
|
||||
collected = await collectRecordableWorkspaceFiles(
|
||||
walkDir,
|
||||
walkRelative,
|
||||
realWorkspace,
|
||||
(filePath) =>
|
||||
isRecordableDerivedChild(path.posix.basename(filePath), filePath),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return { inputs: [input] };
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new SessionArtifactValidationError(
|
||||
`workspacePath could not be inspected: ${reason}`,
|
||||
'workspacePath',
|
||||
);
|
||||
}
|
||||
if (collected.files.length === 0) {
|
||||
throw new SessionArtifactValidationError(
|
||||
collected.depthLimited
|
||||
? `workspacePath is a directory whose recordable files are deeper than ${MAX_DIRECTORY_ARTIFACT_DEPTH} levels`
|
||||
: 'workspacePath is a directory with no recordable files',
|
||||
'workspacePath',
|
||||
);
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
if (collected.truncated) {
|
||||
warnings.push(
|
||||
`workspacePath "${normalizedPath}" contained more than ${MAX_DIRECTORY_ARTIFACT_FILES} files; recorded the first ${collected.files.length}`,
|
||||
);
|
||||
}
|
||||
if (collected.depthLimited) {
|
||||
warnings.push(
|
||||
`workspacePath "${normalizedPath}" exceeded ${MAX_DIRECTORY_ARTIFACT_DEPTH} directory levels; some files were not recorded`,
|
||||
);
|
||||
}
|
||||
if (collected.unreadable) {
|
||||
warnings.push(
|
||||
`workspacePath "${normalizedPath}" contained subdirectories that could not be read`,
|
||||
);
|
||||
}
|
||||
if (collected.skippedUnrecordable > 0) {
|
||||
warnings.push(
|
||||
`workspacePath "${normalizedPath}" skipped ${collected.skippedUnrecordable} files whose names cannot be recorded as artifact titles`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
inputs: collected.files.map((filePath) => {
|
||||
const title = path.posix.basename(filePath).trim();
|
||||
const description =
|
||||
parentDescription ||
|
||||
(parentTitle && parentTitle !== title ? parentTitle : undefined);
|
||||
return {
|
||||
...input,
|
||||
title,
|
||||
workspacePath: filePath,
|
||||
kind: undefined,
|
||||
mimeType: undefined,
|
||||
sizeBytes: undefined,
|
||||
metadata: childMetadata,
|
||||
...(description ? { description } : { description: undefined }),
|
||||
};
|
||||
}),
|
||||
...(warnings.length > 0 ? { warning: warnings.join('; ') } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async normalizeInput(
|
||||
input: RestoreSessionArtifactInput,
|
||||
receivedSeq: number,
|
||||
|
|
@ -1541,10 +1766,10 @@ export class SessionArtifactStore {
|
|||
createdIds: Set<string>,
|
||||
changes: SessionArtifactChange[],
|
||||
strict = false,
|
||||
): Promise<SessionArtifactChange[]> {
|
||||
): Promise<{ removed: SessionArtifactChange[]; droppedCreated: number }> {
|
||||
const removed: SessionArtifactChange[] = [];
|
||||
if (this.artifacts.size <= this.maxArtifacts) {
|
||||
return removed;
|
||||
return { removed, droppedCreated: 0 };
|
||||
}
|
||||
|
||||
const createdInThisBatch = new Set(createdIds);
|
||||
|
|
@ -1592,18 +1817,20 @@ export class SessionArtifactStore {
|
|||
'artifactId',
|
||||
);
|
||||
}
|
||||
let droppedCreated = 0;
|
||||
for (const artifact of overflowCreated) {
|
||||
if (this.artifacts.size <= this.maxArtifacts) {
|
||||
break;
|
||||
}
|
||||
this.artifacts.delete(artifact.id);
|
||||
droppedCreated++;
|
||||
writeStderrLine(
|
||||
`[artifacts] session=${this.sessionId} action=dropped reason="max artifacts exceeded" artifactId=${artifact.id}`,
|
||||
);
|
||||
removePriorChange(changes, artifact.id);
|
||||
}
|
||||
|
||||
return removed;
|
||||
return { removed, droppedCreated };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1667,6 +1894,7 @@ function mergeBatchArtifact(
|
|||
existing.storage === 'workspace' &&
|
||||
next.storage === 'workspace' &&
|
||||
shouldRefreshWorkspaceDisplay(next, existing);
|
||||
const metadata = mergeMetadata(existing, next);
|
||||
return {
|
||||
...existing,
|
||||
title: refreshDisplay ? next.title : existing.title,
|
||||
|
|
@ -1679,7 +1907,9 @@ function mergeBatchArtifact(
|
|||
toolCallId: refreshDisplay ? next.toolCallId : existing.toolCallId,
|
||||
status: next.status,
|
||||
sizeBytes: mergeSizeBytes(existing, next),
|
||||
metadata: mergeMetadata(existing, next),
|
||||
metadata: refreshDisplay
|
||||
? stripExpandedFromDirectoryMarker(metadata)
|
||||
: metadata,
|
||||
clientRetained: existing.clientRetained || next.clientRetained,
|
||||
trustedPublisher: existing.trustedPublisher || next.trustedPublisher,
|
||||
retentionExplicit: existing.retentionExplicit || next.retentionExplicit,
|
||||
|
|
@ -1779,6 +2009,7 @@ function mergeArtifact(
|
|||
next.toolName = incoming.toolName;
|
||||
next.source = incoming.source;
|
||||
next.hookEventName = incoming.hookEventName;
|
||||
next.metadata = stripExpandedFromDirectoryMarker(next.metadata);
|
||||
}
|
||||
|
||||
const changed = !publicArtifactsEqual(
|
||||
|
|
@ -1804,10 +2035,26 @@ function shouldRecordEphemeralUnpin(
|
|||
);
|
||||
}
|
||||
|
||||
function stripExpandedFromDirectoryMarker(
|
||||
metadata: Record<string, string | number | boolean | null> | undefined,
|
||||
): Record<string, string | number | boolean | null> | undefined {
|
||||
if (metadata?.['expandedFromDirectory'] !== true) {
|
||||
return metadata;
|
||||
}
|
||||
const { expandedFromDirectory: _dropped, ...rest } = metadata;
|
||||
return Object.keys(rest).length > 0 ? rest : undefined;
|
||||
}
|
||||
|
||||
function shouldRefreshWorkspaceDisplay(
|
||||
incoming: Pick<NormalizedArtifact, 'toolName' | 'source' | 'hookEventName'>,
|
||||
incoming: Pick<
|
||||
NormalizedArtifact,
|
||||
'toolName' | 'source' | 'hookEventName' | 'metadata'
|
||||
>,
|
||||
existing: Pick<NormalizedArtifact, 'toolName' | 'source' | 'hookEventName'>,
|
||||
): boolean {
|
||||
if (incoming.metadata?.['expandedFromDirectory'] === true) {
|
||||
return false;
|
||||
}
|
||||
if (incoming.toolName === 'record_artifact' && incoming.source !== 'hook') {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -2356,6 +2603,7 @@ function normalizeKind(kind: unknown): DaemonSessionArtifactKind {
|
|||
kind === 'audio' ||
|
||||
kind === 'pdf' ||
|
||||
kind === 'notebook' ||
|
||||
kind === 'document' ||
|
||||
kind === 'other'
|
||||
) {
|
||||
return kind;
|
||||
|
|
@ -2835,6 +3083,7 @@ function inferKind(input: {
|
|||
if (['.mp3', '.wav', '.m4a', '.ogg'].includes(ext)) return 'audio';
|
||||
if (ext === '.pdf') return 'pdf';
|
||||
if (ext === '.ipynb') return 'notebook';
|
||||
if (isOfficeDocumentExtension(ext)) return 'document';
|
||||
return input.workspacePath ? 'file' : 'other';
|
||||
}
|
||||
|
||||
|
|
@ -2868,60 +3117,58 @@ async function getWorkspaceStatus(
|
|||
if (!isSameFile(preOpenStat, stat)) {
|
||||
return { status: 'missing', escaped: true };
|
||||
}
|
||||
if (stat.isFile()) {
|
||||
const expectedMtimeMs =
|
||||
typeof expected?.mtimeMs === 'number' ? expected.mtimeMs : undefined;
|
||||
const expectedSha256 =
|
||||
typeof expected?.sha256 === 'string' ? expected.sha256 : undefined;
|
||||
const unchanged =
|
||||
expected?.sizeBytes === stat.size && expectedMtimeMs === stat.mtimeMs;
|
||||
const sizeChanged =
|
||||
expected?.sizeBytes !== undefined && expected.sizeBytes !== stat.size;
|
||||
if (sizeChanged) {
|
||||
return {
|
||||
status: 'changed',
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
};
|
||||
}
|
||||
if (unchanged) {
|
||||
return {
|
||||
status: 'available',
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
};
|
||||
}
|
||||
if (stat.size > MAX_WORKSPACE_HASH_BYTES) {
|
||||
return {
|
||||
status: expectedSha256 ? 'changed' : 'available',
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
};
|
||||
}
|
||||
if (!options.hashContent) {
|
||||
return {
|
||||
status: expectedSha256 ? 'changed' : 'available',
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
};
|
||||
}
|
||||
const sha256 = await hashFile(handle);
|
||||
if (expectedSha256 && sha256 !== expectedSha256) {
|
||||
return {
|
||||
status: 'changed',
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
};
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new Error('path is not a regular file');
|
||||
}
|
||||
const expectedMtimeMs =
|
||||
typeof expected?.mtimeMs === 'number' ? expected.mtimeMs : undefined;
|
||||
const expectedSha256 =
|
||||
typeof expected?.sha256 === 'string' ? expected.sha256 : undefined;
|
||||
const unchanged =
|
||||
expected?.sizeBytes === stat.size && expectedMtimeMs === stat.mtimeMs;
|
||||
const sizeChanged =
|
||||
expected?.sizeBytes !== undefined && expected.sizeBytes !== stat.size;
|
||||
if (sizeChanged) {
|
||||
return {
|
||||
status: 'changed',
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
};
|
||||
}
|
||||
if (unchanged) {
|
||||
return {
|
||||
status: 'available',
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
sha256,
|
||||
};
|
||||
}
|
||||
if (stat.size > MAX_WORKSPACE_HASH_BYTES) {
|
||||
return {
|
||||
status: expectedSha256 ? 'changed' : 'available',
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
};
|
||||
}
|
||||
if (!options.hashContent) {
|
||||
return {
|
||||
status: expectedSha256 ? 'changed' : 'available',
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
};
|
||||
}
|
||||
const sha256 = await hashFile(handle);
|
||||
if (expectedSha256 && sha256 !== expectedSha256) {
|
||||
return {
|
||||
status: 'changed',
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'available',
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
sha256,
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
|
|
@ -3041,6 +3288,12 @@ function isNotFoundError(error: unknown): boolean {
|
|||
return code === 'ENOENT' || code === 'ENOTDIR';
|
||||
}
|
||||
|
||||
function isPlainMetadataObject(
|
||||
value: unknown,
|
||||
): value is Record<string, string | number | boolean | null> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNoFollowSymlinkError(error: unknown): boolean {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -210,11 +210,24 @@ export {
|
|||
resolveBoundWorkspaceRoot,
|
||||
toCanonicalWorkspaceArtifactPath,
|
||||
} from './utils/workspace-artifact-path.js';
|
||||
export {
|
||||
MAX_DIRECTORY_ARTIFACT_DEPTH,
|
||||
MAX_DIRECTORY_ARTIFACT_FILES,
|
||||
OFFICE_DOCUMENT_EXTENSIONS,
|
||||
collectRecordableWorkspaceFiles,
|
||||
isOfficeDocumentExtension,
|
||||
pathHasSkippedDirectoryComponent,
|
||||
shouldSkipDirectoryArtifactName,
|
||||
stripWorktreeArtifactPrefix,
|
||||
} from './utils/workspace-artifact-directory.js';
|
||||
export type {
|
||||
ArtifactTool,
|
||||
ArtifactToolParams,
|
||||
} from './tools/artifact/artifact-tool.js';
|
||||
export { RecordArtifactTool } from './tools/record-artifact.js';
|
||||
export {
|
||||
RecordArtifactTool,
|
||||
isRecordableDerivedChild,
|
||||
} from './tools/record-artifact.js';
|
||||
export type { RecordArtifactParams } from './tools/record-artifact.js';
|
||||
export type {
|
||||
ArtifactPublisher,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,39 @@ function event(payload: SessionArtifactEventRecordPayload): {
|
|||
}
|
||||
|
||||
describe('session artifact persistence records', () => {
|
||||
it('roundtrips persisted document artifacts', () => {
|
||||
const document = artifact('s1', 'https://example.com/unused', {
|
||||
kind: 'document',
|
||||
storage: 'workspace',
|
||||
workspacePath: 'reports/q3.xlsx',
|
||||
url: undefined,
|
||||
});
|
||||
|
||||
const snapshot = rebuildSessionArtifactSnapshot([
|
||||
{
|
||||
type: 'system',
|
||||
subtype: 'session_artifact_snapshot',
|
||||
systemPayload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: 's1',
|
||||
sequence: 1,
|
||||
recordedAt: '2026-07-04T00:00:00.000Z',
|
||||
artifacts: [document],
|
||||
tombstonedIds: [],
|
||||
stickyEphemeralIds: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(snapshot?.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'document',
|
||||
storage: 'workspace',
|
||||
workspacePath: 'reports/q3.xlsx',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rebuilds durable artifacts and explicit tombstones from event records', () => {
|
||||
const first = artifact('s1', 'https://example.com/first');
|
||||
const second = artifact('s1', 'https://example.com/second');
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export type PersistedSessionArtifactKind =
|
|||
| 'audio'
|
||||
| 'pdf'
|
||||
| 'notebook'
|
||||
| 'document'
|
||||
| 'other';
|
||||
|
||||
export type PersistedSessionArtifactStorage =
|
||||
|
|
@ -856,6 +857,7 @@ function normalizePersistedArtifact(
|
|||
'audio',
|
||||
'pdf',
|
||||
'notebook',
|
||||
'document',
|
||||
'other',
|
||||
]);
|
||||
const storage = normalizeLiteral<PersistedSessionArtifactStorage>(
|
||||
|
|
|
|||
|
|
@ -437,7 +437,7 @@ describe('RecordArtifactTool', () => {
|
|||
expect(String(result.llmContent)).not.toContain('Recorded artifact');
|
||||
});
|
||||
|
||||
it('rejects a directory workspacePath', async () => {
|
||||
it('rejects an empty directory workspacePath', async () => {
|
||||
const ws = await workspace();
|
||||
await mkdir(path.join(ws.cwd, 'reports'));
|
||||
|
||||
|
|
@ -449,7 +449,199 @@ describe('RecordArtifactTool', () => {
|
|||
.execute(signal);
|
||||
|
||||
expect(result.error?.type).toBe(ToolErrorType.TARGET_IS_DIRECTORY);
|
||||
expect(result.artifacts).toBeUndefined();
|
||||
expect(String(result.llmContent)).not.toContain('Recorded artifact');
|
||||
expect(String(result.llmContent)).toContain('no recordable files');
|
||||
});
|
||||
|
||||
it('expands a directory workspacePath into per-file artifacts', async () => {
|
||||
const ws = await workspace();
|
||||
await ws.write('reports/a.xlsx', 'xlsx');
|
||||
await ws.write('reports/b.docx', 'docx');
|
||||
await ws.write('reports/.hidden.xlsx', 'hidden');
|
||||
await ws.write('reports/~$lock.xlsx', 'lock');
|
||||
await ws.write('reports/nested/c.pptx', 'pptx');
|
||||
|
||||
const result = await ws.tool
|
||||
.build({
|
||||
title: 'Daily reports',
|
||||
workspacePath: 'reports',
|
||||
})
|
||||
.execute(signal);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(String(result.llmContent)).toContain('Expanded directory');
|
||||
expect(String(result.llmContent)).not.toContain('Recorded artifact');
|
||||
expect(String(result.llmContent)).toContain('reports/a.xlsx');
|
||||
expect(result.artifacts).toMatchObject([
|
||||
{
|
||||
title: 'a.xlsx',
|
||||
storage: 'workspace',
|
||||
workspacePath: 'reports/a.xlsx',
|
||||
description: 'Daily reports',
|
||||
},
|
||||
{
|
||||
title: 'b.docx',
|
||||
storage: 'workspace',
|
||||
workspacePath: 'reports/b.docx',
|
||||
description: 'Daily reports',
|
||||
},
|
||||
{
|
||||
title: 'c.pptx',
|
||||
storage: 'workspace',
|
||||
workspacePath: 'reports/nested/c.pptx',
|
||||
description: 'Daily reports',
|
||||
metadata: { expandedFromDirectory: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('discloses the 100-file cap when expanding a large directory', async () => {
|
||||
const ws = await workspace();
|
||||
for (let index = 0; index < 101; index++) {
|
||||
await ws.write(`reports/f${String(index).padStart(3, '0')}.txt`, 'x');
|
||||
}
|
||||
|
||||
const result = await ws.tool
|
||||
.build({
|
||||
title: 'Many',
|
||||
workspacePath: 'reports',
|
||||
})
|
||||
.execute(signal);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.artifacts).toHaveLength(100);
|
||||
expect(String(result.llmContent)).toMatch(/first 100 files/i);
|
||||
});
|
||||
|
||||
it('rejects expanding a junk directory root', async () => {
|
||||
const ws = await workspace();
|
||||
await ws.write('node_modules/pkg/index.js', 'js');
|
||||
|
||||
const result = await ws.tool
|
||||
.build({
|
||||
title: 'Deps',
|
||||
workspacePath: 'node_modules',
|
||||
})
|
||||
.execute(signal);
|
||||
|
||||
expect(result.error?.type).toBe(ToolErrorType.TARGET_IS_DIRECTORY);
|
||||
expect(result.artifacts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects expanding a path nested under a junk directory', async () => {
|
||||
const ws = await workspace();
|
||||
await ws.write('node_modules/react/index.js', 'js');
|
||||
|
||||
const result = await ws.tool
|
||||
.build({
|
||||
title: 'React',
|
||||
workspacePath: 'node_modules/react',
|
||||
})
|
||||
.execute(signal);
|
||||
|
||||
expect(result.error?.type).toBe(ToolErrorType.TARGET_IS_DIRECTORY);
|
||||
expect(result.artifacts).toBeUndefined();
|
||||
expect(String(result.llmContent)).toContain('skipped directory');
|
||||
});
|
||||
|
||||
it('skips expansion children whose names are not trim-stable', async () => {
|
||||
const ws = await workspace();
|
||||
await ws.write('notes/keep.txt', 'ok');
|
||||
await writeFile(path.join(ws.cwd, 'notes', ' report.txt'), 'space');
|
||||
|
||||
const result = await ws.tool
|
||||
.build({
|
||||
title: 'Notes',
|
||||
workspacePath: 'notes',
|
||||
})
|
||||
.execute(signal);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.artifacts).toMatchObject([
|
||||
{ workspacePath: 'notes/keep.txt' },
|
||||
]);
|
||||
expect(String(result.llmContent)).toMatch(/Skipped 1 files/i);
|
||||
});
|
||||
|
||||
it('rejects recording the worktree cwd as a directory', async () => {
|
||||
const ws = await workspace(path.join('.qwen', 'worktrees', 'my-feature'));
|
||||
await ws.write('keep.xlsx', 'xlsx');
|
||||
|
||||
const result = await ws.tool
|
||||
.build({
|
||||
title: 'Worktree root',
|
||||
workspacePath: '.',
|
||||
})
|
||||
.execute(signal);
|
||||
|
||||
expect(result.error?.type).toBe(ToolErrorType.TARGET_IS_DIRECTORY);
|
||||
expect(result.artifacts).toBeUndefined();
|
||||
expect(String(result.llmContent)).toContain('workspace root');
|
||||
});
|
||||
|
||||
it('expands a subdirectory inside a worktree session', async () => {
|
||||
const ws = await workspace(path.join('.qwen', 'worktrees', 'my-feature'));
|
||||
await ws.write('reports/a.xlsx', 'xlsx');
|
||||
await ws.write('reports/b.docx', 'docx');
|
||||
|
||||
const result = await ws.tool
|
||||
.build({
|
||||
title: 'Worktree reports',
|
||||
workspacePath: 'reports',
|
||||
})
|
||||
.execute(signal);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.artifacts).toMatchObject([
|
||||
{
|
||||
workspacePath: '.qwen/worktrees/my-feature/reports/a.xlsx',
|
||||
},
|
||||
{
|
||||
workspacePath: '.qwen/worktrees/my-feature/reports/b.docx',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips junk directories and lock files when expanding a directory', async () => {
|
||||
const ws = await workspace();
|
||||
await ws.write('reports/keep.xlsx', 'xlsx');
|
||||
await ws.write('reports/node_modules/skip.txt', 'skip');
|
||||
await ws.write('reports/~$lock.xlsx', 'lock');
|
||||
|
||||
const result = await ws.tool
|
||||
.build({
|
||||
title: 'Reports',
|
||||
workspacePath: 'reports',
|
||||
})
|
||||
.execute(signal);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.artifacts).toMatchObject([
|
||||
{ workspacePath: 'reports/keep.xlsx' },
|
||||
]);
|
||||
expect(String(result.llmContent)).not.toContain('node_modules');
|
||||
});
|
||||
|
||||
it('warns when directory expansion hits the depth limit', async () => {
|
||||
const ws = await workspace();
|
||||
await ws.write('reports/a/b/c/d/e/too-deep.xlsx', 'deep');
|
||||
await ws.write('reports/shallow.xlsx', 'xlsx');
|
||||
|
||||
const result = await ws.tool
|
||||
.build({
|
||||
title: 'Reports',
|
||||
workspacePath: 'reports',
|
||||
})
|
||||
.execute(signal);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.artifacts).toMatchObject([
|
||||
{ workspacePath: 'reports/shallow.xlsx' },
|
||||
]);
|
||||
expect(String(result.llmContent)).toMatch(
|
||||
/deeper than 4 directory levels/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a workspace-relative path that escapes the execution directory', () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ import path from 'node:path';
|
|||
import type { Config } from '../config/config.js';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
import { isWithinRoot } from '../utils/fileUtils.js';
|
||||
import {
|
||||
MAX_DIRECTORY_ARTIFACT_DEPTH,
|
||||
collectRecordableWorkspaceFiles,
|
||||
pathHasSkippedDirectoryComponent,
|
||||
} from '../utils/workspace-artifact-directory.js';
|
||||
import {
|
||||
resolveBoundWorkspaceRoot,
|
||||
toCanonicalWorkspaceArtifactPath,
|
||||
|
|
@ -38,11 +43,11 @@ export interface RecordArtifactParams {
|
|||
metadata?: Record<string, string | number | boolean | null>;
|
||||
}
|
||||
|
||||
const DESCRIPTION = `Registers a session artifact so clients can show it in an artifacts panel. Use it after creating a useful file, URL, image, report, notebook, or other intermediate result that the user may want to open later, unless the producing tool already returned artifact metadata. For example, write_file automatically records HTML, image, PDF, notebook, CSV, and Excel files it writes inside the workspace, so do not call record_artifact again for the same workspacePath; still call it for other formats such as Markdown, JSON, and plain text, and for files produced outside write_file. When the session creates a remote resource, such as a pull request, issue, or comment submitted via gh, record its URL with kind "link" and the url locator so the user can reopen it later.
|
||||
const DESCRIPTION = `Registers a session artifact so clients can show it in an artifacts panel. Use it after creating a useful file, URL, image, report, notebook, or other intermediate result that the user may want to open later, unless the producing tool already returned artifact metadata. For example, write_file automatically records HTML, image, PDF, notebook, CSV, and office documents it writes inside the workspace, so do not call record_artifact again for the same workspacePath; still call it for other formats such as Markdown, JSON, and plain text, and for files produced outside write_file. When the session creates a remote resource, such as a pull request, issue, or comment submitted via gh, record its URL with kind "link" and the url locator so the user can reopen it later.
|
||||
|
||||
Provide exactly one locator: workspacePath, managedId, or url. Do not use the old "path" field. Use the Artifact tool, not record_artifact, for published interactive HTML artifacts.
|
||||
|
||||
For workspace files, workspacePath must be relative to the current execution directory (for example "report.csv" or "reports/summary.html") or an absolute path inside the bound workspace. Do not add workspace folder prefixes such as "w/agent/", and do not use ".." to walk up from a worktree. This tool resolves the file, verifies it exists as a regular file inside the workspace, then stores a workspace-root-relative canonical workspacePath. A successful result includes status=available, the canonical workspacePath, and resolvedPath. If verification fails, the tool returns an error — do not tell the user the artifact can be opened or downloaded.`;
|
||||
For workspace files, workspacePath must be relative to the current execution directory (for example "report.csv" or "reports/summary.html") or an absolute path inside the bound workspace. Do not add workspace folder prefixes such as "w/agent/", and do not use ".." to walk up from a worktree. This tool resolves the path and verifies it stays inside the workspace. A regular file is stored as one workspace-root-relative canonical workspacePath. A directory is never stored as an artifact; each recordable file inside it is recorded separately. Word, Excel, PowerPoint, and other office documents use kind "document". A successful file result includes status=available, the canonical workspacePath, and resolvedPath. A successful directory result lists the expanded files instead of "Recorded artifact". If verification fails, the tool returns an error — do not tell the user the artifact can be opened or downloaded.`;
|
||||
|
||||
export const ARTIFACT_TITLE_MAX_LENGTH = 200;
|
||||
export const ARTIFACT_WORKSPACE_PATH_MAX_LENGTH = 500;
|
||||
|
|
@ -54,7 +59,9 @@ type WorkspaceLocatorSuccess = {
|
|||
ok: true;
|
||||
workspacePath: string;
|
||||
resolvedPath: string;
|
||||
workspaceRoot: string;
|
||||
sizeBytes: number;
|
||||
isDirectory?: boolean;
|
||||
};
|
||||
|
||||
type WorkspaceLocatorFailure = {
|
||||
|
|
@ -98,6 +105,10 @@ class RecordArtifactInvocation extends BaseToolInvocation<
|
|||
};
|
||||
}
|
||||
|
||||
if (locator.isDirectory) {
|
||||
return this.expandDirectoryLocator(locator);
|
||||
}
|
||||
|
||||
const artifact: ToolArtifact = {
|
||||
title: this.params.title.trim(),
|
||||
kind: this.params.kind,
|
||||
|
|
@ -133,6 +144,116 @@ class RecordArtifactInvocation extends BaseToolInvocation<
|
|||
artifacts: [artifact],
|
||||
};
|
||||
}
|
||||
|
||||
private async expandDirectoryLocator(
|
||||
locator: WorkspaceLocatorSuccess,
|
||||
): Promise<ToolResult> {
|
||||
if (pathHasSkippedDirectoryComponent(locator.workspacePath)) {
|
||||
const message = [
|
||||
`Failed to record artifact: "${locator.workspacePath}" is a skipped directory and cannot be recorded.`,
|
||||
WORKSPACE_PATH_HINT,
|
||||
].join('\n');
|
||||
return {
|
||||
llmContent: message,
|
||||
returnDisplay: message,
|
||||
error: {
|
||||
message,
|
||||
type: ToolErrorType.TARGET_IS_DIRECTORY,
|
||||
},
|
||||
};
|
||||
}
|
||||
let collected: {
|
||||
files: string[];
|
||||
truncated: boolean;
|
||||
depthLimited: boolean;
|
||||
unreadable: boolean;
|
||||
skippedUnrecordable: number;
|
||||
};
|
||||
try {
|
||||
collected = await collectRecordableWorkspaceFiles(
|
||||
locator.resolvedPath,
|
||||
locator.workspacePath,
|
||||
locator.workspaceRoot,
|
||||
(workspacePath) =>
|
||||
isRecordableDerivedChild(
|
||||
path.posix.basename(workspacePath),
|
||||
workspacePath,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const failure = pathInspectFailure(
|
||||
error,
|
||||
locator.resolvedPath,
|
||||
locator.workspacePath,
|
||||
`Failed to record artifact: could not inspect "${locator.workspacePath}" (${error instanceof Error ? error.message : String(error)}).`,
|
||||
);
|
||||
return {
|
||||
llmContent: failure.message,
|
||||
returnDisplay: failure.message,
|
||||
error: {
|
||||
message: failure.message,
|
||||
type: failure.type,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (collected.files.length === 0) {
|
||||
const message = [
|
||||
collected.depthLimited
|
||||
? `Failed to record artifact: "${locator.workspacePath}" is a directory whose recordable files are deeper than ${MAX_DIRECTORY_ARTIFACT_DEPTH} levels.`
|
||||
: `Failed to record artifact: "${locator.workspacePath}" is a directory with no recordable files.`,
|
||||
WORKSPACE_PATH_HINT,
|
||||
].join('\n');
|
||||
return {
|
||||
llmContent: message,
|
||||
returnDisplay: message,
|
||||
error: {
|
||||
message,
|
||||
type: ToolErrorType.TARGET_IS_DIRECTORY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (metadataExceedsBudget(this.params.metadata)) {
|
||||
const message =
|
||||
'Failed to record artifact: metadata is too large to expand a directory.';
|
||||
return {
|
||||
llmContent: message,
|
||||
returnDisplay: message,
|
||||
error: {
|
||||
message,
|
||||
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
||||
},
|
||||
};
|
||||
}
|
||||
const parentTitle = this.params.title.trim();
|
||||
const parentDescription = trimOptional(this.params.description);
|
||||
const artifacts: ToolArtifact[] = [];
|
||||
for (const workspacePath of collected.files) {
|
||||
const title = path.posix.basename(workspacePath).trim();
|
||||
const description =
|
||||
parentDescription ||
|
||||
(parentTitle && parentTitle !== title ? parentTitle : undefined);
|
||||
artifacts.push({
|
||||
title,
|
||||
storage: 'workspace',
|
||||
workspacePath,
|
||||
metadata: {
|
||||
...this.params.metadata,
|
||||
expandedFromDirectory: true,
|
||||
},
|
||||
...(description ? { description } : {}),
|
||||
});
|
||||
}
|
||||
const message = formatDirectoryExpansion(locator, {
|
||||
...collected,
|
||||
files: artifacts.map((artifact) => artifact.workspacePath!),
|
||||
});
|
||||
return {
|
||||
llmContent: message,
|
||||
returnDisplay: message,
|
||||
artifacts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class RecordArtifactTool extends BaseDeclarativeTool<
|
||||
|
|
@ -166,6 +287,7 @@ export class RecordArtifactTool extends BaseDeclarativeTool<
|
|||
'audio',
|
||||
'pdf',
|
||||
'notebook',
|
||||
'document',
|
||||
'other',
|
||||
],
|
||||
description: 'Best-effort artifact type for client rendering.',
|
||||
|
|
@ -183,7 +305,7 @@ export class RecordArtifactTool extends BaseDeclarativeTool<
|
|||
workspacePath: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Path relative to the current execution directory, or an absolute path inside the bound workspace. The tool verifies the file and stores a workspace-root-relative canonical path.',
|
||||
'Path relative to the current execution directory, or an absolute path inside the bound workspace. The tool verifies a regular file, or expands a directory into one artifact per recordable file.',
|
||||
},
|
||||
managedId: {
|
||||
type: 'string',
|
||||
|
|
@ -543,6 +665,7 @@ function isArtifactKind(kind: string): kind is ToolArtifactKind {
|
|||
kind === 'audio' ||
|
||||
kind === 'pdf' ||
|
||||
kind === 'notebook' ||
|
||||
kind === 'document' ||
|
||||
kind === 'other'
|
||||
);
|
||||
}
|
||||
|
|
@ -605,6 +728,70 @@ function formatWorkspaceSuccess(
|
|||
].join('\n');
|
||||
}
|
||||
|
||||
function formatDirectoryExpansion(
|
||||
locator: WorkspaceLocatorSuccess,
|
||||
collected: {
|
||||
files: string[];
|
||||
truncated: boolean;
|
||||
depthLimited?: boolean;
|
||||
unreadable?: boolean;
|
||||
skippedUnrecordable?: number;
|
||||
},
|
||||
): string {
|
||||
const lines = [
|
||||
`Expanded directory "${locator.workspacePath}" into ${collected.files.length} artifacts.`,
|
||||
'status: available',
|
||||
`workspacePath: ${locator.workspacePath}`,
|
||||
`resolvedPath: ${locator.resolvedPath}`,
|
||||
'files:',
|
||||
...collected.files.map((file) => `- ${file}`),
|
||||
];
|
||||
if (collected.truncated) {
|
||||
lines.push(`Recorded the first ${collected.files.length} files.`);
|
||||
}
|
||||
if (collected.depthLimited) {
|
||||
lines.push(
|
||||
`Skipped files deeper than ${MAX_DIRECTORY_ARTIFACT_DEPTH} directory levels.`,
|
||||
);
|
||||
}
|
||||
if (collected.unreadable) {
|
||||
lines.push('Skipped subdirectories that could not be read.');
|
||||
}
|
||||
if ((collected.skippedUnrecordable ?? 0) > 0) {
|
||||
lines.push(
|
||||
`Skipped ${collected.skippedUnrecordable} files whose names cannot be recorded as artifact titles.`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function metadataExceedsBudget(
|
||||
metadata: Record<string, string | number | boolean | null> | undefined,
|
||||
): boolean {
|
||||
const withMarker = { ...metadata, expandedFromDirectory: true };
|
||||
return Buffer.byteLength(JSON.stringify(withMarker), 'utf8') > 4096;
|
||||
}
|
||||
|
||||
export function isRecordableDerivedChild(
|
||||
title: string,
|
||||
workspacePath: string,
|
||||
): boolean {
|
||||
const trimmedTitle = title.trim();
|
||||
const trimmedPath = workspacePath.trim();
|
||||
if (title !== trimmedTitle || workspacePath !== trimmedPath) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
trimmedTitle.length > 0 &&
|
||||
trimmedTitle.length <= ARTIFACT_TITLE_MAX_LENGTH &&
|
||||
trimmedPath.length <= ARTIFACT_WORKSPACE_PATH_MAX_LENGTH &&
|
||||
!hasControlCharacter(trimmedTitle) &&
|
||||
!hasUnsafeDisplayPayload(trimmedTitle) &&
|
||||
!hasControlCharacter(trimmedPath) &&
|
||||
!hasUnsafeDisplayPayload(trimmedPath)
|
||||
);
|
||||
}
|
||||
|
||||
function locatorFailure(
|
||||
type: ToolErrorType,
|
||||
message: string,
|
||||
|
|
@ -777,9 +964,8 @@ async function inspectWorkspaceCandidate(
|
|||
);
|
||||
}
|
||||
|
||||
let lst;
|
||||
try {
|
||||
lst = await fs.lstat(candidate);
|
||||
await fs.lstat(candidate);
|
||||
} catch (error) {
|
||||
return pathInspectFailure(
|
||||
error,
|
||||
|
|
@ -789,13 +975,6 @@ async function inspectWorkspaceCandidate(
|
|||
);
|
||||
}
|
||||
|
||||
if (lst.isDirectory()) {
|
||||
return locatorFailure(
|
||||
ToolErrorType.TARGET_IS_DIRECTORY,
|
||||
`Failed to record artifact: "${candidate}" is a directory, not a file.\n${WORKSPACE_PATH_HINT}`,
|
||||
);
|
||||
}
|
||||
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = await fs.realpath(candidate);
|
||||
|
|
@ -826,17 +1005,28 @@ async function inspectWorkspaceCandidate(
|
|||
`Failed to record artifact: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
if (!st.isFile()) {
|
||||
if (!st.isFile() && !st.isDirectory()) {
|
||||
return locatorFailure(
|
||||
st.isDirectory()
|
||||
? ToolErrorType.TARGET_IS_DIRECTORY
|
||||
: ToolErrorType.TARGET_NOT_REGULAR_FILE,
|
||||
ToolErrorType.TARGET_NOT_REGULAR_FILE,
|
||||
`Failed to record artifact: "${resolved}" is not a regular file.\n${WORKSPACE_PATH_HINT}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (st.isDirectory() && path.resolve(resolved) === path.resolve(cwd)) {
|
||||
return locatorFailure(
|
||||
ToolErrorType.TARGET_IS_DIRECTORY,
|
||||
`Failed to record artifact: "${rawPath}" is the workspace root, which cannot be recorded as a directory artifact.`,
|
||||
);
|
||||
}
|
||||
|
||||
const workspacePath = toCanonicalWorkspaceArtifactPath(resolved, cwd);
|
||||
if (!workspacePath) {
|
||||
if (st.isDirectory()) {
|
||||
return locatorFailure(
|
||||
ToolErrorType.TARGET_IS_DIRECTORY,
|
||||
`Failed to record artifact: "${rawPath}" is the workspace root, which cannot be recorded as a directory artifact.`,
|
||||
);
|
||||
}
|
||||
return locatorFailure(
|
||||
ToolErrorType.PATH_NOT_IN_WORKSPACE,
|
||||
`Failed to record artifact: "${rawPath}" could not be converted to a workspace-root-relative path.\n${WORKSPACE_PATH_HINT}`,
|
||||
|
|
@ -861,7 +1051,9 @@ async function inspectWorkspaceCandidate(
|
|||
ok: true,
|
||||
workspacePath,
|
||||
resolvedPath: resolved,
|
||||
sizeBytes: st.size,
|
||||
workspaceRoot: root,
|
||||
sizeBytes: st.isDirectory() ? 0 : st.size,
|
||||
...(st.isDirectory() ? { isDirectory: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -464,6 +464,7 @@ export type ToolArtifactKind =
|
|||
| 'audio'
|
||||
| 'pdf'
|
||||
| 'notebook'
|
||||
| 'document'
|
||||
| 'other';
|
||||
|
||||
export type ToolResultArtifactState = 'undecided' | 'none' | 'reusable';
|
||||
|
|
|
|||
|
|
@ -531,7 +531,9 @@ describe('WriteFileTool', () => {
|
|||
['diagram.svg', 'image'],
|
||||
['photo.webp', 'image'],
|
||||
['table.csv', 'file'],
|
||||
['table.xlsx', 'file'],
|
||||
['table.xlsx', 'document'],
|
||||
['brief.docx', 'document'],
|
||||
['deck.pptx', 'document'],
|
||||
])('infers artifact kind for %s as %s', async (fileName, expectedKind) => {
|
||||
mockConfigInternal.isRecordArtifactEnabled.mockReturnValue(true);
|
||||
const filePath = path.join(rootDir, 'reports', fileName);
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import {
|
|||
hasControlCharacter,
|
||||
hasUnsafeDisplayPayload,
|
||||
} from './record-artifact.js';
|
||||
import { OFFICE_DOCUMENT_EXTENSIONS } from '../utils/workspace-artifact-directory.js';
|
||||
import { toCanonicalWorkspaceArtifactPath } from '../utils/workspace-artifact-path.js';
|
||||
|
||||
const debugLogger = createDebugLogger('WRITE_FILE');
|
||||
|
|
@ -73,8 +74,10 @@ const ARTIFACT_KIND_BY_EXTENSION = new Map<string, ToolArtifactKind>([
|
|||
['.png', 'image'],
|
||||
['.svg', 'image'],
|
||||
['.webp', 'image'],
|
||||
['.xlsx', 'file'],
|
||||
]);
|
||||
for (const ext of OFFICE_DOCUMENT_EXTENSIONS) {
|
||||
ARTIFACT_KIND_BY_EXTENSION.set(ext, 'document');
|
||||
}
|
||||
|
||||
type WorkspaceToolArtifact = ToolArtifact & {
|
||||
storage: 'workspace';
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ const artifactKindRecord = {
|
|||
audio: true,
|
||||
pdf: true,
|
||||
notebook: true,
|
||||
document: true,
|
||||
other: true,
|
||||
} satisfies Record<ToolArtifactKind, true>;
|
||||
const artifactKinds = new Set<ToolArtifactKind>(
|
||||
|
|
|
|||
169
packages/core/src/utils/workspace-artifact-directory.test.ts
Normal file
169
packages/core/src/utils/workspace-artifact-directory.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
mkdtemp,
|
||||
chmod,
|
||||
mkdir,
|
||||
realpath,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MAX_DIRECTORY_ARTIFACT_DEPTH,
|
||||
collectRecordableWorkspaceFiles,
|
||||
pathHasSkippedDirectoryComponent,
|
||||
} from './workspace-artifact-directory.js';
|
||||
|
||||
describe('collectRecordableWorkspaceFiles', () => {
|
||||
const dirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function workspace() {
|
||||
const root = await realpath(
|
||||
await mkdtemp(path.join(os.tmpdir(), 'artifact-dir-')),
|
||||
);
|
||||
dirs.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
it('skips junk directories, lock files, hidden names, and symlink entries', async () => {
|
||||
const root = await workspace();
|
||||
await mkdir(path.join(root, 'node_modules'), { recursive: true });
|
||||
await writeFile(path.join(root, 'node_modules', 'junk.txt'), 'x');
|
||||
await mkdir(path.join(root, 'keep'), { recursive: true });
|
||||
await writeFile(path.join(root, 'keep', 'report.xlsx'), 'xlsx');
|
||||
await writeFile(path.join(root, '.hidden.xlsx'), 'hidden');
|
||||
await writeFile(path.join(root, '~$lock.xlsx'), 'lock');
|
||||
await writeFile(path.join(root, 'real.txt'), 'real');
|
||||
await symlink(path.join(root, 'real.txt'), path.join(root, 'link.txt'));
|
||||
|
||||
const collected = await collectRecordableWorkspaceFiles(root, '', root);
|
||||
expect(collected.files.sort()).toEqual(['keep/report.xlsx', 'real.txt']);
|
||||
expect(collected.truncated).toBe(false);
|
||||
expect(collected.depthLimited).toBe(false);
|
||||
expect(collected.unreadable).toBe(false);
|
||||
expect(collected.skippedUnrecordable).toBe(0);
|
||||
});
|
||||
|
||||
it('counts files rejected by the recordable predicate', async () => {
|
||||
const root = await workspace();
|
||||
await writeFile(path.join(root, 'keep.txt'), 'ok');
|
||||
await writeFile(path.join(root, 'draft.txt'), 'bad');
|
||||
|
||||
const collected = await collectRecordableWorkspaceFiles(
|
||||
root,
|
||||
'',
|
||||
root,
|
||||
(relativePath) => relativePath !== 'draft.txt',
|
||||
);
|
||||
expect(collected.files).toEqual(['keep.txt']);
|
||||
expect(collected.skippedUnrecordable).toBe(1);
|
||||
});
|
||||
|
||||
it('does not treat a worktree-prefixed subdirectory as skipped', () => {
|
||||
expect(
|
||||
pathHasSkippedDirectoryComponent('.qwen/worktrees/my-feature/reports'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
pathHasSkippedDirectoryComponent(
|
||||
'.qwen/worktrees/my-feature/node_modules',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(pathHasSkippedDirectoryComponent('.qwen/skills')).toBe(true);
|
||||
});
|
||||
|
||||
it('records a regular file named after a skipped directory', async () => {
|
||||
const root = await workspace();
|
||||
await writeFile(path.join(root, 'dist'), 'plain');
|
||||
await writeFile(path.join(root, 'keep.xlsx'), 'xlsx');
|
||||
|
||||
const collected = await collectRecordableWorkspaceFiles(root, '', root);
|
||||
expect(collected.files.sort()).toEqual(['dist', 'keep.xlsx']);
|
||||
});
|
||||
|
||||
it('does not flag depth limits for an empty over-deep directory chain', async () => {
|
||||
const root = await workspace();
|
||||
await mkdir(path.join(root, 'a', 'b', 'c', 'd', 'e'), { recursive: true });
|
||||
await writeFile(path.join(root, 'shallow.xlsx'), 'xlsx');
|
||||
|
||||
const collected = await collectRecordableWorkspaceFiles(root, '', root);
|
||||
expect(collected.files).toEqual(['shallow.xlsx']);
|
||||
expect(collected.depthLimited).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag depth limits when the over-deep directory only has skipped names', async () => {
|
||||
const root = await workspace();
|
||||
const deepDir = path.join(root, 'a', 'b', 'c', 'd', 'e');
|
||||
await mkdir(deepDir, { recursive: true });
|
||||
await writeFile(path.join(deepDir, '.DS_Store'), 'junk');
|
||||
await writeFile(path.join(root, 'shallow.xlsx'), 'xlsx');
|
||||
|
||||
const collected = await collectRecordableWorkspaceFiles(root, '', root);
|
||||
expect(collected.files).toEqual(['shallow.xlsx']);
|
||||
expect(collected.depthLimited).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag depth limits for an empty directory nested below a skipped-empty parent', async () => {
|
||||
const root = await workspace();
|
||||
await mkdir(path.join(root, 'a', 'b', 'c', 'd', 'e', 'f'), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(path.join(root, 'shallow.xlsx'), 'xlsx');
|
||||
|
||||
const collected = await collectRecordableWorkspaceFiles(root, '', root);
|
||||
expect(collected.files).toEqual(['shallow.xlsx']);
|
||||
expect(collected.depthLimited).toBe(false);
|
||||
});
|
||||
|
||||
it('signals depth-limited truncation instead of silently dropping deep files', async () => {
|
||||
const root = await workspace();
|
||||
const deepParts = Array.from(
|
||||
{ length: MAX_DIRECTORY_ARTIFACT_DEPTH + 2 },
|
||||
(_, index) => `d${index}`,
|
||||
);
|
||||
const deepDir = path.join(root, ...deepParts);
|
||||
await mkdir(deepDir, { recursive: true });
|
||||
await writeFile(path.join(deepDir, 'deep.xlsx'), 'deep');
|
||||
await writeFile(path.join(root, 'shallow.xlsx'), 'shallow');
|
||||
|
||||
const collected = await collectRecordableWorkspaceFiles(root, '', root);
|
||||
expect(collected.files).toEqual(['shallow.xlsx']);
|
||||
expect(collected.truncated).toBe(false);
|
||||
expect(collected.depthLimited).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an unreadable over-depth directory as depth-limited', async () => {
|
||||
if (process.platform === 'win32' || process.getuid?.() === 0) {
|
||||
return;
|
||||
}
|
||||
const root = await workspace();
|
||||
const deepParts = Array.from(
|
||||
{ length: MAX_DIRECTORY_ARTIFACT_DEPTH + 1 },
|
||||
(_, index) => `d${index}`,
|
||||
);
|
||||
const deepDir = path.join(root, ...deepParts);
|
||||
await mkdir(deepDir, { recursive: true });
|
||||
await writeFile(path.join(root, 'shallow.xlsx'), 'shallow');
|
||||
await chmod(deepDir, 0o000);
|
||||
try {
|
||||
const collected = await collectRecordableWorkspaceFiles(root, '', root);
|
||||
expect(collected.files).toEqual(['shallow.xlsx']);
|
||||
expect(collected.depthLimited).toBe(true);
|
||||
} finally {
|
||||
await chmod(deepDir, 0o755);
|
||||
}
|
||||
});
|
||||
});
|
||||
265
packages/core/src/utils/workspace-artifact-directory.ts
Normal file
265
packages/core/src/utils/workspace-artifact-directory.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const MAX_DIRECTORY_ARTIFACT_FILES = 100;
|
||||
export const MAX_DIRECTORY_ARTIFACT_DEPTH = 4;
|
||||
|
||||
const SKIP_DIRECTORY_ARTIFACT_NAMES = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'__pycache__',
|
||||
'dist',
|
||||
'.qwen',
|
||||
]);
|
||||
|
||||
export const OFFICE_DOCUMENT_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.docm',
|
||||
'.dotx',
|
||||
'.xls',
|
||||
'.xlsx',
|
||||
'.xlsm',
|
||||
'.xlsb',
|
||||
'.ppt',
|
||||
'.pptx',
|
||||
'.pptm',
|
||||
'.odt',
|
||||
'.ods',
|
||||
'.odp',
|
||||
]);
|
||||
|
||||
export type RecordableWorkspaceWalkResult = {
|
||||
files: string[];
|
||||
truncated: boolean;
|
||||
depthLimited: boolean;
|
||||
unreadable: boolean;
|
||||
skippedUnrecordable: number;
|
||||
};
|
||||
|
||||
export function isOfficeDocumentExtension(ext: string): boolean {
|
||||
return OFFICE_DOCUMENT_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
export function shouldSkipDirectoryArtifactName(name: string): boolean {
|
||||
return (
|
||||
name.startsWith('.') ||
|
||||
name.startsWith('~$') ||
|
||||
SKIP_DIRECTORY_ARTIFACT_NAMES.has(name)
|
||||
);
|
||||
}
|
||||
|
||||
const WORKTREE_ARTIFACT_PREFIX_RE = /^\.qwen\/worktrees\/[^/]+\//;
|
||||
|
||||
/**
|
||||
* Bound-root-canonical paths from a worktree session always start with
|
||||
* `.qwen/worktrees/<slug>/`. That leading `.qwen` must not itself trip the
|
||||
* skip-directory gate for ordinary subdirectories inside the worktree.
|
||||
*/
|
||||
export function stripWorktreeArtifactPrefix(workspacePath: string): string {
|
||||
return workspacePath.replace(WORKTREE_ARTIFACT_PREFIX_RE, '');
|
||||
}
|
||||
|
||||
export function pathHasSkippedDirectoryComponent(
|
||||
workspacePath: string,
|
||||
): boolean {
|
||||
return stripWorktreeArtifactPrefix(workspacePath)
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.some((segment) => shouldSkipDirectoryArtifactName(segment));
|
||||
}
|
||||
|
||||
export async function collectRecordableWorkspaceFiles(
|
||||
absoluteDir: string,
|
||||
relativeDir: string,
|
||||
realWorkspace: string,
|
||||
isRecordable?: (relativePath: string) => boolean,
|
||||
): Promise<RecordableWorkspaceWalkResult> {
|
||||
const files: string[] = [];
|
||||
const walked = await walkRecordableWorkspaceFiles(
|
||||
absoluteDir,
|
||||
relativeDir,
|
||||
realWorkspace,
|
||||
files,
|
||||
0,
|
||||
isRecordable,
|
||||
);
|
||||
return { files, ...walked };
|
||||
}
|
||||
|
||||
async function walkRecordableWorkspaceFiles(
|
||||
absoluteDir: string,
|
||||
relativeDir: string,
|
||||
realWorkspace: string,
|
||||
files: string[],
|
||||
depth: number,
|
||||
isRecordable?: (relativePath: string) => boolean,
|
||||
): Promise<{
|
||||
truncated: boolean;
|
||||
depthLimited: boolean;
|
||||
unreadable: boolean;
|
||||
skippedUnrecordable: number;
|
||||
}> {
|
||||
if (files.length >= MAX_DIRECTORY_ARTIFACT_FILES) {
|
||||
return {
|
||||
truncated: true,
|
||||
depthLimited: false,
|
||||
unreadable: false,
|
||||
skippedUnrecordable: 0,
|
||||
};
|
||||
}
|
||||
if (depth > MAX_DIRECTORY_ARTIFACT_DEPTH) {
|
||||
return {
|
||||
truncated: false,
|
||||
depthLimited: await hasRecordableDescendant(
|
||||
absoluteDir,
|
||||
relativeDir,
|
||||
8,
|
||||
isRecordable,
|
||||
),
|
||||
unreadable: false,
|
||||
skippedUnrecordable: 0,
|
||||
};
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(absoluteDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (depth === 0) {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
truncated: false,
|
||||
depthLimited: false,
|
||||
unreadable: true,
|
||||
skippedUnrecordable: 0,
|
||||
};
|
||||
}
|
||||
entries.sort((left, right) =>
|
||||
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
|
||||
);
|
||||
let truncated = false;
|
||||
let depthLimited = false;
|
||||
let unreadable = false;
|
||||
let skippedUnrecordable = 0;
|
||||
for (const entry of entries) {
|
||||
if (files.length >= MAX_DIRECTORY_ARTIFACT_FILES) {
|
||||
return { truncated: true, depthLimited, unreadable, skippedUnrecordable };
|
||||
}
|
||||
if (entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
const relativePath = relativeDir
|
||||
? `${relativeDir}/${entry.name}`
|
||||
: entry.name;
|
||||
const absolutePath = path.join(absoluteDir, entry.name);
|
||||
const relativeToWorkspace = path.relative(realWorkspace, absolutePath);
|
||||
if (!relativeToWorkspace || isOutsidePath(relativeToWorkspace)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
if (shouldSkipDirectoryArtifactName(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const nested = await walkRecordableWorkspaceFiles(
|
||||
absolutePath,
|
||||
relativePath,
|
||||
realWorkspace,
|
||||
files,
|
||||
depth + 1,
|
||||
isRecordable,
|
||||
);
|
||||
truncated ||= nested.truncated;
|
||||
depthLimited ||= nested.depthLimited;
|
||||
unreadable ||= nested.unreadable;
|
||||
skippedUnrecordable += nested.skippedUnrecordable;
|
||||
if (truncated && files.length >= MAX_DIRECTORY_ARTIFACT_FILES) {
|
||||
return {
|
||||
truncated: true,
|
||||
depthLimited,
|
||||
unreadable,
|
||||
skippedUnrecordable,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
if (entry.name.startsWith('.') || entry.name.startsWith('~$')) {
|
||||
continue;
|
||||
}
|
||||
if (isRecordable && !isRecordable(relativePath)) {
|
||||
skippedUnrecordable++;
|
||||
continue;
|
||||
}
|
||||
files.push(relativePath);
|
||||
}
|
||||
}
|
||||
return { truncated, depthLimited, unreadable, skippedUnrecordable };
|
||||
}
|
||||
|
||||
async function hasRecordableDescendant(
|
||||
absoluteDir: string,
|
||||
relativeDir: string,
|
||||
remainingDepth: number,
|
||||
isRecordable?: (relativePath: string) => boolean,
|
||||
): Promise<boolean> {
|
||||
if (remainingDepth < 0) {
|
||||
return true;
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(absoluteDir, { withFileTypes: true });
|
||||
} catch {
|
||||
// Unreadable over-depth dirs are inconclusive — disclose via depthLimited.
|
||||
return true;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
const relativePath = relativeDir
|
||||
? `${relativeDir}/${entry.name}`
|
||||
: entry.name;
|
||||
if (
|
||||
entry.isFile() &&
|
||||
!entry.name.startsWith('.') &&
|
||||
!entry.name.startsWith('~$')
|
||||
) {
|
||||
if (!isRecordable || isRecordable(relativePath)) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory() && !shouldSkipDirectoryArtifactName(entry.name)) {
|
||||
try {
|
||||
if (
|
||||
await hasRecordableDescendant(
|
||||
path.join(absoluteDir, entry.name),
|
||||
relativePath,
|
||||
remainingDepth - 1,
|
||||
isRecordable,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isOutsidePath(relative: string): boolean {
|
||||
return (
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1470,6 +1470,7 @@ export type KnownDaemonSessionArtifactKind =
|
|||
| 'audio'
|
||||
| 'pdf'
|
||||
| 'notebook'
|
||||
| 'document'
|
||||
| 'other';
|
||||
|
||||
export type DaemonSessionArtifactKind =
|
||||
|
|
|
|||
|
|
@ -1102,6 +1102,31 @@ button.treeRow:hover {
|
|||
background: var(--accent);
|
||||
}
|
||||
|
||||
.downloadRow {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.downloadButton:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.downloadButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.htmlPreview {
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
|
|
|
|||
|
|
@ -1016,6 +1016,13 @@ describe('ArtifactPanel code review artifacts', () => {
|
|||
// The regression the early `return` in the dispatch can cause: an
|
||||
// artifact WITHOUT the code_review metadata must keep reaching the
|
||||
// generic file preview, not the dedicated renderer.
|
||||
mockWorkspaceActions.stat.mockResolvedValue({
|
||||
kind: 'stat',
|
||||
path: '.qwen/reviews/review.json',
|
||||
type: 'file',
|
||||
sizeBytes: 2,
|
||||
modifiedMs: 1,
|
||||
});
|
||||
mockWorkspaceActions.readWorkspaceFile.mockResolvedValue({
|
||||
content: '{}',
|
||||
truncated: false,
|
||||
|
|
@ -2530,3 +2537,111 @@ describe('ArtifactPanel image preview tabs', () => {
|
|||
expect(download.getAttribute('download')).toBe('image.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ArtifactPanel download-only workspace artifacts', () => {
|
||||
it('renders a document artifact as download-only and does not preview it', async () => {
|
||||
mockWorkspaceActions.stat.mockResolvedValue({
|
||||
type: 'file',
|
||||
sizeBytes: 12,
|
||||
modifiedMs: 1,
|
||||
});
|
||||
mockWorkspaceActions.readWorkspaceFile.mockResolvedValue({
|
||||
content: 'PK\u0003\u0004',
|
||||
truncated: false,
|
||||
});
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
mounted.push({ root, container });
|
||||
|
||||
act(() =>
|
||||
root.render(
|
||||
artifactPanel({
|
||||
id: 'review-artifact',
|
||||
kind: 'document',
|
||||
storage: 'workspace',
|
||||
source: 'tool',
|
||||
status: 'available',
|
||||
title: 'Q3 workbook',
|
||||
workspacePath: 'reports/q3.xlsx',
|
||||
retention: 'ephemeral',
|
||||
clientRetained: false,
|
||||
createdAt: '2026-08-18T00:00:00.000Z',
|
||||
updatedAt: '2026-08-18T00:00:00.000Z',
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toMatch(/Download/i);
|
||||
expect(container.querySelector('.cm-editor')).toBeNull();
|
||||
expect(mockWorkspaceActions.readWorkspaceFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows status and disables download for a missing document artifact', async () => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
mounted.push({ root, container });
|
||||
|
||||
act(() =>
|
||||
root.render(
|
||||
artifactPanel({
|
||||
id: 'review-artifact',
|
||||
kind: 'document',
|
||||
storage: 'workspace',
|
||||
source: 'tool',
|
||||
status: 'missing',
|
||||
title: 'Q3 workbook',
|
||||
workspacePath: 'reports/q3.xlsx',
|
||||
retention: 'ephemeral',
|
||||
clientRetained: false,
|
||||
createdAt: '2026-08-18T00:00:00.000Z',
|
||||
updatedAt: '2026-08-18T00:00:00.000Z',
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toMatch(/missing/i);
|
||||
const download = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.includes('Download'),
|
||||
);
|
||||
expect(download).toBeTruthy();
|
||||
expect(download).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
it('does not read workspace bytes when stat says the path is a directory', async () => {
|
||||
mockWorkspaceActions.stat.mockResolvedValue({
|
||||
type: 'directory',
|
||||
sizeBytes: 0,
|
||||
modifiedMs: 1,
|
||||
});
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
mounted.push({ root, container });
|
||||
|
||||
act(() =>
|
||||
root.render(
|
||||
artifactPanel({
|
||||
id: 'review-artifact',
|
||||
kind: 'file',
|
||||
storage: 'workspace',
|
||||
source: 'tool',
|
||||
status: 'available',
|
||||
title: 'Legacy folder',
|
||||
workspacePath: 'exports',
|
||||
retention: 'ephemeral',
|
||||
clientRetained: false,
|
||||
createdAt: '2026-08-18T00:00:00.000Z',
|
||||
updatedAt: '2026-08-18T00:00:00.000Z',
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toMatch(/director/i);
|
||||
expect(mockWorkspaceActions.readWorkspaceFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import {
|
|||
getArtifactImageMimeType,
|
||||
getImageMimeTypeFromPath,
|
||||
getReviewDownloadMimeType,
|
||||
isDownloadOnlyWorkspaceArtifact,
|
||||
normalizePath,
|
||||
readWorkspaceFileAsBlob,
|
||||
withArtifactPreviewCsp,
|
||||
|
|
@ -2427,6 +2428,14 @@ function ArtifactDetail({
|
|||
}
|
||||
|
||||
if (canPreviewWorkspaceFile && artifact.workspacePath) {
|
||||
if (isDownloadOnlyWorkspaceArtifact(artifact)) {
|
||||
return (
|
||||
<DownloadableWorkspaceArtifact
|
||||
artifact={artifact}
|
||||
workspaceActions={workspaceActions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<WorkspaceFilePreview
|
||||
workspacePath={artifact.workspacePath}
|
||||
|
|
@ -2454,7 +2463,10 @@ function ArtifactDetail({
|
|||
{isAutomationSnapshot ? 'Automation Snapshot' : 'Artifact'}
|
||||
</div>
|
||||
<div className={styles.fieldGrid}>
|
||||
<Field label="Type" value={artifactKindLabel(artifact.kind)} />
|
||||
<Field
|
||||
label="Type"
|
||||
value={artifactKindLabel(artifact.kind, artifact.workspacePath)}
|
||||
/>
|
||||
<Field label="Storage" value={artifact.storage} />
|
||||
<Field label="Status" value={artifact.status} />
|
||||
<Field label="Source" value={artifact.source} />
|
||||
|
|
@ -2562,6 +2574,83 @@ function isMarkdownArtifact(artifact: DaemonSessionArtifact) {
|
|||
);
|
||||
}
|
||||
|
||||
function DownloadableWorkspaceArtifact({
|
||||
artifact,
|
||||
workspaceActions,
|
||||
}: {
|
||||
artifact: DaemonSessionArtifact;
|
||||
workspaceActions: ArtifactWorkspaceActions;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
const location = getArtifactLocation(artifact);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const download = async () => {
|
||||
if (!artifact.workspacePath) return;
|
||||
setDownloading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await downloadWorkspaceFile(
|
||||
workspaceActions,
|
||||
artifact.workspacePath,
|
||||
artifact.mimeType,
|
||||
() => !mountedRef.current,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (!mountedRef.current) return;
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{t('common.download')}</div>
|
||||
<div className={styles.fieldGrid}>
|
||||
<Field
|
||||
label="Type"
|
||||
value={artifactKindLabel(artifact.kind, artifact.workspacePath)}
|
||||
/>
|
||||
<Field label="Size" value={formatArtifactSize(artifact.sizeBytes)} />
|
||||
{location ? <Field label="Location" value={location} /> : null}
|
||||
{artifact.status !== 'available' && artifact.status !== 'changed' ? (
|
||||
<Field label="Status" value={artifact.status ?? 'missing'} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.downloadRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.downloadButton}
|
||||
onClick={() => {
|
||||
void download();
|
||||
}}
|
||||
disabled={
|
||||
downloading ||
|
||||
(artifact.status !== 'available' && artifact.status !== 'changed')
|
||||
}
|
||||
>
|
||||
{downloading ? t('common.downloading') : t('common.download')}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className={styles.previewError}>{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceFilePreview({
|
||||
workspacePath,
|
||||
artifactVersion,
|
||||
|
|
@ -2878,9 +2967,16 @@ function useWorkspaceFileContent({
|
|||
setError(null);
|
||||
if (previewOnly) return undefined;
|
||||
workspaceActions
|
||||
.readWorkspaceFile(workspacePath)
|
||||
.then((file) => {
|
||||
.stat(workspacePath)
|
||||
.then((stat) => {
|
||||
if (cancelled) return;
|
||||
if (stat.type === 'directory') {
|
||||
throw new Error('Directories cannot be opened as artifacts.');
|
||||
}
|
||||
return workspaceActions.readWorkspaceFile(workspacePath);
|
||||
})
|
||||
.then((file) => {
|
||||
if (cancelled || !file) return;
|
||||
setContent(file.content);
|
||||
if (file.truncated) setError(truncatedMessage);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ describe('TurnOutputs artifact downloads', () => {
|
|||
'audio',
|
||||
'pdf',
|
||||
'notebook',
|
||||
'document',
|
||||
'other',
|
||||
];
|
||||
const artifacts = kinds.map(
|
||||
|
|
@ -147,6 +148,20 @@ describe('TurnOutputs artifact downloads', () => {
|
|||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll('button')).filter(
|
||||
(button) => button.textContent?.trim() === 'Download',
|
||||
),
|
||||
).toHaveLength(3);
|
||||
|
||||
const showMore = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.includes('more artifacts'),
|
||||
);
|
||||
expect(showMore).toBeTruthy();
|
||||
act(() => {
|
||||
showMore?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll('button')).filter(
|
||||
(button) => button.textContent?.trim() === 'Download',
|
||||
|
|
@ -520,6 +535,14 @@ describe('TurnOutputs artifact downloads', () => {
|
|||
);
|
||||
});
|
||||
|
||||
const showMore = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.includes('more artifacts'),
|
||||
);
|
||||
expect(showMore).toBeTruthy();
|
||||
act(() => {
|
||||
showMore?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll('button')).filter(
|
||||
(button) => button.textContent?.trim() === 'Download',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
getWorkspaceArtifactOpenBlockReason,
|
||||
isDownloadableReviewFilePath,
|
||||
isRenderedFilePath,
|
||||
TURN_OUTPUT_VISIBLE_LIMIT,
|
||||
visibleTurnOutputs,
|
||||
type TurnOutputFileChange,
|
||||
} from './TurnOutputs';
|
||||
import {
|
||||
|
|
@ -22,6 +24,14 @@ import {
|
|||
} from 'lucide-react';
|
||||
|
||||
describe('TurnOutputs helpers', () => {
|
||||
it('caps collapsed turn outputs at three items', () => {
|
||||
const items = [1, 2, 3, 4, 5];
|
||||
expect(visibleTurnOutputs(items, false)).toEqual([1, 2, 3]);
|
||||
expect(visibleTurnOutputs(items, true)).toEqual(items);
|
||||
expect(TURN_OUTPUT_VISIBLE_LIMIT).toBe(3);
|
||||
expect(items.length - TURN_OUTPUT_VISIBLE_LIMIT).toBe(2);
|
||||
});
|
||||
|
||||
it('uses workspace cwd when matching artifact preview content', () => {
|
||||
const artifact = {
|
||||
id: 'artifact-1',
|
||||
|
|
@ -114,6 +124,7 @@ describe('TurnOutputs helpers', () => {
|
|||
['audio', FileAudioIcon],
|
||||
['pdf', FileTextIcon],
|
||||
['notebook', NotebookTabsIcon],
|
||||
['document', FileTextIcon],
|
||||
])('selects the Lucide icon for %s artifacts', (kind, icon) => {
|
||||
expect(getArtifactFormatIcon(kind)).toBe(icon);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -66,6 +66,15 @@ export const TURN_OUTPUT_KINDS: readonly TurnOutputKind[] = [
|
|||
'scheduled_task',
|
||||
];
|
||||
|
||||
export const TURN_OUTPUT_VISIBLE_LIMIT = 3;
|
||||
|
||||
export function visibleTurnOutputs<T>(
|
||||
items: readonly T[],
|
||||
expanded: boolean,
|
||||
): readonly T[] {
|
||||
return expanded ? items : items.slice(0, TURN_OUTPUT_VISIBLE_LIMIT);
|
||||
}
|
||||
|
||||
export type TurnOutputOpenRequest = (
|
||||
| {
|
||||
id: 'review';
|
||||
|
|
@ -163,6 +172,7 @@ function TurnOutputsComponent({
|
|||
const workspaceTarget = useArtifactWorkspaceTarget(workspaceCwd);
|
||||
const workspaceActions = workspaceTarget?.actions;
|
||||
const [showAllChanges, setShowAllChanges] = useState(false);
|
||||
const [showAllArtifacts, setShowAllArtifacts] = useState(false);
|
||||
if (
|
||||
changes.length === 0 &&
|
||||
artifacts.length === 0 &&
|
||||
|
|
@ -170,8 +180,10 @@ function TurnOutputsComponent({
|
|||
) {
|
||||
return null;
|
||||
}
|
||||
const visibleChanges = showAllChanges ? changes : changes.slice(0, 3);
|
||||
const remainingChanges = changes.length - 3;
|
||||
const visibleChanges = visibleTurnOutputs(changes, showAllChanges);
|
||||
const remainingChanges = changes.length - TURN_OUTPUT_VISIBLE_LIMIT;
|
||||
const visibleArtifacts = visibleTurnOutputs(artifacts, showAllArtifacts);
|
||||
const remainingArtifacts = artifacts.length - TURN_OUTPUT_VISIBLE_LIMIT;
|
||||
const totals = sumLineStats(changes);
|
||||
const openReview = (selectedPath?: string) => {
|
||||
if (onOpenRequest) {
|
||||
|
|
@ -351,7 +363,7 @@ function TurnOutputsComponent({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{artifacts.map((artifact) => (
|
||||
{visibleArtifacts.map((artifact) => (
|
||||
<ArtifactCard
|
||||
key={artifact.id}
|
||||
artifact={artifact}
|
||||
|
|
@ -374,6 +386,22 @@ function TurnOutputsComponent({
|
|||
}
|
||||
/>
|
||||
))}
|
||||
{remainingArtifacts > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMoreButton}
|
||||
onClick={() => setShowAllArtifacts((value) => !value)}
|
||||
>
|
||||
<span>
|
||||
{showAllArtifacts
|
||||
? t('turnOutputs.collapseArtifacts')
|
||||
: t('turnOutputs.showMoreArtifacts', {
|
||||
count: remainingArtifacts,
|
||||
})}
|
||||
</span>
|
||||
<ChevronIcon open={showAllArtifacts} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{scheduledTasks.map((task) => (
|
||||
<ScheduledTaskCard
|
||||
|
|
@ -487,6 +515,7 @@ const ARTIFACT_FORMAT_ICONS: Readonly<Record<string, LucideIcon>> = {
|
|||
audio: FileAudioIcon,
|
||||
pdf: FileTextIcon,
|
||||
notebook: NotebookTabsIcon,
|
||||
document: FileTextIcon,
|
||||
};
|
||||
|
||||
export function getArtifactFormatIcon(kind: string): LucideIcon | undefined {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
|
||||
import {
|
||||
artifactKindLabel,
|
||||
downloadWorkspaceFile,
|
||||
getArtifactImageMimeType,
|
||||
getArtifactTypeLabel,
|
||||
getReviewDownloadMimeType,
|
||||
isDownloadOnlyWorkspaceArtifact,
|
||||
isOfficeDocumentPath,
|
||||
normalizePath,
|
||||
readWorkspaceFileAsBlob,
|
||||
withArtifactPreviewCsp,
|
||||
|
|
@ -17,6 +20,59 @@ describe('artifactUtils', () => {
|
|||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('labels office documents from path or kind', () => {
|
||||
expect(artifactKindLabel('file', 'data/table.xlsx')).toBe('Excel');
|
||||
expect(artifactKindLabel('document', 'brief.docx')).toBe('Word');
|
||||
expect(artifactKindLabel('document', 'deck.pptx')).toBe('PowerPoint');
|
||||
expect(artifactKindLabel('document')).toBe('Document');
|
||||
expect(isOfficeDocumentPath('reports/a.XLSX')).toBe(true);
|
||||
expect(
|
||||
isDownloadOnlyWorkspaceArtifact({
|
||||
kind: 'file',
|
||||
workspacePath: 'a.xlsx',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isDownloadOnlyWorkspaceArtifact({
|
||||
kind: 'pdf',
|
||||
workspacePath: 'paper.pdf',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isDownloadOnlyWorkspaceArtifact({
|
||||
kind: 'image',
|
||||
workspacePath: 'photo.png',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isDownloadOnlyWorkspaceArtifact({
|
||||
kind: 'file',
|
||||
workspacePath: 'notes.md',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects directory stats before reading bytes', async () => {
|
||||
const readFileBytes = vi.fn();
|
||||
const statFile = vi.fn().mockResolvedValue({
|
||||
sizeBytes: 0,
|
||||
modifiedMs: 1,
|
||||
type: 'directory',
|
||||
});
|
||||
|
||||
await expect(
|
||||
readWorkspaceFileAsBlob(
|
||||
readFileBytes,
|
||||
'exports',
|
||||
'application/octet-stream',
|
||||
{
|
||||
statFile,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('Directories cannot be opened');
|
||||
expect(readFileBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves parent path segments', () => {
|
||||
expect(normalizePath('src/foo/../bar.ts')).toBe('src/bar.ts');
|
||||
expect(normalizePath('/workspace/app/../app/src/./main.ts')).toBe(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,34 @@ import type {
|
|||
} from '@qwen-code/sdk/daemon';
|
||||
import type { DaemonWorkspaceActions } from '@qwen-code/webui/daemon-react-sdk';
|
||||
|
||||
export function artifactKindLabel(kind: string): string {
|
||||
export function artifactKindLabel(
|
||||
kind: string,
|
||||
workspacePath?: string,
|
||||
): string {
|
||||
const ext = pathExtension(workspacePath);
|
||||
switch (ext) {
|
||||
case '.doc':
|
||||
case '.docx':
|
||||
case '.docm':
|
||||
case '.dotx':
|
||||
case '.odt':
|
||||
return 'Word';
|
||||
case '.xls':
|
||||
case '.xlsx':
|
||||
case '.xlsm':
|
||||
case '.xlsb':
|
||||
case '.ods':
|
||||
return 'Excel';
|
||||
case '.ppt':
|
||||
case '.pptx':
|
||||
case '.pptm':
|
||||
case '.odp':
|
||||
return 'PowerPoint';
|
||||
case '.csv':
|
||||
return 'CSV';
|
||||
default:
|
||||
break;
|
||||
}
|
||||
switch (kind) {
|
||||
case 'html':
|
||||
return 'HTML';
|
||||
|
|
@ -12,16 +39,78 @@ export function artifactKindLabel(kind: string): string {
|
|||
return 'PDF';
|
||||
case 'notebook':
|
||||
return 'Notebook';
|
||||
case 'document':
|
||||
return 'Document';
|
||||
default:
|
||||
return kind || 'artifact';
|
||||
}
|
||||
}
|
||||
|
||||
// Keep in sync with OFFICE_DOCUMENT_EXTENSIONS in
|
||||
// packages/core/src/utils/workspace-artifact-directory.ts
|
||||
const OFFICE_DOCUMENT_EXTENSIONS = new Set([
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.docm',
|
||||
'.dotx',
|
||||
'.xls',
|
||||
'.xlsx',
|
||||
'.xlsm',
|
||||
'.xlsb',
|
||||
'.ppt',
|
||||
'.pptx',
|
||||
'.pptm',
|
||||
'.odt',
|
||||
'.ods',
|
||||
'.odp',
|
||||
]);
|
||||
|
||||
const DOWNLOAD_ONLY_EXTENSIONS = new Set([
|
||||
...OFFICE_DOCUMENT_EXTENSIONS,
|
||||
'.pdf',
|
||||
'.mp4',
|
||||
'.mov',
|
||||
'.webm',
|
||||
'.mp3',
|
||||
'.wav',
|
||||
'.m4a',
|
||||
'.ogg',
|
||||
]);
|
||||
|
||||
export function isOfficeDocumentPath(workspacePath?: string): boolean {
|
||||
return OFFICE_DOCUMENT_EXTENSIONS.has(pathExtension(workspacePath));
|
||||
}
|
||||
|
||||
export function isDownloadOnlyWorkspaceArtifact(artifact: {
|
||||
kind?: string;
|
||||
workspacePath?: string;
|
||||
}): boolean {
|
||||
if (artifact.kind === 'image') {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
artifact.kind === 'document' ||
|
||||
artifact.kind === 'pdf' ||
|
||||
artifact.kind === 'video' ||
|
||||
artifact.kind === 'audio' ||
|
||||
isOfficeDocumentPath(artifact.workspacePath)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return DOWNLOAD_ONLY_EXTENSIONS.has(pathExtension(artifact.workspacePath));
|
||||
}
|
||||
|
||||
function pathExtension(workspacePath?: string): string {
|
||||
const name = (workspacePath ?? '').split(/[/\\]/).pop() ?? '';
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot >= 0 ? name.slice(dot).toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function getArtifactTypeLabel(artifact: DaemonSessionArtifact): string {
|
||||
const artifactType = artifact.metadata?.['artifactType'];
|
||||
return typeof artifactType === 'string' && artifactType
|
||||
? artifactType
|
||||
: artifactKindLabel(artifact.kind);
|
||||
: artifactKindLabel(artifact.kind, artifact.workspacePath);
|
||||
}
|
||||
|
||||
export function formatArtifactSize(sizeBytes: number | undefined): string {
|
||||
|
|
@ -85,7 +174,7 @@ export async function readWorkspaceFileAsBlob(
|
|||
options: {
|
||||
statFile: (
|
||||
filePath: string,
|
||||
) => Promise<{ sizeBytes: number; modifiedMs: number }>;
|
||||
) => Promise<{ sizeBytes: number; modifiedMs: number; type?: string }>;
|
||||
isCancelled?: () => boolean;
|
||||
maxBytes?: number;
|
||||
},
|
||||
|
|
@ -96,6 +185,9 @@ export async function readWorkspaceFileAsBlob(
|
|||
if (options.isCancelled?.()) {
|
||||
throw new Error('File loading was cancelled.');
|
||||
}
|
||||
if (initialStat.type === 'directory') {
|
||||
throw new Error('Directories cannot be opened or downloaded as artifacts.');
|
||||
}
|
||||
if (initialStat.sizeBytes > maxBytes) {
|
||||
throw new Error('File is too large to preview or download.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,152 @@ function toolGroup(id: string, tools: ACPToolCall[]): ToolGroupMessage {
|
|||
}
|
||||
|
||||
describe('turnOutputSelectors', () => {
|
||||
it('attaches expanded directory files to the recorded folder turn', () => {
|
||||
const messages = [
|
||||
userMessage('u1', 'export excel'),
|
||||
toolGroup('tg1', [
|
||||
{
|
||||
callId: 'call-1',
|
||||
toolName: 'record_artifact',
|
||||
status: 'completed',
|
||||
args: { workspacePath: 'scheduler_timeline_daily' },
|
||||
},
|
||||
]),
|
||||
];
|
||||
const artifacts = [
|
||||
{
|
||||
id: 'artifact-1',
|
||||
title: 'day1.xlsx',
|
||||
workspacePath: 'scheduler_timeline_daily/day1.xlsx',
|
||||
},
|
||||
{
|
||||
id: 'artifact-2',
|
||||
title: 'day2.xlsx',
|
||||
workspacePath: 'scheduler_timeline_daily/nested/day2.xlsx',
|
||||
},
|
||||
] as DaemonSessionArtifact[];
|
||||
|
||||
expect(getArtifactsByTurn(messages, artifacts).get('u1')).toEqual(
|
||||
artifacts,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not attach later artifacts under a previously recorded directory', () => {
|
||||
const messages = [
|
||||
userMessage('u1', 'export excel'),
|
||||
toolGroup('tg1', [
|
||||
{
|
||||
callId: 'call-1',
|
||||
toolName: 'record_artifact',
|
||||
status: 'completed',
|
||||
args: { workspacePath: 'reports' },
|
||||
},
|
||||
]),
|
||||
userMessage('u2', 'write summary'),
|
||||
toolGroup('tg2', [
|
||||
{
|
||||
callId: 'call-2',
|
||||
toolName: 'write_file',
|
||||
status: 'completed',
|
||||
args: { file_path: 'reports/summary.csv' },
|
||||
},
|
||||
]),
|
||||
];
|
||||
const expanded = {
|
||||
id: 'artifact-1',
|
||||
workspacePath: 'reports/day1.xlsx',
|
||||
toolCallId: 'call-1',
|
||||
};
|
||||
const later = {
|
||||
id: 'artifact-2',
|
||||
workspacePath: 'reports/summary.csv',
|
||||
toolCallId: 'call-2',
|
||||
};
|
||||
const artifacts = [expanded, later] as DaemonSessionArtifact[];
|
||||
|
||||
expect(getArtifactsByTurn(messages, artifacts).get('u1')).toEqual([
|
||||
expanded,
|
||||
]);
|
||||
expect(getArtifactsByTurn(messages, artifacts).get('u2')).toEqual([later]);
|
||||
});
|
||||
|
||||
it('ignores a failed record_artifact when grouping by directory prefix', () => {
|
||||
const messages = [
|
||||
userMessage('u1', 'export excel'),
|
||||
toolGroup('tg1', [
|
||||
{
|
||||
callId: 'call-1',
|
||||
toolName: 'record_artifact',
|
||||
status: 'failed',
|
||||
args: { workspacePath: 'reports' },
|
||||
},
|
||||
]),
|
||||
userMessage('u2', 'write summary'),
|
||||
toolGroup('tg2', [
|
||||
{
|
||||
callId: 'call-2',
|
||||
toolName: 'write_file',
|
||||
status: 'completed',
|
||||
args: { file_path: 'reports/summary.csv' },
|
||||
},
|
||||
]),
|
||||
];
|
||||
const later = {
|
||||
id: 'artifact-2',
|
||||
workspacePath: 'reports/summary.csv',
|
||||
toolCallId: 'call-2',
|
||||
} as DaemonSessionArtifact;
|
||||
|
||||
expect(getArtifactsByTurn(messages, [later]).get('u1')).toBeUndefined();
|
||||
expect(getArtifactsByTurn(messages, [later]).get('u2')).toEqual([later]);
|
||||
});
|
||||
|
||||
it('does not treat a sibling path as a recorded directory child', () => {
|
||||
const messages = [
|
||||
userMessage('u1', 'export excel'),
|
||||
toolGroup('tg1', [
|
||||
{
|
||||
callId: 'call-1',
|
||||
toolName: 'record_artifact',
|
||||
status: 'completed',
|
||||
args: { workspacePath: 'reports' },
|
||||
},
|
||||
]),
|
||||
];
|
||||
const artifacts = [
|
||||
{
|
||||
id: 'artifact-1',
|
||||
workspacePath: 'reports-old/summary.xlsx',
|
||||
},
|
||||
] as DaemonSessionArtifact[];
|
||||
|
||||
expect(getArtifactsByTurn(messages, artifacts).get('u1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('groups expanded directory children through the workspace cwd', () => {
|
||||
const messages = [
|
||||
userMessage('u1', 'export excel'),
|
||||
toolGroup('tg1', [
|
||||
{
|
||||
callId: 'call-1',
|
||||
toolName: 'record_artifact',
|
||||
status: 'completed',
|
||||
args: { workspacePath: '/workspace/project/reports' },
|
||||
},
|
||||
]),
|
||||
];
|
||||
const artifacts = [
|
||||
{
|
||||
id: 'artifact-1',
|
||||
workspacePath: 'reports/day1.xlsx',
|
||||
},
|
||||
] as DaemonSessionArtifact[];
|
||||
|
||||
expect(
|
||||
getArtifactsByTurn(messages, artifacts, '/workspace/project').get('u1'),
|
||||
).toEqual(artifacts);
|
||||
});
|
||||
|
||||
it('groups artifacts by the turn that recorded them', () => {
|
||||
const messages = [
|
||||
userMessage('u1', 'make report'),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type {
|
|||
TurnOutputFileChange,
|
||||
TurnOutputScheduledTask,
|
||||
} from './TurnOutputs';
|
||||
import { isSamePath, normalizePath } from './artifactUtils';
|
||||
import { isSamePath, normalizePath, stripWorkspacePath } from './artifactUtils';
|
||||
|
||||
function getToolCallIds(tool: ACPToolCall): string[] {
|
||||
const ids = new Set<string>();
|
||||
|
|
@ -18,6 +18,7 @@ function getToolCallIds(tool: ACPToolCall): string[] {
|
|||
|
||||
interface RecordArtifactReference {
|
||||
turnId: string;
|
||||
callId?: string;
|
||||
workspacePath?: string;
|
||||
managedId?: string;
|
||||
url?: string;
|
||||
|
|
@ -78,9 +79,13 @@ function collectRecordArtifactReferences(
|
|||
turnId: string,
|
||||
references: RecordArtifactReference[],
|
||||
) {
|
||||
if (tool.toolName.toLowerCase() === 'record_artifact') {
|
||||
if (
|
||||
tool.toolName.toLowerCase() === 'record_artifact' &&
|
||||
(!tool.status || tool.status === 'completed')
|
||||
) {
|
||||
references.push({
|
||||
turnId,
|
||||
callId: tool.callId,
|
||||
workspacePath: getStringField(tool.args, 'workspacePath'),
|
||||
managedId: getStringField(tool.args, 'managedId'),
|
||||
url: getStringField(tool.args, 'url'),
|
||||
|
|
@ -98,17 +103,28 @@ function getRecordArtifactTurnIds(
|
|||
) {
|
||||
const turnIds = new Set<string>();
|
||||
for (const reference of references) {
|
||||
if (
|
||||
reference.workspacePath &&
|
||||
artifact.workspacePath &&
|
||||
isSameWorkspacePath(
|
||||
reference.workspacePath,
|
||||
artifact.workspacePath,
|
||||
workspaceCwd,
|
||||
)
|
||||
) {
|
||||
turnIds.add(reference.turnId);
|
||||
continue;
|
||||
if (reference.workspacePath && artifact.workspacePath) {
|
||||
if (
|
||||
isSameWorkspacePath(
|
||||
reference.workspacePath,
|
||||
artifact.workspacePath,
|
||||
workspaceCwd,
|
||||
)
|
||||
) {
|
||||
turnIds.add(reference.turnId);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isSameWorkspacePathOrChild(
|
||||
reference.workspacePath,
|
||||
artifact.workspacePath,
|
||||
workspaceCwd,
|
||||
) &&
|
||||
(!artifact.toolCallId || artifact.toolCallId === reference.callId)
|
||||
) {
|
||||
turnIds.add(reference.turnId);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (reference.managedId && reference.managedId === artifact.managedId) {
|
||||
turnIds.add(reference.turnId);
|
||||
|
|
@ -496,3 +512,19 @@ function isSameWorkspacePath(
|
|||
) {
|
||||
return isSamePath(left, right, workspaceCwd);
|
||||
}
|
||||
|
||||
function isSameWorkspacePathOrChild(
|
||||
parent: string,
|
||||
child: string,
|
||||
workspaceCwd?: string,
|
||||
) {
|
||||
if (isSamePath(parent, child, workspaceCwd)) {
|
||||
return true;
|
||||
}
|
||||
const normalizedParent = stripWorkspacePath(parent, workspaceCwd);
|
||||
const normalizedChild = stripWorkspacePath(child, workspaceCwd);
|
||||
return (
|
||||
Boolean(normalizedParent) &&
|
||||
normalizedChild.startsWith(`${normalizedParent}/`)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1240,6 +1240,9 @@ const EN: Messages = {
|
|||
'turnOutputs.preview': 'Preview',
|
||||
'turnOutputs.collapseFiles': 'Collapse files',
|
||||
'turnOutputs.showMoreFiles': (v) => `Show ${v?.count ?? 0} more files`,
|
||||
'turnOutputs.collapseArtifacts': 'Collapse artifacts',
|
||||
'turnOutputs.showMoreArtifacts': (v) =>
|
||||
`Show ${v?.count ?? 0} more artifacts`,
|
||||
'turnOutputs.previousTurn': 'Previous turn',
|
||||
'turnOutputs.fileCount': (v) => `${v?.count ?? 0} files`,
|
||||
'turnOutputs.openFileTree': 'Open file tree',
|
||||
|
|
@ -4247,6 +4250,8 @@ const ZH: Messages = {
|
|||
'turnOutputs.preview': '预览',
|
||||
'turnOutputs.collapseFiles': '收起文件',
|
||||
'turnOutputs.showMoreFiles': (v) => `再展示 ${v?.count ?? 0} 个文件`,
|
||||
'turnOutputs.collapseArtifacts': '收起产物',
|
||||
'turnOutputs.showMoreArtifacts': (v) => `再展示 ${v?.count ?? 0} 个产物`,
|
||||
'turnOutputs.previousTurn': '上轮对话',
|
||||
'turnOutputs.fileCount': (v) => `${v?.count ?? 0} 个文件`,
|
||||
'turnOutputs.openFileTree': '打开文件树',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue