feat(daemon): add session artifact APIs (#5895)

* docs: add session artifacts daemon API design

* docs: tighten session artifacts design scope

* docs: frame artifacts API as complete v1 capability

* docs: address artifacts review follow-ups

* docs: clarify artifacts reset boundary

* docs: clarify batch hook artifact flow

* docs: address latest artifact design audit

* docs: tighten artifact event and store semantics

* docs: simplify artifact v1 merge policy

* docs: resolve artifact v1 review blockers

* docs: tighten artifact trust and retention semantics

* docs: close artifact v1 boundary gaps

* feat(daemon): add session artifact APIs

* fix(daemon): harden session artifact semantics

* fix(sdk): update daemon browser bundle budget

* fix(daemon): tighten artifact ingestion boundaries

* fix(daemon): cache artifact workspace realpath

* fix(daemon): sanitize artifact add dispatch input

* docs(daemon): align artifact change wire shape

* fix(daemon): harden artifact status validation

* test(daemon): cover artifact acp dispatch

* test(daemon): update artifact capability baseline

* fix(daemon): clear workspace locator on published artifacts

* fix(core): forward post-tool batch artifacts

* fix(daemon): harden artifact status refresh

* fix(daemon): guard artifact event ingestion

* test(daemon): cover non-strict artifact drops

* fix(core): align artifact display validation

* fix(daemon): serialize artifact store operations

* chore(daemon): clarify artifact publisher tool name

* fix(daemon): coordinate artifact route mutations

* fix(daemon): harden artifact refresh comparison

* fix(daemon): harden artifact ingress edge cases

* fix(daemon): guard artifact rpc mutations during archive

* fix(daemon): gate session metadata mutation auth

* fix(daemon): harden artifact route boundaries

* fix(channels): compact drained group history

* fix(daemon): address artifact review findings

* fix(daemon): address artifact review follow-ups

* fix(daemon): preserve hook artifact success output

* fix(daemon): handle artifact review edge cases

* fix(daemon): address artifact review hardening

* test(daemon): cover artifact review edge cases

* fix(daemon): validate hook artifact aggregation

* fix(daemon): improve artifact ingestion diagnostics

* fix(daemon): address artifact review feedback

* fix(daemon): address artifact review feedback

* fix(daemon): harden session artifact ingress

* fix(daemon): harden artifact edge cases

* fix(daemon): tighten artifact path validation

* fix(daemon): address artifact review races

* fix(daemon): surface artifact path inspection errors

* fix(daemon): forward batch hook artifacts in ACP

* fix(daemon): clean artifact bridge metadata

* test(daemon): cover artifact store edge cases

* fix(daemon): resolve artifact file url symlinks

* fix(daemon): harden artifact ingestion paths

* fix(daemon): harden artifact review paths

* test(daemon): cover artifact tool name sync

* fix(daemon): harden artifact republish validation

* chore(daemon): remove unrelated artifact PR churn

* fix(daemon): address artifact review gaps

* test(daemon): cover artifact url rejection

* chore(daemon): drop unrelated formatting churn

* chore(daemon): update settings schema

* fix(daemon): harden artifact validation

* fix(daemon): tighten artifact event validation

* docs(core): clarify artifact env flag comment

* test(cli): align soft failure artifact expectation

* fix(daemon): address artifact review edge cases

* fix(daemon): enable artifact metadata recording

* fix(daemon): harden artifact store review paths

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
ChiGao 2026-07-03 16:58:43 +08:00 committed by GitHub
parent da22360c25
commit 9658dccfbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 9493 additions and 89 deletions

View file

@ -503,7 +503,7 @@ LSP server configuration is done through `.lsp.json` files in your project root
| `experimental.cron` | boolean | Enable in-session cron/loop tools (`cron_create`, `cron_list`, `cron_delete`) so the model can create recurring prompts. Can be disabled via the `QWEN_CODE_DISABLE_CRON=1` environment variable. Requires restart. | `true` |
| `experimental.cronRecurringMaxAgeDays` | number | Days a recurring cron/loop job lives before auto-expiring (it fires one final time, then is deleted). Set to `0` to disable expiry so jobs run until deleted — useful for long-running daemon deployments. Can be overridden via the `QWEN_CODE_CRON_MAX_AGE_DAYS` environment variable. Requires restart. | `7` |
| `experimental.agentTeam` | boolean | Enable agent-team collaboration tools (`team_create`, `task_create`, `task_update`, `send_message`, etc.) for multi-agent coordination. Can also be enabled via `QWEN_CODE_ENABLE_AGENT_TEAM=1`. Requires restart. | `false` |
| `experimental.artifact` | boolean | Enable the Artifact tool, letting the model publish a self-contained HTML page and open it in the browser. Interactive, non-SDK sessions only. Toggle via `QWEN_CODE_ENABLE_ARTIFACT=1` / `QWEN_CODE_DISABLE_ARTIFACT=1`. Requires restart. | `false` |
| `experimental.artifact` | boolean | Enable the Artifact tool, letting the model publish a self-contained HTML page and open it in the browser. Interactive, non-SDK sessions only. `QWEN_CODE_ENABLE_ARTIFACT=1` enables metadata-only `record_artifact` for non-SDK daemon sessions and also enables the Artifact tool in interactive sessions; `QWEN_CODE_DISABLE_ARTIFACT=1` disables both. Requires restart. | `false` |
| `experimental.emitToolUseSummaries` | boolean | Generate a short LLM-based label after each tool-call batch completes. See [Tool-Use Summaries](../features/tool-use-summaries). Requires a fast model to be configured (`fastModel`); silently skipped otherwise. Can be overridden per-session with `QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0` or `=1`. | `true` |
#### mcpServers

View file

@ -247,6 +247,7 @@ describe('qwen serve — capabilities envelope', () => {
'session_prompt',
'session_cancel',
'session_events',
'session_artifacts',
'slow_client_warning',
'typed_event_schema',
'session_set_model',

View file

@ -47,6 +47,10 @@
"types": "./dist/bridgeTypes.d.ts",
"import": "./dist/bridgeTypes.js"
},
"./sessionArtifacts": {
"types": "./dist/sessionArtifacts.d.ts",
"import": "./dist/sessionArtifacts.js"
},
"./daemonEventTypes": {
"types": "./dist/daemonEventTypes.d.ts",
"import": "./dist/daemonEventTypes.js"

View file

@ -105,6 +105,84 @@ describe('createAcpSessionBridge', () => {
);
});
it('sanitizes client artifact provenance fields', async () => {
const bridge = makeBridge({
channelFactory: async () => makeChannel().channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
try {
await bridge.addSessionArtifact(
session.sessionId,
{
title: 'Client link',
url: 'https://example.com/client',
toolName: 'artifact',
hookEventName: 'PostToolUse',
toolCallId: 'call-forged',
clientId: 'forged-client',
},
{ clientId: session.clientId },
);
const snapshot = await bridge.getSessionArtifacts(session.sessionId);
expect(snapshot.artifacts).toMatchObject([
{
title: 'Client link',
source: 'client',
clientId: session.clientId,
},
]);
expect(snapshot.artifacts[0]).not.toHaveProperty('toolName');
expect(snapshot.artifacts[0]).not.toHaveProperty('hookEventName');
expect(snapshot.artifacts[0]).not.toHaveProperty('toolCallId');
} finally {
await bridge.shutdown();
}
});
it('keeps client artifacts owned by the issuing client', async () => {
const bridge = makeBridge({
channelFactory: async () => makeChannel().channel,
});
const first = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const second = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
try {
const created = await bridge.addSessionArtifact(
first.sessionId,
{
title: 'Client link',
url: 'https://example.com/client',
},
{ clientId: first.clientId },
);
const artifactId = created.changes[0]!.artifactId;
await expect(
bridge.removeSessionArtifact(first.sessionId, artifactId, {
clientId: second.clientId,
}),
).resolves.toMatchObject({ changes: [] });
await expect(
bridge.removeSessionArtifact(first.sessionId, artifactId),
).resolves.toMatchObject({ changes: [] });
await expect(
bridge.getSessionArtifacts(first.sessionId),
).resolves.toMatchObject({
artifacts: [{ id: artifactId, clientId: first.clientId }],
});
await expect(
bridge.removeSessionArtifact(first.sessionId, artifactId, {
clientId: first.clientId,
}),
).resolves.toMatchObject({
changes: [{ action: 'removed', artifactId, reason: 'explicit' }],
});
} finally {
await bridge.shutdown();
}
});
it('uses bridge telemetry for channel/session/prompt dispatch and prompt metadata injection', async () => {
const handle = makeChannel();
const operations: string[] = [];

View file

@ -100,6 +100,12 @@ import {
type PermissionAuditPublisher,
} from './permissionMediator.js';
import { PermissionForbiddenError } from './bridgeErrors.js';
import {
SessionArtifactStore,
type SessionArtifactChange,
type SessionArtifactInput,
type SessionArtifactMutationResult,
} from './sessionArtifacts.js';
const NOOP_BRIDGE_TELEMETRY: BridgeTelemetry = {
captureContext: () => undefined,
@ -241,6 +247,8 @@ interface SessionEntry {
connection: ClientSideConnection;
/** Per-session event bus drives `GET /session/:id/events`. */
events: EventBus;
/** Per-session structured artifact registry. */
artifacts: SessionArtifactStore;
/**
* Tail of the per-session prompt queue. Each new prompt chains off the
* resolved (or rejected) state of this promise so prompts run one at a
@ -1341,6 +1349,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
},
async () => await channelFactory(boundWorkspace, childEnvOverrides),
);
const sessionIds = new Set<string>();
const client = new BridgeClient(
// BfFut: ACP today carries a sessionId on every per-session
// notification / request, so the no-sessionId branch is
@ -1395,6 +1404,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// Mode A) never host a client MCP server, so the method stays
// unreachable.
opts.clientMcpSender,
(sessionId) => sessionIds.has(sessionId),
);
const connection = new ClientSideConnection(() => client, channel.stream);
@ -1412,7 +1422,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
channel,
connection,
client,
sessionIds: new Set(),
sessionIds,
pendingRestoreIds: new Set(),
sessionSpawnsInFlight: 0,
workspaceControlInFlight: 0,
@ -2255,6 +2265,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
channel: ci.channel,
connection: ci.connection,
events,
artifacts: new SessionArtifactStore({ sessionId, workspaceCwd }),
promptQueue: Promise.resolve(),
pendingPromptCount: 0,
pendingPromptList: [],
@ -2282,6 +2293,43 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return entry;
};
const publishArtifactChanges = (
entry: SessionEntry,
changes: SessionArtifactChange[],
originatorClientId?: string,
): void => {
for (const change of changes) {
entry.events.publish({
type: 'artifact_changed',
data: { sessionId: entry.sessionId, change },
...(originatorClientId ? { originatorClientId } : {}),
});
}
};
const makeClientArtifactInput = (
artifact: SessionArtifactInput,
clientId: string | undefined,
): SessionArtifactInput => {
const input: SessionArtifactInput = {
title: artifact.title,
kind: artifact.kind,
storage: artifact.storage,
description: artifact.description,
workspacePath: artifact.workspacePath,
managedId: artifact.managedId,
url: artifact.url,
mimeType: artifact.mimeType,
sizeBytes: artifact.sizeBytes,
metadata: artifact.metadata,
source: 'client',
};
if (clientId) {
input.clientId = clientId;
}
return input;
};
// A5: seed the snapshot caches from the agent's session-create response
// (`newSession` / `loadSession` / `resumeSession` all return `models` +
// `modes`). Without this the caches stay unset until the first change, so a
@ -3964,6 +4012,32 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return { displayName: entry.displayName };
},
async getSessionArtifacts(sessionId) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
return entry.artifacts.list();
},
async addSessionArtifact(sessionId, artifact, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
const clientId = resolveTrustedClientId(entry, context?.clientId);
const input = makeClientArtifactInput(artifact, clientId);
const result: SessionArtifactMutationResult =
await entry.artifacts.upsertMany([input], { strict: true });
publishArtifactChanges(entry, result.changes, clientId);
return result;
},
async removeSessionArtifact(sessionId, artifactId, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
const clientId = resolveTrustedClientId(entry, context?.clientId);
const result = await entry.artifacts.remove(artifactId, { clientId });
publishArtifactChanges(entry, result.changes, clientId);
return result;
},
listWorkspaceSessions(workspaceCwd) {
if (!path.isAbsolute(workspaceCwd)) return [];
const key =

View file

@ -32,15 +32,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fsp } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
import type {
ReadTextFileRequest,
ReadTextFileResponse,
SessionNotification,
WriteTextFileRequest,
WriteTextFileResponse,
} from '@agentclientprotocol/sdk';
import { RequestError } from '@agentclientprotocol/sdk';
import {
ClientMcpRegistrar,
ToolNames,
type ClientMcpFrame,
} from '@qwen-code/qwen-code-core';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
@ -50,6 +53,7 @@ import type { MidTurnQueueEntry } from './bridgeTypes.js';
import type { ClientMcpMessageSender } from './bridgeOptions.js';
import { CancelSentinelCollisionError } from './bridgeErrors.js';
import { CANCEL_VOTE_SENTINEL } from './permissionMediator.js';
import { SessionArtifactStore } from './sessionArtifacts.js';
/**
* Minimal-stub constructor for a `BridgeClient` whose only purpose is
@ -544,6 +548,842 @@ describe('BridgeClient — original timestamp preservation', () => {
});
});
describe('BridgeClient — artifact ingress', () => {
const noPermissionFlow = () => {
throw new Error('test: permission flow should not run');
};
it('stores tool result artifacts and publishes artifact_changed', async () => {
const workspace = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-bridge-artifacts-'),
);
try {
const sessionId = 'sess:artifacts';
const publish = vi.fn().mockReturnValue(true);
const artifactUrl = pathToFileURL(
path.join(workspace, 'dashboard.html'),
).href;
const fakeEntry = {
sessionId,
events: { publish },
artifacts: new SessionArtifactStore({
sessionId,
workspaceCwd: workspace,
}),
pendingPermissionIds: new Set<string>(),
midTurnMessageQueue: [] as MidTurnQueueEntry[],
promptActive: true,
};
const client = new BridgeClient(
((sid: string) => (sid === sessionId ? fakeEntry : undefined)) as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
);
await client.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: 'call-artifact',
status: 'completed',
content: [],
_meta: {
toolName: ToolNames.ARTIFACT,
artifacts: [
{
title: 'Dashboard',
storage: 'published',
url: artifactUrl,
managedId: 'managed-1',
},
],
},
},
} as Parameters<BridgeClient['sessionUpdate']>[0]);
expect(publish.mock.calls.map(([event]) => event.type)).toEqual([
'session_update',
'artifact_changed',
]);
expect(publish).toHaveBeenCalledWith(
expect.objectContaining({ type: 'session_update' }),
);
expect(publish).toHaveBeenCalledWith(
expect.objectContaining({
type: 'artifact_changed',
data: {
sessionId,
change: expect.objectContaining({
action: 'created',
artifact: expect.objectContaining({
title: 'Dashboard',
storage: 'published',
}),
}),
},
}),
);
await expect(fakeEntry.artifacts.list()).resolves.toMatchObject({
artifacts: [
{
title: 'Dashboard',
storage: 'published',
managedId: 'managed-1',
},
],
});
} finally {
await fsp.rm(workspace, { recursive: true, force: true });
}
});
it('strips raw tool artifacts from session updates before publishing', async () => {
const sessionId = 'sess:sanitized-artifacts';
const publish = vi.fn().mockReturnValue(true);
const upsertMany = vi.fn().mockResolvedValue({ changes: [] });
const fakeEntry = {
sessionId,
events: { publish },
artifacts: {
inputBatchLimit: () => 1,
upsertMany,
},
pendingPermissionIds: new Set<string>(),
midTurnMessageQueue: [] as MidTurnQueueEntry[],
promptActive: true,
};
const client = new BridgeClient(
((sid: string) => (sid === sessionId ? fakeEntry : undefined)) as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
);
const stderr = vi
.spyOn(process.stderr, 'write')
.mockReturnValue(true as never);
try {
await client.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: 'call-sanitize',
status: 'completed',
content: [],
_meta: {
toolName: ToolNames.ARTIFACT,
keep: 'visible',
artifacts: [
'not-an-artifact',
{ title: 'One', url: 'https://example.com/1' },
{ title: 'Two', url: 'https://example.com/2' },
],
},
},
} as Parameters<BridgeClient['sessionUpdate']>[0]);
const sessionUpdate = publish.mock.calls
.map(([event]) => event as { type: string; data: SessionNotification })
.find((event) => event.type === 'session_update');
expect(sessionUpdate).toBeDefined();
const publishedMeta = (
sessionUpdate!.data.update as { _meta?: Record<string, unknown> }
)._meta;
expect(publishedMeta).toEqual({
toolName: ToolNames.ARTIFACT,
keep: 'visible',
});
expect(upsertMany).toHaveBeenCalledWith(
[expect.objectContaining({ title: 'One' })],
{ trustedPublisher: true },
);
const logged = stderr.mock.calls.map((call) => String(call[0])).join('');
expect(logged).toContain('reason=malformed');
expect(logged).toContain('source=tool');
expect(logged).toContain('index=0');
expect(logged).toContain('artifact batch limit exceeded');
expect(logged).toContain('dropped=1');
} finally {
stderr.mockRestore();
}
});
it('stores artifacts from failed tool updates before stripping session metadata', async () => {
const sessionId = 'sess:failed-tool-artifacts';
const publish = vi.fn().mockReturnValue(true);
const upsertMany = vi.fn().mockResolvedValue({ changes: [] });
const fakeEntry = {
sessionId,
events: { publish },
artifacts: {
inputBatchLimit: () => 400,
upsertMany,
},
pendingPermissionIds: new Set<string>(),
midTurnMessageQueue: [] as MidTurnQueueEntry[],
promptActive: true,
};
const client = new BridgeClient(
((sid: string) => (sid === sessionId ? fakeEntry : undefined)) as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
);
await client.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: 'call-failed-artifact',
status: 'failed',
content: [],
_meta: {
toolName: 'record_artifact',
artifacts: [
{
title: 'Failure report',
workspacePath: 'reports/failure.html',
},
],
},
},
} as Parameters<BridgeClient['sessionUpdate']>[0]);
expect(upsertMany).toHaveBeenCalledWith(
[
expect.objectContaining({
title: 'Failure report',
workspacePath: 'reports/failure.html',
source: 'tool',
toolCallId: 'call-failed-artifact',
toolName: 'record_artifact',
}),
],
{ trustedPublisher: false },
);
const sessionUpdate = publish.mock.calls
.map(([event]) => event as { type: string; data: SessionNotification })
.find((event) => event.type === 'session_update');
expect(
(sessionUpdate?.data.update as { _meta?: Record<string, unknown> })._meta,
).toEqual({ toolName: 'record_artifact' });
});
it('does not store artifact metadata from non-tool session updates', async () => {
const sessionId = 'sess:non-tool-artifacts';
const publish = vi.fn().mockReturnValue(true);
const upsertMany = vi.fn().mockResolvedValue({ changes: [] });
const fakeEntry = {
sessionId,
events: { publish },
artifacts: {
inputBatchLimit: () => 1,
upsertMany,
},
pendingPermissionIds: new Set<string>(),
midTurnMessageQueue: [] as MidTurnQueueEntry[],
promptActive: true,
};
const client = new BridgeClient(
((sid: string) => (sid === sessionId ? fakeEntry : undefined)) as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
);
await client.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'plan_update',
_meta: {
artifacts: [{ title: 'Forged', url: 'https://example.com/forged' }],
},
},
} as unknown as Parameters<BridgeClient['sessionUpdate']>[0]);
expect(upsertMany).not.toHaveBeenCalled();
expect(publish).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'artifact_changed' }),
);
const sessionUpdate = publish.mock.calls
.map(([event]) => event as { type: string; data: SessionNotification })
.find((event) => event.type === 'session_update');
expect(
(sessionUpdate?.data.update as { _meta?: Record<string, unknown> })._meta,
).toEqual({});
});
it('ignores forged published trust markers from non-artifact tools', async () => {
const workspace = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-bridge-artifacts-'),
);
try {
const sessionId = 'sess:forged-artifacts';
const publish = vi.fn().mockReturnValue(true);
const fakeEntry = {
sessionId,
events: { publish },
artifacts: new SessionArtifactStore({
sessionId,
workspaceCwd: workspace,
}),
pendingPermissionIds: new Set<string>(),
midTurnMessageQueue: [] as MidTurnQueueEntry[],
promptActive: true,
};
const client = new BridgeClient(
((sid: string) => (sid === sessionId ? fakeEntry : undefined)) as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
);
await client.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: 'call-forged',
status: 'completed',
content: [],
_meta: {
toolName: 'record_artifact',
artifactsTrustedPublisher: true,
artifacts: [
{
title: 'Forged',
storage: 'published',
url: 'file:///tmp/forged.html',
managedId: 'managed-forged',
},
],
},
},
} as Parameters<BridgeClient['sessionUpdate']>[0]);
expect(publish).toHaveBeenCalledWith(
expect.objectContaining({ type: 'session_update' }),
);
expect(publish).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'artifact_changed' }),
);
await expect(fakeEntry.artifacts.list()).resolves.toMatchObject({
artifacts: [],
});
} finally {
await fsp.rm(workspace, { recursive: true, force: true });
}
});
it('stores hook artifact events and publishes artifact_changed', async () => {
const workspace = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-bridge-artifacts-'),
);
try {
const sessionId = 'sess:hook-artifacts';
const publish = vi.fn().mockReturnValue(true);
const fakeEntry = {
sessionId,
events: { publish },
artifacts: new SessionArtifactStore({
sessionId,
workspaceCwd: workspace,
}),
pendingPermissionIds: new Set<string>(),
midTurnMessageQueue: [] as MidTurnQueueEntry[],
promptActive: true,
};
const client = new BridgeClient(
((sid: string) => (sid === sessionId ? fakeEntry : undefined)) as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
);
await client.extNotification('qwen/notify/session/artifact-event', {
sessionId,
hookEventName: 'PostToolUse',
artifacts: [
{
title: 'Hook dashboard',
url: 'https://example.com/hook-dashboard',
},
],
});
expect(publish).toHaveBeenCalledWith(
expect.objectContaining({
type: 'artifact_changed',
data: {
sessionId,
change: expect.objectContaining({
action: 'created',
artifact: expect.objectContaining({
source: 'hook',
hookEventName: 'PostToolUse',
title: 'Hook dashboard',
}),
}),
},
}),
);
await expect(fakeEntry.artifacts.list()).resolves.toMatchObject({
artifacts: [
{
source: 'hook',
hookEventName: 'PostToolUse',
title: 'Hook dashboard',
},
],
});
} finally {
await fsp.rm(workspace, { recursive: true, force: true });
}
});
it('drops artifact events for sessions outside this bridge channel', async () => {
const ownedSessionId = 'sess:owned-artifacts';
const forgedSessionId = 'sess:forged-artifacts';
const publish = vi.fn().mockReturnValue(true);
const resolveEntry = vi.fn((sid: string | undefined) =>
sid === forgedSessionId
? {
sessionId: forgedSessionId,
events: { publish },
artifacts: {
inputBatchLimit: () => 400,
upsertMany: vi.fn().mockResolvedValue({ changes: [] }),
},
pendingPermissionIds: new Set<string>(),
midTurnMessageQueue: [] as MidTurnQueueEntry[],
}
: undefined,
);
const client = new BridgeClient(
resolveEntry as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
undefined,
undefined,
undefined,
undefined,
(sid) => sid === ownedSessionId,
);
const stderr = vi
.spyOn(process.stderr, 'write')
.mockReturnValue(true as never);
try {
await expect(
client.extNotification('qwen/notify/session/artifact-event', {
sessionId: forgedSessionId,
artifacts: [{ title: 'Forged', url: 'https://example.com/forged' }],
}),
).resolves.toBeUndefined();
expect(resolveEntry).not.toHaveBeenCalled();
expect(publish).not.toHaveBeenCalled();
const logged = stderr.mock.calls.map((call) => String(call[0])).join('');
expect(logged).toContain('reason=session_not_owned');
expect(logged).toContain(forgedSessionId);
} finally {
stderr.mockRestore();
}
});
it('drops session updates for sessions outside this bridge channel', async () => {
const ownedSessionId = 'sess:owned-session-update';
const forgedSessionId = 'sess:forged-session-update';
const publish = vi.fn().mockReturnValue(true);
const upsertMany = vi.fn().mockResolvedValue({ changes: [] });
const resolveEntry = vi.fn((sid: string | undefined) =>
sid === forgedSessionId
? {
sessionId: forgedSessionId,
events: { publish },
artifacts: {
inputBatchLimit: () => 400,
upsertMany,
},
pendingPermissionIds: new Set<string>(),
midTurnMessageQueue: [] as MidTurnQueueEntry[],
}
: undefined,
);
const client = new BridgeClient(
resolveEntry as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
undefined,
undefined,
undefined,
undefined,
(sid) => sid === ownedSessionId,
);
const stderr = vi
.spyOn(process.stderr, 'write')
.mockReturnValue(true as never);
try {
await client.sessionUpdate({
sessionId: forgedSessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: 'call-forged-session-update',
status: 'completed',
content: [],
_meta: {
toolName: ToolNames.ARTIFACT,
artifacts: [
{
title: 'Forged',
storage: 'published',
url: 'file:///tmp/forged.html',
managedId: 'managed-forged',
},
],
},
},
} as Parameters<BridgeClient['sessionUpdate']>[0]);
expect(resolveEntry).not.toHaveBeenCalled();
expect(upsertMany).not.toHaveBeenCalled();
expect(publish).not.toHaveBeenCalled();
const logged = stderr.mock.calls.map((call) => String(call[0])).join('');
expect(logged).toContain('type=session_update');
expect(logged).toContain('reason=session_not_owned');
expect(logged).toContain(forgedSessionId);
} finally {
stderr.mockRestore();
}
});
it('allows session updates during an in-flight restore on this channel', async () => {
const sessionId = 'sess:restore-session-update';
const publish = vi.fn().mockReturnValue(true);
const client = new BridgeClient(
(() => undefined) as never,
((sid: string) => (sid === sessionId ? { publish } : undefined)) as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
undefined,
undefined,
undefined,
undefined,
() => false,
);
client.markRestoreInFlight(sessionId);
try {
await client.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'restored' },
},
} as Parameters<BridgeClient['sessionUpdate']>[0]);
expect(publish).toHaveBeenCalledWith(
expect.objectContaining({
type: 'session_update',
data: expect.objectContaining({ sessionId }),
}),
);
} finally {
client.clearRestoreInFlight(sessionId);
}
});
it('allows artifact events during an in-flight restore on this channel', async () => {
const sessionId = 'sess:restore-artifact-event';
const publish = vi.fn().mockReturnValue(true);
const upsertMany = vi.fn().mockResolvedValue({ changes: [] });
const fakeEntry = {
sessionId,
events: { publish },
artifacts: {
inputBatchLimit: () => 400,
upsertMany,
},
pendingPermissionIds: new Set<string>(),
midTurnMessageQueue: [] as MidTurnQueueEntry[],
};
const client = new BridgeClient(
((sid: string) => (sid === sessionId ? fakeEntry : undefined)) as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
undefined,
undefined,
undefined,
undefined,
() => false,
);
client.markRestoreInFlight(sessionId);
try {
await client.extNotification('qwen/notify/session/artifact-event', {
sessionId,
hookEventName: 'PostToolUse',
artifacts: [
{
title: 'Restored hook artifact',
url: 'https://example.com/restored-hook',
},
],
});
expect(upsertMany).toHaveBeenCalledWith(
[
expect.objectContaining({
source: 'hook',
hookEventName: 'PostToolUse',
title: 'Restored hook artifact',
}),
],
undefined,
);
} finally {
client.clearRestoreInFlight(sessionId);
}
});
it('logs and drops artifact events when store ingestion fails', async () => {
const sessionId = 'sess:artifact-error';
const publish = vi.fn().mockReturnValue(true);
const fakeEntry = {
sessionId,
events: { publish },
artifacts: {
inputBatchLimit: () => 400,
upsertMany: vi
.fn()
.mockRejectedValue(new Error('artifact store unavailable')),
},
pendingPermissionIds: new Set<string>(),
midTurnMessageQueue: [] as MidTurnQueueEntry[],
promptActive: true,
};
const client = new BridgeClient(
((sid: string) => (sid === sessionId ? fakeEntry : undefined)) as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
);
const stderr = vi
.spyOn(process.stderr, 'write')
.mockReturnValue(true as never);
try {
await expect(
client.extNotification('qwen/notify/session/artifact-event', {
sessionId,
artifacts: [{ title: 'Dropped', url: 'https://example.com/drop' }],
}),
).resolves.toBeUndefined();
expect(publish).not.toHaveBeenCalled();
const logged = stderr.mock.calls.map((call) => String(call[0])).join('');
expect(logged).toContain('artifact store unavailable');
expect(logged).toContain('"name":"Error"');
expect(logged).toContain('"stack":');
} finally {
stderr.mockRestore();
}
});
it('caps oversized artifact event batches before store ingestion', async () => {
const sessionId = 'sess:artifact-batch-cap';
const publish = vi.fn().mockReturnValue(true);
const upsertMany = vi.fn().mockResolvedValue({ changes: [] });
const fakeEntry = {
sessionId,
events: { publish },
artifacts: {
inputBatchLimit: () => 2,
upsertMany,
},
pendingPermissionIds: new Set<string>(),
midTurnMessageQueue: [] as MidTurnQueueEntry[],
promptActive: true,
};
const client = new BridgeClient(
((sid: string) => (sid === sessionId ? fakeEntry : undefined)) as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
);
const lateArtifact = {};
Object.defineProperty(lateArtifact, 'title', {
get: () => {
throw new Error('artifact past cap should not be mapped');
},
});
const stderr = vi
.spyOn(process.stderr, 'write')
.mockReturnValue(true as never);
try {
await client.extNotification('qwen/notify/session/artifact-event', {
sessionId,
artifacts: [
{ title: 'One', url: 'https://example.com/1' },
{ title: 'Two', url: 'https://example.com/2' },
lateArtifact,
],
});
expect(upsertMany).toHaveBeenCalledWith(
[
expect.objectContaining({ title: 'One' }),
expect.objectContaining({ title: 'Two' }),
],
undefined,
);
const logged = stderr.mock.calls.map((call) => String(call[0])).join('');
expect(logged).toContain('artifact batch limit exceeded');
expect(logged).toContain('dropped=1');
} finally {
stderr.mockRestore();
}
});
it('stores hook artifact events for child-initiated turns', async () => {
const sessionId = 'sess:child-artifacts';
const publish = vi.fn().mockReturnValue(true);
const workspace = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-bridge-artifacts-'),
);
const fakeEntry = {
sessionId,
events: { publish },
artifacts: new SessionArtifactStore({
sessionId,
workspaceCwd: workspace,
}),
pendingPermissionIds: new Set<string>(),
midTurnMessageQueue: [] as MidTurnQueueEntry[],
promptActive: false,
};
const client = new BridgeClient(
((sid: string) => (sid === sessionId ? fakeEntry : undefined)) as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
);
try {
await expect(
client.extNotification('qwen/notify/session/artifact-event', {
sessionId,
source: 'hook',
hookEventName: 'PostToolUse',
toolName: 'read_file',
toolCallId: 'call-idle',
artifacts: [{ title: 'Idle', url: 'https://example.com/idle' }],
}),
).resolves.toBeUndefined();
await expect(fakeEntry.artifacts.list()).resolves.toMatchObject({
artifacts: [
expect.objectContaining({
title: 'Idle',
source: 'hook',
hookEventName: 'PostToolUse',
toolName: 'read_file',
toolCallId: 'call-idle',
}),
],
});
expect(publish).toHaveBeenCalledWith(
expect.objectContaining({
type: 'artifact_changed',
}),
);
} finally {
await fsp.rm(workspace, { recursive: true, force: true });
}
});
it('logs and drops artifact events for unknown sessions', async () => {
const client = new BridgeClient(
(() => undefined) as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
);
const stderr = vi
.spyOn(process.stderr, 'write')
.mockReturnValue(true as never);
try {
await expect(
client.extNotification('qwen/notify/session/artifact-event', {
sessionId: 'sess:missing',
artifacts: [{ title: 'Lost', url: 'https://example.com/lost' }],
}),
).resolves.toBeUndefined();
const logged = stderr.mock.calls.map((call) => String(call[0])).join('');
expect(logged).toContain('reason=session_not_found');
expect(logged).toContain('sess:missing');
} finally {
stderr.mockRestore();
}
});
it('logs and drops malformed artifact events before resolving a session', async () => {
const publish = vi.fn().mockReturnValue(true);
const resolveEntry = vi.fn(() => ({
sessionId: 'sess:malformed',
events: { publish },
artifacts: new SessionArtifactStore({
sessionId: 'sess:malformed',
workspaceCwd: process.cwd(),
}),
pendingPermissionIds: new Set<string>(),
midTurnMessageQueue: [] as MidTurnQueueEntry[],
promptActive: true,
}));
const client = new BridgeClient(
resolveEntry as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
);
const stderr = vi
.spyOn(process.stderr, 'write')
.mockReturnValue(true as never);
try {
await expect(
client.extNotification('qwen/notify/session/artifact-event', {
artifacts: [{ title: 'Missing session' }],
}),
).resolves.toBeUndefined();
await expect(
client.extNotification('qwen/notify/session/artifact-event', {
sessionId: 'sess:malformed',
artifacts: 'not-array',
}),
).resolves.toBeUndefined();
expect(resolveEntry).not.toHaveBeenCalled();
expect(publish).not.toHaveBeenCalled();
const logged = stderr.mock.calls.map((call) => String(call[0])).join('');
expect(logged).toContain('reason=malformed');
expect(logged).toContain('session=<missing>');
expect(logged).toContain('session=sess:malformed');
} finally {
stderr.mockRestore();
}
});
});
/**
* Wenshao review #4335 / 3271978365 `requestPermission`'s pre-publish
* `CancelSentinelCollisionError` guard prevents an orphan SSE
@ -818,7 +1658,9 @@ describe('BridgeClient — reverse tool channel (qwen/control/client_mcp/message
* the serve layer). The registrar pushes outbound frames to `onFrame` so the
* test can answer them like the extension's WS would.
*/
function makeClientWithRegistrar(registrar: ClientMcpRegistrar): BridgeClient {
function makeClientWithRegistrar(
registrar: ClientMcpRegistrar,
): BridgeClient {
const sender: ClientMcpMessageSender = (serverName: string) =>
registrar.hasServer(serverName)
? (payload: unknown) =>

View file

@ -40,6 +40,15 @@ import type {
} from './permission.js';
import { CancelSentinelCollisionError } from './bridgeErrors.js';
import { writeStderrLine } from './internal/stderrLine.js';
import type {
SessionArtifactChange,
SessionArtifactInput,
SessionArtifactStore,
} from './sessionArtifacts.js';
// Keep in sync with core `ToolNames.ARTIFACT`; acp-bridge avoids a runtime
// import from core for this hot demux path.
const PUBLISH_ARTIFACT_TOOL_NAME = 'artifact';
/**
* Duck-type check for `FsError` from `cli/src/serve/fs/errors.ts`.
@ -70,6 +79,144 @@ function isFsErrorShape(err: unknown): err is FsErrorShape {
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function artifactPayloadFields(
artifact: Record<string, unknown>,
): SessionArtifactInput {
return {
title: artifact['title'] as string,
kind: artifact['kind'] as SessionArtifactInput['kind'],
storage: artifact['storage'] as SessionArtifactInput['storage'],
description: artifact['description'] as string | undefined,
workspacePath: artifact['workspacePath'] as string | undefined,
managedId: artifact['managedId'] as string | undefined,
url: artifact['url'] as string | undefined,
mimeType: artifact['mimeType'] as string | undefined,
sizeBytes: artifact['sizeBytes'] as number | undefined,
metadata: artifact['metadata'] as SessionArtifactInput['metadata'],
};
}
function extractCappedArtifactInputs(
rawArtifacts: unknown[],
limit: number,
sessionId: string,
source: 'tool' | 'hook',
toInput: (artifact: Record<string, unknown>) => SessionArtifactInput,
): SessionArtifactInput[] {
const artifacts: SessionArtifactInput[] = [];
for (let index = 0; index < rawArtifacts.length; index++) {
const artifact = rawArtifacts[index];
if (!isRecord(artifact)) {
writeStderrLine(
`[artifacts] session=${sessionId} action=dropped reason=malformed source=${source} index=${index}`,
);
continue;
}
if (artifacts.length >= limit) {
writeStderrLine(
`[artifacts] session=${sessionId} action=dropped reason="artifact batch limit exceeded" source=${source} dropped=${rawArtifacts.length - index}`,
);
break;
}
artifacts.push(toInput(artifact));
}
return artifacts;
}
function artifactIngestionErrorReason(error: unknown): unknown {
if (!(error instanceof Error)) {
return String(error);
}
return {
name: error.name,
message: error.message,
stack: error.stack?.split('\n').slice(0, 4).join('\n'),
};
}
function extractSessionUpdateArtifacts(
params: SessionNotification,
updateMeta: Record<string, unknown> | undefined,
limit: number,
sessionId: string,
): SessionArtifactInput[] {
const rawArtifacts = updateMeta?.['artifacts'];
if (!Array.isArray(rawArtifacts)) {
return [];
}
const update = params.update as {
sessionUpdate?: unknown;
status?: unknown;
toolCallId?: unknown;
};
if (
update.sessionUpdate !== 'tool_call_update' ||
(update.status !== 'completed' &&
update.status !== 'failed' &&
update.status !== 'cancelled')
) {
return [];
}
const toolCallId =
typeof update.toolCallId === 'string' ? update.toolCallId : undefined;
const toolName =
typeof updateMeta?.['toolName'] === 'string'
? updateMeta['toolName']
: undefined;
return extractCappedArtifactInputs(
rawArtifacts,
limit,
sessionId,
'tool',
(artifact) => ({
...artifactPayloadFields(artifact),
source: 'tool' as const,
toolCallId,
toolName,
}),
);
}
function sanitizeSessionUpdateArtifacts(
params: SessionNotification,
updateMeta: Record<string, unknown> | undefined,
): SessionNotification {
if (!Array.isArray(updateMeta?.['artifacts'])) {
return params;
}
const sanitizedMeta = { ...updateMeta };
delete sanitizedMeta['artifacts'];
const update = {
...(params.update as Record<string, unknown>),
_meta: sanitizedMeta,
} as SessionNotification['update'];
return {
...params,
update,
};
}
function isTrustedArtifactToolUpdate(
params: SessionNotification,
updateMeta: Record<string, unknown> | undefined,
): boolean {
const update = params.update as {
sessionUpdate?: unknown;
status?: unknown;
};
// ToolCallEmitter stamps _meta.toolName from the actual tool invocation. The
// artifact payload itself is never allowed to self-declare publisher trust.
return (
update.sessionUpdate === 'tool_call_update' &&
update.status === 'completed' &&
updateMeta?.['toolName'] === PUBLISH_ARTIFACT_TOOL_NAME
);
}
/**
* Rethrow an FsError as a structured ACP `RequestError` so the
* agent's RPC client sees `data.errorKind` / `data.hint` /
@ -207,6 +354,7 @@ function sliceLineRange(
export interface BridgeClientSessionEntry {
sessionId: string;
events: EventBus;
artifacts: SessionArtifactStore;
pendingPermissionIds: Set<string>;
/**
* Mid-turn user messages queued by the browser, drained here when the ACP
@ -215,6 +363,8 @@ export interface BridgeClientSessionEntry {
* `extMethod` can splice it. See `SessionEntry.midTurnMessageQueue`.
*/
midTurnMessageQueue: MidTurnQueueEntry[];
/** True while a prompt is executing for this session. */
promptActive?: boolean;
activePromptOriginatorClientId?: string;
/**
* True while the bridge drives a model roundtrip; the
@ -332,6 +482,7 @@ export class BridgeClient implements Client {
* `methodNotFound` (no client-hosted server can exist without it).
*/
private readonly clientMcpSender?: ClientMcpMessageSender,
private readonly ownsSession: (sessionId: string) => boolean = () => true,
) {}
async requestPermission(
@ -418,6 +569,15 @@ export class BridgeClient implements Client {
}
async sessionUpdate(params: SessionNotification): Promise<void> {
if (
!this.ownsSession(params.sessionId) &&
!this.inFlightRestoreIds.has(params.sessionId)
) {
writeStderrLine(
`[demux] session=${params.sessionId} type=session_update action=dropped reason=session_not_owned`,
);
return;
}
const entry = this.resolveEntry(params.sessionId);
const events =
entry?.events ?? this.resolvePendingRestoreEvents(params.sessionId);
@ -471,12 +631,28 @@ export class BridgeClient implements Client {
typeof originalTs === 'number' && Number.isFinite(originalTs)
? originalTs
: undefined;
const artifacts = entry?.artifacts
? extractSessionUpdateArtifacts(
params,
updateMeta,
entry.artifacts.inputBatchLimit(),
entry.sessionId,
)
: [];
const publishParams = sanitizeSessionUpdateArtifacts(params, updateMeta);
events.publish({
type: 'session_update',
data: params,
data: publishParams,
...originator,
...(serverTimestamp !== undefined ? { _meta: { serverTimestamp } } : {}),
});
if (entry) {
if (artifacts.length > 0) {
await this.upsertAndPublishArtifacts(entry, artifacts, {
trustedPublisher: isTrustedArtifactToolUpdate(params, updateMeta),
});
}
}
}
/**
@ -658,11 +834,12 @@ export class BridgeClient implements Client {
}
/**
* Handle child->bridge ACP `extNotification` calls. Six methods are
* recognized `qwen/notify/session/model-update`,
* Handle child->bridge ACP `extNotification` calls. Recognized methods are
* `qwen/notify/session/model-update`,
* `qwen/notify/session/mode-update`,
* `qwen/notify/session/title-update` (auto/in-process session titles),
* `qwen/notify/session/prompt-suggestion` (followup assist),
* `qwen/notify/session/artifact-event` (hook artifacts),
* `qwen/notify/session/terminal-sequence`, and
* `qwen/notify/session/mcp-budget-event` each translated into a
* session-scoped SSE frame. Unknown methods are dropped silently
@ -741,6 +918,10 @@ export class BridgeClient implements Client {
this.publishExtNotification(sessionId, 'terminal_sequence', rest);
return;
}
if (method === 'qwen/notify/session/artifact-event') {
await this.handleArtifactEvent(params);
return;
}
if (method !== 'qwen/notify/session/mcp-budget-event') return;
const sessionId = params['sessionId'];
if (typeof sessionId !== 'string') return;
@ -765,6 +946,91 @@ export class BridgeClient implements Client {
this.publishExtNotification(sessionId, type, rest);
}
private async handleArtifactEvent(
params: Record<string, unknown>,
): Promise<void> {
const sessionId = params['sessionId'];
const rawArtifacts = params['artifacts'];
if (typeof sessionId !== 'string' || !Array.isArray(rawArtifacts)) {
writeStderrLine(
`[demux] session=${typeof sessionId === 'string' ? sessionId : '<missing>'} type=artifact_event action=dropped reason=malformed`,
);
return;
}
if (
!this.ownsSession(sessionId) &&
!this.inFlightRestoreIds.has(sessionId)
) {
writeStderrLine(
`[demux] session=${sessionId} type=artifact_event action=dropped reason=session_not_owned`,
);
return;
}
const entry = this.resolveEntry(sessionId);
if (!entry) {
writeStderrLine(
`[demux] session=${sessionId} type=artifact_event action=dropped reason=session_not_found`,
);
return;
}
const hookEventName =
typeof params['hookEventName'] === 'string'
? params['hookEventName']
: undefined;
const toolName =
typeof params['toolName'] === 'string' ? params['toolName'] : undefined;
const toolCallId =
typeof params['toolCallId'] === 'string'
? params['toolCallId']
: undefined;
const artifacts = extractCappedArtifactInputs(
rawArtifacts,
entry.artifacts.inputBatchLimit(),
entry.sessionId,
'hook',
(artifact) => ({
...artifactPayloadFields(artifact),
source: 'hook' as const,
hookEventName,
toolName,
toolCallId,
}),
);
await this.upsertAndPublishArtifacts(entry, artifacts);
}
private async upsertAndPublishArtifacts(
entry: BridgeClientSessionEntry,
artifacts: SessionArtifactInput[],
options?: Parameters<SessionArtifactStore['upsertMany']>[1],
): Promise<void> {
try {
const result = await entry.artifacts.upsertMany(artifacts, options);
this.publishArtifactChanges(entry, result.changes);
} catch (error) {
writeStderrLine(
`[artifacts] session=${entry.sessionId} action=dropped reason=${JSON.stringify(
artifactIngestionErrorReason(error),
)}`,
);
}
}
private publishArtifactChanges(
entry: BridgeClientSessionEntry,
changes: SessionArtifactChange[],
): void {
for (const change of changes) {
entry.events.publish({
type: 'artifact_changed',
data: { sessionId: entry.sessionId, change },
...(entry.activePromptOriginatorClientId
? { originatorClientId: entry.activePromptOriginatorClientId }
: {}),
});
}
}
private publishExtNotification(
sessionId: string,
type: string,

View file

@ -17,6 +17,11 @@ import type {
} from '@agentclientprotocol/sdk';
import type { BridgeEvent, SubscribeOptions } from './eventBus.js';
import type { PermissionPolicy } from './permission.js';
import type {
SessionArtifactInput,
SessionArtifactMutationResult,
SessionArtifactsEnvelope,
} from './sessionArtifacts.js';
import type {
ServeSessionContextStatus,
ServeSessionHooksStatus,
@ -494,6 +499,33 @@ export interface AcpSessionBridge {
context?: BridgeClientRequestContext,
): SessionMetadataUpdate;
/**
* List the structured artifacts registered for a live session. Throws
* `SessionNotFoundError` when the id is unknown.
*/
getSessionArtifacts(sessionId: string): Promise<SessionArtifactsEnvelope>;
/**
* Register a client-supplied artifact for the session. Client artifacts use
* the daemon-issued client id from the request context for retention/audit;
* request bodies cannot self-assign client ids.
*/
addSessionArtifact(
sessionId: string,
artifact: SessionArtifactInput,
context?: BridgeClientRequestContext,
): Promise<SessionArtifactMutationResult>;
/**
* Remove an artifact from the session. Missing artifact ids are idempotent
* no-ops; unknown session ids still throw `SessionNotFoundError`.
*/
removeSessionArtifact(
sessionId: string,
artifactId: string,
context?: BridgeClientRequestContext,
): Promise<SessionArtifactMutationResult>;
/**
* Cast a vote on a pending `permission_request` (first-responder wins).
*/

View file

@ -12,6 +12,7 @@ export * from './permissionMediator.js';
export * from './workspacePaths.js';
export * from './status.js';
export * from './bridgeErrors.js';
export * from './sessionArtifacts.js';
export * from './bridgeTypes.js';
export * from './bridgeOptions.js';
export * from './spawnChannel.js';

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3236,6 +3236,12 @@ describe('Session', () => {
llmContent: 'nope',
returnDisplay: 'failed',
error: { message: 'tool blew up' },
artifacts: [
{
title: 'Failure artifact',
workspacePath: 'reports/failure.html',
},
],
}),
}),
};
@ -3278,6 +3284,23 @@ describe('Session', () => {
.find((ev) => ev.function_name === 'read_file');
expect(toolEvent?.status).toBe('error');
expect(toolEvent?.success).toBe(false);
expect(mockClient.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'test-session-id',
update: expect.objectContaining({
sessionUpdate: 'tool_call_update',
status: 'failed',
_meta: expect.objectContaining({
artifacts: [
{
title: 'Failure artifact',
workspacePath: 'reports/failure.html',
},
],
}),
}),
}),
);
});
});
@ -8794,10 +8817,24 @@ describe('Session', () => {
describe('PostToolUse hook', () => {
it('fires PostToolUse hook after successful tool execution', async () => {
const messageBus = {
request: vi.fn().mockResolvedValue({
success: true,
output: {},
}),
request: vi
.fn()
.mockImplementation(async (request: { eventName: string }) => ({
success: true,
output:
request.eventName === 'PostToolUse'
? {
hookSpecificOutput: {
artifacts: [
{
title: 'Success report',
workspacePath: 'reports/success.html',
},
],
},
}
: { decision: 'allow' },
})),
};
mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
@ -8855,6 +8892,22 @@ describe('Session', () => {
}),
expect.anything(),
);
expect(mockClient.extNotification).toHaveBeenCalledWith(
'qwen/notify/session/artifact-event',
expect.objectContaining({
sessionId: 'test-session-id',
source: 'hook',
hookEventName: 'PostToolUse',
toolName: 'read_file',
toolCallId: 'call-1',
artifacts: [
{
title: 'Success report',
workspacePath: 'reports/success.html',
},
],
}),
);
});
it('stops execution when PostToolUse hook returns shouldStop', async () => {
@ -8924,10 +8977,24 @@ describe('Session', () => {
describe('PostToolUseFailure hook', () => {
it('fires PostToolUseFailure hook when tool execution fails', async () => {
const messageBus = {
request: vi.fn().mockResolvedValue({
success: true,
output: {},
}),
request: vi
.fn()
.mockImplementation(async (request: { eventName: string }) => ({
success: true,
output:
request.eventName === 'PostToolUseFailure'
? {
hookSpecificOutput: {
artifacts: [
{
title: 'Failure report',
workspacePath: 'reports/failure.html',
},
],
},
}
: { decision: 'allow' },
})),
};
mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
@ -8981,6 +9048,22 @@ describe('Session', () => {
}),
expect.anything(),
);
expect(mockClient.extNotification).toHaveBeenCalledWith(
'qwen/notify/session/artifact-event',
expect.objectContaining({
sessionId: 'test-session-id',
source: 'hook',
hookEventName: 'PostToolUseFailure',
toolName: 'read_file',
toolCallId: 'call-1',
artifacts: [
{
title: 'Failure report',
workspacePath: 'reports/failure.html',
},
],
}),
);
});
});
@ -9720,6 +9803,52 @@ describe('Session', () => {
};
}
it('does not fire PostToolBatch hooks from the ACP session path', async () => {
const messageBus = {
request: vi.fn().mockImplementation(async (request) => ({
success: true,
output: { decision: 'allow', eventName: request.eventName },
})),
};
mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.hasHooksForEvent = vi
.fn()
.mockImplementation(
(eventName: string) => eventName === 'PostToolBatch',
);
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
const execute = vi.fn().mockResolvedValue({
llmContent: 'tool output',
returnDisplay: 'tool output',
});
mockToolRegistry.getTool.mockReturnValue(
mockAllowedTool('read_file', execute),
);
await (session as unknown as ToolCallInternals).runToolCalls(
new AbortController().signal,
'prompt-batch-artifacts',
[
{
id: 'read_call',
name: 'read_file',
args: { path: 'README.md' },
},
],
);
expect(
messageBus.request.mock.calls.some(
([request]) => request.eventName === 'PostToolBatch',
),
).toBe(false);
expect(mockClient.extNotification).not.toHaveBeenCalledWith(
'qwen/notify/session/artifact-event',
expect.objectContaining({ hookEventName: 'PostToolBatch' }),
);
});
it('marks cancelled ask_user_question as a turn stop', async () => {
const execute = vi.fn().mockResolvedValue({
llmContent: 'should not execute',

View file

@ -31,6 +31,7 @@ import type {
ToolCallRequestInfo,
ToolCallResponseInfo,
LoopTickResult,
ToolArtifact,
VisionBridgeResult,
} from '@qwen-code/qwen-code-core';
import {
@ -171,6 +172,7 @@ import {
import { parseAcpModelOption } from '../../utils/acpModelUtils.js';
import { classifyApiError } from '../../ui/hooks/useGeminiStream.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import {
buildExtensionMentionContext,
EXTENSION_CONTEXT_BUDGET,
@ -3978,7 +3980,6 @@ export class Session implements SessionContext {
parts.push(await recordSkippedToolCall(remainingCall, message));
}
};
// Bounded-concurrency runner: matches core's `runConcurrently`
// behaviour (`coreToolScheduler.ts:1506`), capped by
// `QWEN_CODE_MAX_TOOL_CONCURRENCY` (default 10). Results are returned
@ -5011,6 +5012,11 @@ export class Session implements SessionContext {
? 'error'
: 'success';
const succeeded = status === 'success';
const responseError = toolResult.error
? new Error(toolResult.error.message)
: aborted
? new Error('Tool execution was cancelled')
: undefined;
// Fire PostToolUse hook on successful execution (aligned with core path)
if (
@ -5053,6 +5059,12 @@ export class Session implements SessionContext {
const contextPart = { text: postHookResult.additionalContext };
responseParts.push(contextPart);
}
await this.emitHookArtifactsNotification({
hookEventName: 'PostToolUse',
toolName,
toolCallId: callId,
artifacts: postHookResult.artifacts,
});
} else if (
hooksEnabledForTool &&
messageBusForTool &&
@ -5078,6 +5090,12 @@ export class Session implements SessionContext {
`PostToolUseFailure hook additional context for ${toolName}: ${failureHookResult.additionalContext}`,
);
}
await this.emitHookArtifactsNotification({
hookEventName: 'PostToolUseFailure',
toolName,
toolCallId: callId,
artifacts: failureHookResult.artifacts,
});
}
// Handle TodoWriteTool: extract todos and send plan update
@ -5096,20 +5114,15 @@ export class Session implements SessionContext {
// Still log and return function response for LLM
} else {
// Normal tool handling: emit result using ToolCallEmitter
const error = toolResult.error
? new Error(toolResult.error.message)
: aborted
? new Error('Tool execution was cancelled')
: undefined;
await this.toolCallEmitter.emitResult({
callId,
toolName,
args,
message: responseParts,
resultDisplay: toolResult.returnDisplay,
error,
error: responseError,
success: succeeded,
artifacts: toolResult.artifacts,
});
}
@ -5185,6 +5198,12 @@ export class Session implements SessionContext {
`PostToolUseFailure hook additional context for ${toolName}: ${failureHookResult.additionalContext}`,
);
}
await this.emitHookArtifactsNotification({
hookEventName: 'PostToolUseFailure',
toolName,
toolCallId: callId,
artifacts: failureHookResult.artifacts,
});
}
// Use ToolCallEmitter for error handling
@ -5221,8 +5240,9 @@ export class Session implements SessionContext {
error,
);
const responseParts = errorResponse(error);
return {
parts: errorResponse(error),
parts: responseParts,
stopAfterPermissionCancel: nestedPermissionCancelled,
loopDetected,
};
@ -5666,6 +5686,35 @@ export class Session implements SessionContext {
}
}
private async emitHookArtifactsNotification(args: {
hookEventName: 'PostToolUse' | 'PostToolUseFailure';
toolName?: string;
toolCallId?: string;
artifacts?: ToolArtifact[];
}): Promise<void> {
if (!args.artifacts || args.artifacts.length === 0) {
return;
}
try {
await this.client.extNotification('qwen/notify/session/artifact-event', {
v: 1,
sessionId: this.sessionId,
source: 'hook',
hookEventName: args.hookEventName,
toolName: args.toolName,
toolCallId: args.toolCallId,
artifacts: args.artifacts,
});
} catch (error) {
writeStderrLine(
`Hook artifact notification dropped for ${args.toolName ?? args.hookEventName}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
/**
* Fire a notification hook and forward any terminalSequence to the ACP
* client as an extNotification. Fire-and-forget errors are logged at

View file

@ -179,6 +179,48 @@ describe('ToolCallEmitter', () => {
);
});
it('emits structured artifacts without a wire trust marker', async () => {
await emitter.emitResult({
toolName: ToolNames.ARTIFACT,
callId: 'call-artifact',
success: true,
message: createMockMessage('Published'),
artifacts: [
{
kind: 'html',
storage: 'published',
title: 'Dashboard',
url: 'file:///tmp/dashboard.html',
managedId: 'managed-1',
},
],
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-artifact',
status: 'completed',
_meta: expect.objectContaining({
toolName: ToolNames.ARTIFACT,
artifacts: [
expect.objectContaining({
title: 'Dashboard',
storage: 'published',
}),
],
}),
}),
);
expect(
(
sendUpdateSpy.mock.calls[0]?.[0] as {
_meta?: Record<string, unknown>;
}
)._meta,
).not.toHaveProperty('artifactsTrustedPublisher');
});
it('should emit tool_call_update with failed status on failure', async () => {
await emitter.emitResult({
toolName: 'test_tool',
@ -202,6 +244,35 @@ describe('ToolCallEmitter', () => {
});
});
it('emits structured artifacts from failed tool results', async () => {
await emitter.emitResult({
toolName: ToolNames.RECORD_ARTIFACT,
callId: 'call-failed-artifact',
success: false,
message: [],
error: new Error('record failed'),
artifacts: [
{ title: 'Failure report', url: 'https://example.com/drop' },
],
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-failed-artifact',
status: 'failed',
_meta: expect.objectContaining({
artifacts: [
expect.objectContaining({
title: 'Failure report',
url: 'https://example.com/drop',
}),
],
}),
}),
);
});
it('should handle diff display format', async () => {
await emitter.emitResult({
toolName: 'edit_file',

View file

@ -167,6 +167,9 @@ export class ToolCallEmitter extends BaseEmitter {
...(BaseEmitter.toEpochMs(params.timestamp) != null && {
timestamp: BaseEmitter.toEpochMs(params.timestamp),
}),
...(params.artifacts && params.artifacts.length > 0
? { artifacts: params.artifacts }
: {}),
},
};

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@qwen-code/qwen-code-core';
import type { Config, ToolArtifact } from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
import type {
SessionUpdate,
@ -109,6 +109,8 @@ export interface ToolCallResultParams {
resultDisplay?: unknown;
/** Error if tool execution failed */
error?: Error;
/** Structured artifacts produced by the tool result. */
artifacts?: ToolArtifact[];
/** Original args (fallback for TodoWriteTool todos extraction) */
args?: Record<string, unknown>;
/** Optional subagent metadata */

View file

@ -3022,7 +3022,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: false,
description:
'Enable the Artifact tool (experimental). When enabled, the model can publish a self-contained HTML page as an interactive Artifact and open it in the browser. Interactive, non-SDK sessions only. Can also be enabled via QWEN_CODE_ENABLE_ARTIFACT=1, or hard-disabled via QWEN_CODE_DISABLE_ARTIFACT=1.',
'Enable the Artifact tool (experimental). When enabled, the model can publish a self-contained HTML page as an interactive Artifact and open it in the browser. Interactive, non-SDK sessions only. QWEN_CODE_ENABLE_ARTIFACT=1 enables the metadata-only record_artifact tool for non-SDK daemon sessions, and also enables the Artifact tool in interactive sessions. QWEN_CODE_DISABLE_ARTIFACT=1 hard-disables both.',
showInDialog: true,
},
emitToolUseSummaries: {

View file

@ -186,6 +186,7 @@ export default {
'toolDisplayName.SaveMemory': 'toolDisplayName.SaveMemory',
'toolDisplayName.Agent': 'toolDisplayName.Agent',
'toolDisplayName.Artifact': 'toolDisplayName.Artifact',
'toolDisplayName.RecordArtifact': 'toolDisplayName.RecordArtifact',
'toolDisplayName.Skill': 'toolDisplayName.Skill',
'toolDisplayName.EnterPlanMode': 'toolDisplayName.EnterPlanMode',
'toolDisplayName.ExitPlanMode': 'toolDisplayName.ExitPlanMode',

View file

@ -177,6 +177,7 @@ export default {
'toolDisplayName.SaveMemory': '儲存記憶',
'toolDisplayName.Agent': 'Agent',
'toolDisplayName.Artifact': '製品',
'toolDisplayName.RecordArtifact': '記錄製品',
'toolDisplayName.Skill': '技能',
'toolDisplayName.EnterPlanMode': '進入計畫模式',
'toolDisplayName.ExitPlanMode': '退出計畫模式',

View file

@ -178,6 +178,7 @@ export default {
'toolDisplayName.SaveMemory': '保存记忆',
'toolDisplayName.Agent': 'Agent',
'toolDisplayName.Artifact': '制品',
'toolDisplayName.RecordArtifact': '记录制品',
'toolDisplayName.Skill': '技能',
'toolDisplayName.EnterPlanMode': '进入计划模式',
'toolDisplayName.ExitPlanMode': '退出计划模式',

View file

@ -41,6 +41,7 @@ import {
SessionShellDisabledError,
WorkspaceMismatchError,
} from '@qwen-code/acp-bridge/bridgeErrors';
import { SessionArtifactValidationError } from '@qwen-code/acp-bridge/sessionArtifacts';
import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import { MAX_WORKSPACE_PATH_LENGTH } from '../fs/paths.js';
@ -127,6 +128,9 @@ function errMsg(err: unknown): string {
type PermissionResponse = Parameters<
HttpAcpBridge['respondToSessionPermission']
>[2];
type AddSessionArtifactInput = Parameters<
HttpAcpBridge['addSessionArtifact']
>[1];
const SESSION_SHELL_METHOD = `${QWEN_METHOD_NS}session/shell`;
const INVALID_PERMISSION_OUTCOME_ERROR =
@ -160,6 +164,9 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [
`${QWEN_METHOD_NS}session/context_usage`,
`${QWEN_METHOD_NS}session/tasks`,
`${QWEN_METHOD_NS}session/lsp`,
`${QWEN_METHOD_NS}session/artifacts`,
`${QWEN_METHOD_NS}session/artifacts/add`,
`${QWEN_METHOD_NS}session/artifacts/remove`,
// Wave 1: memory
`${QWEN_METHOD_NS}workspace/memory`,
`${QWEN_METHOD_NS}workspace/memory/write`,
@ -369,6 +376,36 @@ function parsePermissionResponse(
return response as PermissionResponse;
}
function pickSessionArtifactInput(
params: Record<string, unknown>,
): AddSessionArtifactInput {
const {
title,
kind,
storage,
description,
workspacePath,
managedId,
url,
mimeType,
sizeBytes,
metadata,
} = params;
return {
title,
kind,
storage,
description,
workspacePath,
managedId,
url,
mimeType,
sizeBytes,
metadata,
} as AddSessionArtifactInput;
}
/**
* Map a thrown error to a JSON-RPC error code + a client-safe message.
* Param-validation errors are echoed (they describe the client's own bad
@ -464,6 +501,16 @@ function toRpcError(err: unknown): {
data: { errorKind: 'client_id_required' },
};
}
if (err instanceof SessionArtifactValidationError) {
return {
code: RPC.INVALID_PARAMS,
message: err.message,
data: {
errorKind: 'artifact_validation_failed',
...(err.field ? { field: err.field } : {}),
},
};
}
const name = err instanceof Error ? err.name : '';
switch (name) {
case 'SessionArchivedError':
@ -2288,6 +2335,49 @@ export class AcpDispatcher {
return;
}
case `${QWEN_METHOD_NS}session/artifacts`: {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
const result = await this.bridge.getSessionArtifacts(sessionId);
this.replyConn(conn, id, result as unknown);
return;
}
case `${QWEN_METHOD_NS}session/artifacts/add`: {
const sessionId = String(params['sessionId'] ?? '');
await this.withMutableOwned(conn, sessionId, id, async () => {
const result = await this.bridge.addSessionArtifact(
sessionId,
pickSessionArtifactInput(params),
this.sessionCtx(conn, sessionId, loopback),
);
this.replyConn(conn, id, result as unknown);
});
return;
}
case `${QWEN_METHOD_NS}session/artifacts/remove`: {
const sessionId = String(params['sessionId'] ?? '');
await this.withMutableOwned(conn, sessionId, id, async () => {
const artifactId = String(params['artifactId'] ?? '');
if (!artifactId) {
if (id !== undefined) {
conn.sendConn(
error(id, RPC.INVALID_PARAMS, '`artifactId` is required'),
);
}
return;
}
const result = await this.bridge.removeSessionArtifact(
sessionId,
artifactId,
this.sessionCtx(conn, sessionId, loopback),
);
this.replyConn(conn, id, result as unknown);
});
return;
}
case `${QWEN_METHOD_NS}workspace/memory`: {
const result = await collectWorkspaceMemoryStatus(
this.boundWorkspace,

View file

@ -188,6 +188,7 @@ const WS_READ_METHODS = new Set([
'_qwen/session/context_usage',
'_qwen/session/tasks',
'_qwen/session/lsp',
'_qwen/session/artifacts',
'_qwen/workspace/mcp',
'_qwen/workspace/skills',
'_qwen/workspace/providers',

View file

@ -17,6 +17,7 @@ import type {
HttpAcpBridge,
} from '@qwen-code/acp-bridge/bridgeTypes';
import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
import { SessionArtifactValidationError } from '@qwen-code/acp-bridge/sessionArtifacts';
import {
CancelSentinelCollisionError,
InvalidClientIdError,
@ -353,6 +354,51 @@ class FakeBridge {
servers: [{ name: 'typescript', status: 'READY', languages: ['ts'] }],
};
}
lastAddedArtifact:
| {
sessionId: string;
artifact: Parameters<HttpAcpBridge['addSessionArtifact']>[1];
context: Parameters<HttpAcpBridge['addSessionArtifact']>[2];
}
| undefined;
lastArtifactListSessionId: string | undefined;
lastRemovedArtifact:
| {
sessionId: string;
artifactId: string;
context: Parameters<HttpAcpBridge['removeSessionArtifact']>[2];
}
| undefined;
async getSessionArtifacts(sessionId: string) {
this.lastArtifactListSessionId = sessionId;
return {
v: 1,
sessionId,
artifacts: [],
generatedAt: new Date().toISOString(),
limits: { maxArtifacts: 200 },
};
}
async addSessionArtifact(
sessionId: string,
artifact: Parameters<HttpAcpBridge['addSessionArtifact']>[1],
context: Parameters<HttpAcpBridge['addSessionArtifact']>[2],
) {
this.lastAddedArtifact = { sessionId, artifact, context };
return { v: 1, sessionId, changes: [] };
}
async removeSessionArtifact(
sessionId: string,
artifactId: string,
context: Parameters<HttpAcpBridge['removeSessionArtifact']>[2],
) {
this.lastRemovedArtifact = { sessionId, artifactId, context };
return {
v: 1,
sessionId,
changes: [{ action: 'removed' as const, artifactId, reason: 'explicit' }],
};
}
async getWorkspaceToolsStatus() {
return { v: 1, tools: [] };
}
@ -5449,6 +5495,331 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
});
});
it('_qwen/session/artifacts returns the session artifact snapshot', async () => {
const connId = await initialize();
const streamRes = openStream(connId);
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 99,
method: 'session/new',
params: {},
});
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 58,
method: '_qwen/session/artifacts',
params: { sessionId: 'sess-1' },
});
const frames = await takeFrames(await streamRes, 2);
expect(frames[1]).toMatchObject({
result: {
v: 1,
sessionId: 'sess-1',
artifacts: [],
limits: { maxArtifacts: 200 },
},
});
expect(bridge.lastArtifactListSessionId).toBe('sess-1');
});
it('_qwen/session/artifacts/add forwards only public artifact fields', async () => {
const connId = await initialize();
const streamRes = openStream(connId);
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 99,
method: 'session/new',
params: {},
});
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 58,
method: '_qwen/session/artifacts/add',
params: {
sessionId: 'sess-1',
title: 'Lineage',
kind: 'link',
storage: 'external_url',
url: 'https://example.test/lineage',
metadata: { table: 'fact_orders' },
source: 'tool',
trustedPublisher: true,
clientId: 'forged-client',
toolName: 'forged-tool',
hookEventName: 'forged-hook',
},
});
const frames = await takeFrames(await streamRes, 2);
expect(frames[1]).toMatchObject({
result: { v: 1, sessionId: 'sess-1', changes: [] },
});
expect(bridge.lastAddedArtifact?.sessionId).toBe('sess-1');
expect(bridge.lastAddedArtifact?.artifact).toMatchObject({
title: 'Lineage',
kind: 'link',
storage: 'external_url',
url: 'https://example.test/lineage',
metadata: { table: 'fact_orders' },
});
const artifact = bridge.lastAddedArtifact?.artifact as
| Record<string, unknown>
| undefined;
expect(artifact).not.toHaveProperty('sessionId');
expect(artifact).not.toHaveProperty('source');
expect(artifact).not.toHaveProperty('trustedPublisher');
expect(artifact).not.toHaveProperty('clientId');
expect(artifact).not.toHaveProperty('toolName');
expect(artifact).not.toHaveProperty('hookEventName');
});
it('_qwen/session/artifacts/add maps artifact validation errors to invalid params', async () => {
bridge.addSessionArtifact = async () => {
throw new SessionArtifactValidationError(
'url must use http or https',
'url',
);
};
const connId = await initialize();
const streamRes = openStream(connId);
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 99,
method: 'session/new',
params: {},
});
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 59,
method: '_qwen/session/artifacts/add',
params: {
sessionId: 'sess-1',
title: 'Bad URL',
url: 'file:///tmp/report.html',
},
});
const frames = await takeFrames(await streamRes, 2);
expect(frames[1]).toMatchObject({
error: {
code: -32602,
data: { errorKind: 'artifact_validation_failed', field: 'url' },
},
});
});
it('_qwen/session/artifacts/remove forwards artifact id', async () => {
const connId = await initialize();
const streamRes = openStream(connId);
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 99,
method: 'session/new',
params: {},
});
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 59,
method: '_qwen/session/artifacts/remove',
params: { sessionId: 'sess-1', artifactId: 'artifact-1' },
});
const frames = await takeFrames(await streamRes, 2);
expect(frames[1]).toMatchObject({
result: {
v: 1,
sessionId: 'sess-1',
changes: [
{
action: 'removed',
artifactId: 'artifact-1',
reason: 'explicit',
},
],
},
});
expect(bridge.lastRemovedArtifact).toMatchObject({
sessionId: 'sess-1',
artifactId: 'artifact-1',
});
});
it('_qwen/session/artifacts/remove rejects missing artifact id', async () => {
const connId = await initialize();
const streamRes = openStream(connId);
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 99,
method: 'session/new',
params: {},
});
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 60,
method: '_qwen/session/artifacts/remove',
params: { sessionId: 'sess-1' },
});
const frames = await takeFrames(await streamRes, 2);
expect(frames[1]).toMatchObject({
error: {
code: -32602,
message: '`artifactId` is required',
},
});
expect(bridge.lastRemovedArtifact).toBeUndefined();
});
it('_qwen/session/artifacts/add holds the archive gate while mutating', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440131';
await writeStoredSession(sessionId);
let addStarted!: () => void;
let releaseAdd!: () => void;
const addStartedPromise = new Promise<void>((resolve) => {
addStarted = resolve;
});
const addReleasedPromise = new Promise<void>((resolve) => {
releaseAdd = resolve;
});
bridge.addSessionArtifact = async (sessionId, artifact, context) => {
bridge.lastAddedArtifact = { sessionId, artifact, context };
addStarted();
await addReleasedPromise;
return { v: 1, sessionId, changes: [] };
};
const connId = await initialize();
const stream = await openStream(connId);
const reader = frameReader(stream);
await post(connId, {
jsonrpc: '2.0',
id: 99,
method: 'session/load',
params: { sessionId },
});
expect(await reader.next()).toMatchObject({ id: 99 });
await post(connId, {
jsonrpc: '2.0',
id: 60,
method: '_qwen/session/artifacts/add',
params: {
sessionId,
title: 'Lineage',
url: 'https://example.test/lineage',
},
});
await addStartedPromise;
await post(connId, {
jsonrpc: '2.0',
id: 61,
method: '_qwen/sessions/archive',
params: { sessionIds: [sessionId] },
});
expect(await reader.next()).toMatchObject({
id: 61,
error: {
code: -32603,
data: { errorKind: 'session_archiving', sessionId },
},
});
releaseAdd();
expect(await reader.next()).toMatchObject({
id: 60,
result: { v: 1, sessionId, changes: [] },
});
reader.close();
});
});
it('_qwen/session/artifacts/remove holds the archive gate while mutating', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440132';
await writeStoredSession(sessionId);
let removeStarted!: () => void;
let releaseRemove!: () => void;
const removeStartedPromise = new Promise<void>((resolve) => {
removeStarted = resolve;
});
const removeReleasedPromise = new Promise<void>((resolve) => {
releaseRemove = resolve;
});
bridge.removeSessionArtifact = async (
sessionId,
artifactId,
context,
) => {
bridge.lastRemovedArtifact = { sessionId, artifactId, context };
removeStarted();
await removeReleasedPromise;
return {
v: 1,
sessionId,
changes: [{ action: 'removed', artifactId, reason: 'explicit' }],
};
};
const connId = await initialize();
const stream = await openStream(connId);
const reader = frameReader(stream);
await post(connId, {
jsonrpc: '2.0',
id: 99,
method: 'session/load',
params: { sessionId },
});
expect(await reader.next()).toMatchObject({ id: 99 });
await post(connId, {
jsonrpc: '2.0',
id: 62,
method: '_qwen/session/artifacts/remove',
params: { sessionId, artifactId: 'artifact-1' },
});
await removeStartedPromise;
await post(connId, {
jsonrpc: '2.0',
id: 63,
method: '_qwen/sessions/archive',
params: { sessionIds: [sessionId] },
});
expect(await reader.next()).toMatchObject({
id: 63,
error: {
code: -32603,
data: { errorKind: 'session_archiving', sessionId },
},
});
releaseRemove();
expect(await reader.next()).toMatchObject({
id: 62,
result: {
v: 1,
sessionId,
changes: [
{
action: 'removed',
artifactId: 'artifact-1',
reason: 'explicit',
},
],
},
});
reader.close();
});
});
it('session methods reject unowned session', async () => {
const connId = await initialize();
const streamRes = openStream(connId);

View file

@ -118,3 +118,5 @@ export {
MAX_WORKSPACE_PATH_LENGTH,
canonicalizeWorkspace,
} from '@qwen-code/acp-bridge/workspacePaths';
export { SessionArtifactValidationError } from '@qwen-code/acp-bridge/sessionArtifacts';

View file

@ -44,6 +44,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
session_prompt: { since: 'v1' },
session_cancel: { since: 'v1' },
session_events: { since: 'v1' },
session_artifacts: { since: 'v1' },
// Daemon emits `slow_client_warning` synthetic frames at 75% queue
// fill and honors `?maxQueued=N` (range [16, 2048]) on
// `GET /session/:id/events`. Old daemons silently lack both — SDK

View file

@ -14,12 +14,14 @@ import {
type ApprovalMode,
type SessionArchiveState,
} from '@qwen-code/qwen-code-core';
import type { SessionArtifactInput } from '@qwen-code/acp-bridge/sessionArtifacts';
import type { Application, Request, RequestHandler, Response } from 'express';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import {
canonicalizeWorkspace,
InvalidClientIdError,
PromptQueueFullError,
SessionArtifactValidationError,
SessionShellClientRequiredError,
SessionShellDisabledError,
type AcpSessionBridge,
@ -63,6 +65,21 @@ interface RegisterSessionRoutesDeps {
languageCodes: string[];
}
function sendArtifactValidationError(res: Response, err: unknown): boolean {
if (!(err instanceof SessionArtifactValidationError)) {
return false;
}
res.status(400).json({
v: 1,
error: {
code: err.code,
message: err.message,
...(err.field ? { field: err.field } : {}),
},
});
return true;
}
export function registerSessionRoutes(
app: Application,
deps: RegisterSessionRoutesDeps,
@ -506,6 +523,99 @@ export function registerSessionRoutes(
}
});
app.get('/session/:id/artifacts', async (req, res) => {
const sessionId = requireSessionId(req, res);
if (sessionId === null) return;
try {
res.status(200).json(await bridge.getSessionArtifacts(sessionId));
} catch (err) {
sendBridgeError(res, err, {
route: 'GET /session/:id/artifacts',
sessionId,
});
}
});
app.post(
'/session/:id/artifacts',
mutate({ strict: true }),
withMutableSession(
'POST /session/:id/artifacts',
async (req, res, sessionId) => {
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
try {
const body = safeBody(req);
const artifact: SessionArtifactInput = {
title: body['title'] as SessionArtifactInput['title'],
kind: body['kind'] as SessionArtifactInput['kind'],
storage: body['storage'] as SessionArtifactInput['storage'],
description: body[
'description'
] as SessionArtifactInput['description'],
workspacePath: body[
'workspacePath'
] as SessionArtifactInput['workspacePath'],
managedId: body['managedId'] as SessionArtifactInput['managedId'],
url: body['url'] as SessionArtifactInput['url'],
mimeType: body['mimeType'] as SessionArtifactInput['mimeType'],
sizeBytes: body['sizeBytes'] as SessionArtifactInput['sizeBytes'],
metadata: body['metadata'] as SessionArtifactInput['metadata'],
};
const result = await bridge.addSessionArtifact(
sessionId,
artifact,
clientId !== undefined ? { clientId } : undefined,
);
res.status(200).json(result);
} catch (err) {
if (sendArtifactValidationError(res, err)) return;
sendBridgeError(res, err, {
route: 'POST /session/:id/artifacts',
sessionId,
});
}
},
),
);
app.delete(
'/session/:id/artifacts/:artifactId',
mutate({ strict: true }),
withMutableSession(
'DELETE /session/:id/artifacts/:artifactId',
async (req, res, sessionId) => {
const artifactId = req.params['artifactId'];
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
if (!artifactId) {
res.status(400).json({
v: 1,
error: {
code: 'VALIDATION_FAILED',
message: '`artifactId` route parameter is required',
field: 'artifactId',
},
});
return;
}
try {
const result = await bridge.removeSessionArtifact(
sessionId,
artifactId,
clientId !== undefined ? { clientId } : undefined,
);
res.status(200).json(result);
} catch (err) {
sendBridgeError(res, err, {
route: 'DELETE /session/:id/artifacts/:artifactId',
sessionId,
});
}
},
),
);
app.post(
'/session/:id/tasks/:taskId/cancel',
mutate({ strict: true }),
@ -875,6 +985,7 @@ export function registerSessionRoutes(
app.patch(
'/session/:id/metadata',
mutate({ strict: true }),
withMutableSession('PATCH /session/:id/metadata', (req, res, sessionId) => {
const body = safeBody(req);
const clientId = parseClientIdHeader(req, res);

View file

@ -64,6 +64,7 @@ import {
PermissionPolicyNotImplementedError,
PromptQueueFullError,
RestoreInProgressError,
SessionArtifactValidationError,
SessionShellClientRequiredError,
SessionShellDisabledError,
SessionBusyError,
@ -185,6 +186,7 @@ const EXPECTED_STAGE1_FEATURES = [
'session_prompt',
'session_cancel',
'session_events',
'session_artifacts',
'slow_client_warning',
'typed_event_schema',
'session_set_model',
@ -403,6 +405,9 @@ interface FakeBridgeOpts {
) => boolean;
listImpl?: (workspaceCwd: string) => BridgeSessionSummary[];
summaryImpl?: (sessionId: string) => BridgeSessionSummary;
getSessionArtifactsImpl?: AcpSessionBridge['getSessionArtifacts'];
addSessionArtifactImpl?: AcpSessionBridge['addSessionArtifact'];
removeSessionArtifactImpl?: AcpSessionBridge['removeSessionArtifact'];
workspaceMcpImpl?: () => Promise<ServeWorkspaceMcpStatus>;
workspaceMcpToolsImpl?: (
serverName: string,
@ -605,6 +610,17 @@ interface FakeBridge extends AcpSessionBridge {
}>;
listCalls: string[];
summaryCalls: string[];
sessionArtifactsCalls: string[];
addSessionArtifactCalls: Array<{
sessionId: string;
artifact: Parameters<AcpSessionBridge['addSessionArtifact']>[1];
context?: BridgeClientRequestContext;
}>;
removeSessionArtifactCalls: Array<{
sessionId: string;
artifactId: string;
context?: BridgeClientRequestContext;
}>;
workspaceMcpCalls: number;
workspaceMcpToolsCalls: string[];
workspaceMcpResourcesCalls: string[];
@ -747,6 +763,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
const sessionPermissionVotes: FakeBridge['sessionPermissionVotes'] = [];
const listCalls: string[] = [];
const summaryCalls: string[] = [];
const sessionArtifactsCalls: string[] = [];
const addSessionArtifactCalls: FakeBridge['addSessionArtifactCalls'] = [];
const removeSessionArtifactCalls: FakeBridge['removeSessionArtifactCalls'] =
[];
let workspaceMcpCalls = 0;
const workspaceMcpToolsCalls: string[] = [];
const workspaceMcpResourcesCalls: string[] = [];
@ -823,6 +843,59 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
((sessionId: string): BridgeSessionSummary => {
throw new SessionNotFoundError(sessionId);
});
const getSessionArtifactsImpl =
opts.getSessionArtifactsImpl ??
(async (sessionId: string) => ({
v: 1 as const,
sessionId,
artifacts: [],
generatedAt: '2026-01-01T00:00:00.000Z',
limits: { maxArtifacts: 200 },
}));
const addSessionArtifactImpl =
opts.addSessionArtifactImpl ??
(async (sessionId, artifact, context) => ({
v: 1 as const,
sessionId,
changes: [
{
action: 'created' as const,
artifactId: 'artifact-1',
artifact: {
id: 'artifact-1',
kind: artifact.kind ?? 'link',
storage: artifact.workspacePath ? 'workspace' : 'external_url',
source: 'client' as const,
status: 'available' as const,
title: artifact.title,
...(artifact.url ? { url: artifact.url } : {}),
...(artifact.workspacePath
? { workspacePath: artifact.workspacePath }
: {}),
clientRetained: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...(context?.clientId ? { clientId: context.clientId } : {}),
},
},
],
}));
const removeSessionArtifactImpl =
opts.removeSessionArtifactImpl ??
((sessionId, artifactId) => ({
v: 1 as const,
sessionId,
changes:
artifactId === 'missing'
? []
: [
{
action: 'removed' as const,
artifactId,
reason: 'explicit' as const,
},
],
}));
const workspaceMcpImpl =
opts.workspaceMcpImpl ??
(async () => ({
@ -1173,6 +1246,9 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
sessionPermissionVotes,
listCalls,
summaryCalls,
sessionArtifactsCalls,
addSessionArtifactCalls,
removeSessionArtifactCalls,
workspaceMcpToolsCalls,
workspaceMcpResourcesCalls,
extensionEvents,
@ -1322,6 +1398,26 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
summaryCalls.push(sessionId);
return summaryImpl(sessionId);
},
async getSessionArtifacts(sessionId) {
sessionArtifactsCalls.push(sessionId);
return getSessionArtifactsImpl(sessionId);
},
async addSessionArtifact(sessionId, artifact, context) {
addSessionArtifactCalls.push({
sessionId,
artifact,
...(context ? { context } : {}),
});
return addSessionArtifactImpl(sessionId, artifact, context);
},
async removeSessionArtifact(sessionId, artifactId, context) {
removeSessionArtifactCalls.push({
sessionId,
artifactId,
...(context ? { context } : {}),
});
return removeSessionArtifactImpl(sessionId, artifactId, context);
},
async getWorkspaceMcpStatus() {
workspaceMcpCalls += 1;
return workspaceMcpImpl();
@ -6912,6 +7008,172 @@ describe('createServeApp', () => {
});
});
describe('session artifact routes', () => {
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
const auth = (req: request.Test): request.Test =>
req
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret');
it('GET /session/:id/artifacts returns the bridge snapshot', async () => {
const bridge = fakeBridge({
getSessionArtifactsImpl: async (sessionId) => ({
v: 1,
sessionId,
artifacts: [
{
id: 'artifact-1',
kind: 'link',
storage: 'external_url',
source: 'tool',
status: 'available',
title: 'Lineage',
url: 'https://example.com/lineage',
clientRetained: false,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
},
],
generatedAt: '2026-01-01T00:00:00.000Z',
limits: { maxArtifacts: 200 },
}),
});
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(request(app).get('/session/session-A/artifacts'));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
v: 1,
sessionId: 'session-A',
artifacts: [{ id: 'artifact-1', title: 'Lineage' }],
});
expect(bridge.sessionArtifactsCalls).toEqual(['session-A']);
});
it('GET /session/:id/artifacts returns 404 for an unknown session', async () => {
const bridge = fakeBridge({
getSessionArtifactsImpl: async (sessionId) => {
throw new SessionNotFoundError(sessionId);
},
});
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(request(app).get('/session/ghost/artifacts'));
expect(res.status).toBe(404);
expect(res.body.sessionId).toBe('ghost');
expect(bridge.sessionArtifactsCalls).toEqual(['ghost']);
});
it('POST /session/:id/artifacts requires strict mutation auth', async () => {
const bridge = fakeBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
const res = await request(app)
.post('/session/session-A/artifacts')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ title: 'Lineage', url: 'https://example.com/lineage' });
expect(res.status).toBe(401);
expect(res.body.code).toBe('token_required');
expect(bridge.addSessionArtifactCalls).toHaveLength(0);
});
it('POST /session/:id/artifacts forwards body and client context', async () => {
const bridge = fakeBridge();
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(request(app).post('/session/session-A/artifacts'))
.set('X-Qwen-Client-Id', 'client-1')
.send({ title: 'Lineage', url: 'https://example.com/lineage' });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
v: 1,
sessionId: 'session-A',
changes: [
{
action: 'created',
artifact: { title: 'Lineage', clientId: 'client-1' },
},
],
});
expect(bridge.addSessionArtifactCalls).toEqual([
{
sessionId: 'session-A',
artifact: {
title: 'Lineage',
url: 'https://example.com/lineage',
},
context: { clientId: 'client-1' },
},
]);
});
it('POST /session/:id/artifacts maps artifact validation errors', async () => {
const bridge = fakeBridge({
addSessionArtifactImpl: async () => {
throw new SessionArtifactValidationError(
'url scheme is not allowed',
'url',
);
},
});
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(
request(app).post('/session/session-A/artifacts'),
).send({ title: 'Bad link', url: 'javascript:alert(1)' });
expect(res.status).toBe(400);
expect(res.body).toEqual({
v: 1,
error: {
code: 'VALIDATION_FAILED',
message: 'url scheme is not allowed',
field: 'url',
},
});
});
it('DELETE /session/:id/artifacts/:artifactId requires strict mutation auth', async () => {
const bridge = fakeBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
const res = await request(app)
.delete('/session/session-A/artifacts/artifact-1')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(401);
expect(res.body.code).toBe('token_required');
expect(bridge.removeSessionArtifactCalls).toHaveLength(0);
});
it('DELETE /session/:id/artifacts/:artifactId is idempotent for missing artifacts', async () => {
const bridge = fakeBridge();
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(
request(app).delete('/session/session-A/artifacts/missing'),
).set('X-Qwen-Client-Id', 'client-1');
expect(res.status).toBe(200);
expect(res.body).toEqual({
v: 1,
sessionId: 'session-A',
changes: [],
});
expect(bridge.removeSessionArtifactCalls).toEqual([
{
sessionId: 'session-A',
artifactId: 'missing',
context: { clientId: 'client-1' },
},
]);
});
});
describe('POST /session/:id/model', () => {
it('200 with the agent response on success', async () => {
const bridge = fakeBridge({
@ -10308,13 +10570,18 @@ describe('createServeApp', () => {
});
describe('PATCH /session/:id/metadata', () => {
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
const auth = (req: request.Test): request.Test =>
req
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret');
it('200 on successful metadata update', async () => {
const bridge = fakeBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
const res = await request(app)
.patch('/session/session-A/metadata')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ displayName: 'My Session' });
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(
request(app).patch('/session/session-A/metadata'),
).send({ displayName: 'My Session' });
expect(res.status).toBe(200);
expect(res.body).toEqual({
sessionId: 'session-A',
@ -10327,12 +10594,33 @@ describe('createServeApp', () => {
});
});
it('passes client identity context', async () => {
it('requires mutation auth before updating metadata', async () => {
const bridge = fakeBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
const res = await request(app)
const noTokenApp = createServeApp(baseOpts, undefined, { bridge });
const noToken = await request(noTokenApp)
.patch('/session/session-A/metadata')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ displayName: 'blocked' });
expect(noToken.status).toBe(401);
expect(noToken.body.code).toBe('token_required');
expect(bridge.updateMetadataCalls).toHaveLength(0);
const app = createServeApp(tokenOpts, undefined, { bridge });
const authed = await auth(
request(app).patch('/session/session-A/metadata'),
).send({ displayName: 'allowed' });
expect(authed.status).toBe(200);
expect(bridge.updateMetadataCalls).toHaveLength(1);
expect(bridge.updateMetadataCalls[0]?.metadata).toEqual({
displayName: 'allowed',
});
});
it('passes client identity context', async () => {
const bridge = fakeBridge();
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(request(app).patch('/session/session-A/metadata'))
.set('X-Qwen-Client-Id', 'client-1')
.send({ displayName: 'test' });
expect(res.status).toBe(200);
@ -10343,11 +10631,10 @@ describe('createServeApp', () => {
it('400 when displayName is not a string', async () => {
const bridge = fakeBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
const res = await request(app)
.patch('/session/session-A/metadata')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ displayName: 123 });
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(
request(app).patch('/session/session-A/metadata'),
).send({ displayName: 123 });
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_metadata');
expect(res.body.field).toBe('displayName');
@ -10359,11 +10646,10 @@ describe('createServeApp', () => {
throw new SessionNotFoundError(sessionId);
},
});
const app = createServeApp(baseOpts, undefined, { bridge });
const res = await request(app)
.patch('/session/missing/metadata')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ displayName: 'test' });
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(
request(app).patch('/session/missing/metadata'),
).send({ displayName: 'test' });
expect(res.status).toBe(404);
expect(res.body.sessionId).toBe('missing');
});
@ -10377,11 +10663,10 @@ describe('createServeApp', () => {
);
},
});
const app = createServeApp(baseOpts, undefined, { bridge });
const res = await request(app)
.patch('/session/session-A/metadata')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ displayName: 'x'.repeat(300) });
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(
request(app).patch('/session/session-A/metadata'),
).send({ displayName: 'x'.repeat(300) });
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_metadata');
});

View file

@ -53,6 +53,30 @@ export function resolveDaemonTelemetryRoute(
sessionId: sessionMetadata[1],
};
}
const sessionArtifacts = path.match(/^\/session\/([^/]+)\/artifacts$/);
if (sessionArtifacts?.[1]) {
if (req.method === 'GET') {
return {
route: 'GET /session/:id/artifacts',
sessionId: sessionArtifacts[1],
};
}
if (req.method === 'POST') {
return {
route: 'POST /session/:id/artifacts',
sessionId: sessionArtifacts[1],
};
}
}
const sessionArtifact = path.match(
/^\/session\/([^/]+)\/artifacts\/([^/]+)$/,
);
if (sessionArtifact?.[1] && req.method === 'DELETE') {
return {
route: 'DELETE /session/:id/artifacts/:artifactId',
sessionId: sessionArtifact[1],
};
}
const sessionPermission = path.match(
/^\/session\/([^/]+)\/permission\/([^/]+)$/,
);

View file

@ -145,6 +145,9 @@ export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet<string> = new Set([
// never enter or exit the user's worktree state independently.
ToolNames.ENTER_WORKTREE,
ToolNames.EXIT_WORKTREE,
// V1 session artifacts are owned by the parent daemon session.
ToolNames.ARTIFACT,
ToolNames.RECORD_ARTIFACT,
// FIX-8 (SEC-I1): WORKFLOW is excluded to prevent unbounded recursive
// fan-out: a subagent spawned by Workflow that calls Workflow would create
// O(k^n) subagents.

View file

@ -1923,6 +1923,49 @@ describe('Server Config (config.ts)', () => {
expect(registeredNames).toContain(ToolNames.READ_MCP_RESOURCE);
});
it('does not register artifact tools when artifacts are disabled', async () => {
const config = new Config({ ...baseParams });
await config.initialize();
const registeredNames = (
ToolRegistry.prototype.registerFactory as Mock
).mock.calls.map((call) => call[0]);
expect(registeredNames).not.toContain(ToolNames.ARTIFACT);
expect(registeredNames).not.toContain(ToolNames.RECORD_ARTIFACT);
});
it('registers both artifact tools when artifacts are enabled', async () => {
const config = new Config({
...baseParams,
artifactEnabled: true,
interactive: true,
sdkMode: false,
});
await config.initialize();
const registeredNames = (
ToolRegistry.prototype.registerFactory as Mock
).mock.calls.map((call) => call[0]);
expect(registeredNames).toContain(ToolNames.ARTIFACT);
expect(registeredNames).toContain(ToolNames.RECORD_ARTIFACT);
});
it('registers only record_artifact for daemon artifact metadata', async () => {
const config = new Config({
...baseParams,
artifactEnabled: true,
interactive: false,
sdkMode: false,
});
await config.initialize();
const registeredNames = (
ToolRegistry.prototype.registerFactory as Mock
).mock.calls.map((call) => call[0]);
expect(registeredNames).not.toContain(ToolNames.ARTIFACT);
expect(registeredNames).toContain(ToolNames.RECORD_ARTIFACT);
});
describe('isArtifactEnabled', () => {
const originalForceEnable = process.env['QWEN_CODE_ENABLE_ARTIFACT'];
const originalDisable = process.env['QWEN_CODE_DISABLE_ARTIFACT'];
@ -1986,7 +2029,7 @@ describe('Server Config (config.ts)', () => {
expect(config.isArtifactEnabled()).toBe(false);
});
it('stays disabled outside interactive mode even when force-enabled', () => {
it('keeps the Artifact tool disabled for daemon CLI env enablement', () => {
process.env['QWEN_CODE_ENABLE_ARTIFACT'] = '1';
const config = new Config({
@ -1996,6 +2039,19 @@ describe('Server Config (config.ts)', () => {
});
expect(config.isArtifactEnabled()).toBe(false);
expect(config.isRecordArtifactEnabled()).toBe(true);
});
it('lets daemon sessions record metadata from settings without publishing', () => {
const config = new Config({
...baseParams,
artifactEnabled: true,
interactive: false,
sdkMode: false,
});
expect(config.isArtifactEnabled()).toBe(false);
expect(config.isRecordArtifactEnabled()).toBe(true);
});
it('lets QWEN_CODE_ENABLE_ARTIFACT force-enable interactive CLI use', () => {

View file

@ -1478,7 +1478,7 @@ export class Config {
* the model later calls a tool that no longer exists (see
* `CoreToolScheduler.getToolNotFoundMessage`). Self-heals: a name is dropped
* from the set the moment the server reappears in the effective map.
*/
*/
private readonly recentlyRemovedMcpServers = new Set<string>();
private readonly topTierMcpServers:
| Record<string, MCPServerConfig>
@ -5005,11 +5005,20 @@ export class Config {
isArtifactEnabled(): boolean {
// Artifacts are experimental and opt-in. Publishing writes outside the
// project and opens a browser, so it is limited to interactive, non-SDK
// sessions. QWEN_CODE_DISABLE_ARTIFACT hard-disables;
// QWEN_CODE_ENABLE_ARTIFACT force-enables (still subject to the
// interactive/SDK gate).
// sessions. QWEN_CODE_DISABLE_ARTIFACT hard-disables both artifact tools;
// QWEN_CODE_ENABLE_ARTIFACT force-enables interactive artifact tooling
// here. isRecordArtifactEnabled() also treats it as an opt-in for the
// metadata-only daemon record_artifact tool.
if (process.env['QWEN_CODE_DISABLE_ARTIFACT'] === '1') return false;
if (this.sdkMode || !this.interactive) return false;
if (this.sdkMode) return false;
if (!this.interactive) return false;
if (process.env['QWEN_CODE_ENABLE_ARTIFACT'] === '1') return true;
return this.artifactEnabled;
}
isRecordArtifactEnabled(): boolean {
if (process.env['QWEN_CODE_DISABLE_ARTIFACT'] === '1') return false;
if (this.sdkMode) return false;
if (process.env['QWEN_CODE_ENABLE_ARTIFACT'] === '1') return true;
return this.artifactEnabled;
}
@ -6123,6 +6132,14 @@ export class Config {
return new ArtifactTool(this);
});
}
if (this.isRecordArtifactEnabled()) {
await registerLazy(ToolNames.RECORD_ARTIFACT, async () => {
const { RecordArtifactTool } = await import(
'../tools/record-artifact.js'
);
return new RecordArtifactTool();
});
}
if (this.isLspEnabled() && this.getLspClient()) {
await registerLazy(ToolNames.LSP, async () => {
const { LspTool } = await import('../tools/lsp.js');

View file

@ -169,4 +169,14 @@ describe('Workflow anti-recursion guard', () => {
);
expect(EXCLUDED_TOOLS_FOR_SUBAGENTS.has(ToolNames.WORKFLOW)).toBe(true);
});
it('artifact tools are in EXCLUDED_TOOLS_FOR_SUBAGENTS', async () => {
const { EXCLUDED_TOOLS_FOR_SUBAGENTS } = await import(
'../agents/runtime/agent-core.js'
);
expect(EXCLUDED_TOOLS_FOR_SUBAGENTS.has(ToolNames.ARTIFACT)).toBe(true);
expect(EXCLUDED_TOOLS_FOR_SUBAGENTS.has(ToolNames.RECORD_ARTIFACT)).toBe(
true,
);
});
});

View file

@ -2186,6 +2186,12 @@ describe('CoreToolScheduler', () => {
hookSpecificOutput: {
hookEventName: 'PostToolBatch',
additionalContext: 'batch context',
artifacts: [
{
title: 'Batch report',
workspacePath: 'batch.html',
},
],
},
}
: { decision: 'allow' },
@ -2283,13 +2289,20 @@ describe('CoreToolScheduler', () => {
.calls as unknown as Array<[ToolCall[]]>;
const completedCalls = completionCalls[0]?.[0];
const lastCompletedCall = completedCalls?.at(-1);
const lastResponse =
const lastCompletedResponse =
lastCompletedCall && 'response' in lastCompletedCall
? lastCompletedCall.response.responseParts.at(-1)
? lastCompletedCall.response
: undefined;
const lastResponse = lastCompletedResponse?.responseParts.at(-1);
expect(lastResponse?.functionResponse?.response?.['output']).toContain(
'batch context',
);
expect(lastCompletedResponse?.artifacts).toEqual([
{
title: 'Batch report',
workspacePath: 'batch.html',
},
]);
expect(
(
scheduler as unknown as {
@ -6672,9 +6685,14 @@ describe('CoreToolScheduler telemetry spans', () => {
}
function buildScheduler(options: {
execute?: () => Promise<ToolResult>;
execute?: (
params: { [key: string]: unknown },
signal?: AbortSignal,
updateOutput?: (output: string) => void,
) => Promise<ToolResult>;
messageBus?: { request: ReturnType<typeof vi.fn> };
disableHooks?: boolean;
canUpdateOutput?: boolean;
includeSensitiveSpanAttributes?: boolean;
sensitiveSpanAttributeMaxLength?: number;
}): {
@ -6683,6 +6701,7 @@ describe('CoreToolScheduler telemetry spans', () => {
} {
const mockTool = new MockTool({
name: 'mockTool',
canUpdateOutput: options.canUpdateOutput,
execute:
options.execute ??
vi.fn().mockResolvedValue({
@ -6748,10 +6767,15 @@ describe('CoreToolScheduler telemetry spans', () => {
async function runSingleTool(
options: {
execute?: () => Promise<ToolResult>;
execute?: (
params: { [key: string]: unknown },
signal?: AbortSignal,
updateOutput?: (output: string) => void,
) => Promise<ToolResult>;
messageBus?: { request: ReturnType<typeof vi.fn> };
disableHooks?: boolean;
abortController?: AbortController;
canUpdateOutput?: boolean;
throwSpanSetAttribute?: boolean;
throwSpanSetStatus?: boolean;
includeSensitiveSpanAttributes?: boolean;
@ -6986,6 +7010,116 @@ describe('CoreToolScheduler telemetry spans', () => {
expectSanitizedFailure(spanRecord, 'Tool execution failed', 'tool_error');
});
it('preserves PostToolUseFailure artifacts on toolResult.error responses', async () => {
const messageBus = {
request: vi
.fn()
.mockResolvedValueOnce({
type: MessageBusType.HOOK_EXECUTION_RESPONSE,
correlationId: 'pre-hook',
success: true,
output: { decision: 'allow' },
})
.mockResolvedValueOnce({
type: MessageBusType.HOOK_EXECUTION_RESPONSE,
correlationId: 'failure-hook',
success: true,
output: {
hookSpecificOutput: {
artifacts: [
{
title: 'Failure report',
workspacePath: 'reports/failure.html',
},
],
},
},
}),
};
const { completedCalls } = await runSingleTool({
messageBus,
disableHooks: false,
execute: vi.fn().mockResolvedValue({
llmContent: 'failed',
returnDisplay: 'failed',
error: {
message: 'tool failed',
type: ToolErrorType.EXECUTION_FAILED,
},
}),
});
const completedCall = completedCalls[0];
expect(completedCall.status).toBe('error');
if (completedCall.status === 'error') {
expect(completedCall.response.artifacts).toEqual([
{
title: 'Failure report',
workspacePath: 'reports/failure.html',
},
]);
}
});
it('preserves PostToolUse artifacts on successful responses', async () => {
const messageBus = {
request: vi
.fn()
.mockResolvedValueOnce({
type: MessageBusType.HOOK_EXECUTION_RESPONSE,
correlationId: 'pre-hook',
success: true,
output: { decision: 'allow' },
})
.mockResolvedValueOnce({
type: MessageBusType.HOOK_EXECUTION_RESPONSE,
correlationId: 'post-hook',
success: true,
output: {
hookSpecificOutput: {
artifacts: [
{
title: 'Hook report',
workspacePath: 'reports/hook.html',
},
],
},
},
}),
};
const { completedCalls } = await runSingleTool({
messageBus,
disableHooks: false,
execute: vi.fn().mockResolvedValue({
llmContent: 'ok',
returnDisplay: 'ok',
artifacts: [
{
title: 'Tool report',
workspacePath: 'reports/tool.html',
},
],
}),
});
const completedCall = completedCalls[0];
expect(completedCall.status).toBe('success');
if (completedCall.status === 'success') {
expect(completedCall.response.artifacts).toEqual([
{
title: 'Tool report',
workspacePath: 'reports/tool.html',
},
{
title: 'Hook report',
workspacePath: 'reports/hook.html',
},
]);
}
});
it('sets tool failure status when span attribute recording fails', async () => {
const { spanRecord, completedCalls } = await runSingleTool({
throwSpanSetAttribute: true,
@ -7617,6 +7751,146 @@ describe('CoreToolScheduler telemetry spans', () => {
expect(failureHookSpan!.hookMetadata?.success).toBe(true);
});
it('preserves PostToolUseFailure artifacts on user-abort cancellations', async () => {
const abortController = new AbortController();
const messageBus = {
request: vi.fn(async (req: { eventName: string }) => ({
type: MessageBusType.HOOK_EXECUTION_RESPONSE,
correlationId: `${req.eventName}-hook`,
success: true,
output:
req.eventName === 'PostToolUseFailure'
? {
hookSpecificOutput: {
artifacts: [
{
title: 'Cancel report',
workspacePath: 'reports/cancel.html',
},
],
},
}
: { decision: 'allow' },
})),
};
const { completedCalls } = await runSingleTool({
abortController,
messageBus,
disableHooks: false,
execute: vi.fn().mockImplementation(async () => {
abortController.abort();
throw new Error('aborted');
}),
});
const completedCall = completedCalls[0];
expect(completedCall.status).toBe('cancelled');
if (completedCall.status === 'cancelled') {
expect(completedCall.response.artifacts).toEqual([
{
title: 'Cancel report',
workspacePath: 'reports/cancel.html',
},
]);
}
});
it('preserves PostToolUseFailure artifacts when an aborted tool resolves', async () => {
const abortController = new AbortController();
const messageBus = {
request: vi.fn(async (req: { eventName: string }) => ({
type: MessageBusType.HOOK_EXECUTION_RESPONSE,
correlationId: `${req.eventName}-hook`,
success: true,
output:
req.eventName === 'PostToolUseFailure'
? {
hookSpecificOutput: {
artifacts: [
{
title: 'Resolved cancel report',
workspacePath: 'reports/resolved-cancel.html',
},
],
},
}
: { decision: 'allow' },
})),
};
const { completedCalls } = await runSingleTool({
abortController,
messageBus,
disableHooks: false,
execute: vi.fn().mockImplementation(async () => {
abortController.abort();
return { llmContent: 'done', returnDisplay: 'done' };
}),
});
const completedCall = completedCalls[0];
expect(completedCall.status).toBe('cancelled');
if (completedCall.status === 'cancelled') {
expect(completedCall.response.artifacts).toEqual([
{
title: 'Resolved cancel report',
workspacePath: 'reports/resolved-cancel.html',
},
]);
}
});
it('preserves live output when hook artifacts are attached to user-abort cancellations', async () => {
const abortController = new AbortController();
const messageBus = {
request: vi.fn(async (req: { eventName: string }) => ({
type: MessageBusType.HOOK_EXECUTION_RESPONSE,
correlationId: `${req.eventName}-hook`,
success: true,
output:
req.eventName === 'PostToolUseFailure'
? {
hookSpecificOutput: {
artifacts: [
{
title: 'Cancel report',
workspacePath: 'reports/cancel.html',
},
],
},
}
: { decision: 'allow' },
})),
};
const { completedCalls } = await runSingleTool({
abortController,
messageBus,
disableHooks: false,
canUpdateOutput: true,
execute: vi.fn(async (_params, _signal, updateOutput) => {
updateOutput?.('live output before abort');
abortController.abort();
throw new Error('aborted');
}),
});
const completedCall = completedCalls[0];
expect(completedCall.status).toBe('cancelled');
if (completedCall.status === 'cancelled') {
expect(completedCall.response.resultDisplay).toBe(
'live output before abort',
);
expect(completedCall.response.artifacts).toEqual([
{
title: 'Cancel report',
workspacePath: 'reports/cancel.html',
},
]);
}
});
it('PostToolUseFailure hook span records is_interrupt=false on real exception path (#4321)', async () => {
// Companion to the abort test — same hook event but the
// executeError-not-from-abort branch tags is_interrupt:false. A
@ -7645,6 +7919,46 @@ describe('CoreToolScheduler telemetry spans', () => {
expect(failureHookSpan!.hookMetadata?.success).toBe(true);
});
it('preserves PostToolUseFailure artifacts on thrown exceptions', async () => {
const messageBus = {
request: vi.fn(async (req: { eventName: string }) => ({
type: MessageBusType.HOOK_EXECUTION_RESPONSE,
correlationId: `${req.eventName}-hook`,
success: true,
output:
req.eventName === 'PostToolUseFailure'
? {
hookSpecificOutput: {
artifacts: [
{
title: 'Exception report',
workspacePath: 'reports/exception.html',
},
],
},
}
: { decision: 'allow' },
})),
};
const { completedCalls } = await runSingleTool({
messageBus,
disableHooks: false,
execute: vi.fn().mockRejectedValue(new Error('real boom')),
});
const completedCall = completedCalls[0];
expect(completedCall.status).toBe('error');
if (completedCall.status === 'error') {
expect(completedCall.response.artifacts).toEqual([
{
title: 'Exception report',
workspacePath: 'reports/exception.html',
},
]);
}
});
it('every span recorded in a successful tool call is ended (#3731 Phase 2)', async () => {
// Leak guard: every span we record should be ended by the time
// schedule() returns. If a future change forgets to finalize a tool

View file

@ -17,6 +17,7 @@ import type {
AnyDeclarativeTool,
AnyToolInvocation,
ChatRecordingService,
ToolArtifact,
} from '../index.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { compactToolResultDisplayForHistory } from '../utils/toolResultDisplayCompaction.js';
@ -832,6 +833,7 @@ const createErrorResponse = (
request: ToolCallRequestInfo,
error: Error,
errorType: ToolErrorType | undefined,
artifacts?: ToolArtifact[],
): ToolCallResponseInfo => ({
callId: request.callId,
error,
@ -847,8 +849,45 @@ const createErrorResponse = (
resultDisplay: error.message,
errorType,
contentLength: error.message.length,
...(artifacts && artifacts.length > 0 ? { artifacts } : {}),
});
const createCancelledResponse = (
request: ToolCallRequestInfo,
reason: string,
artifacts?: ToolArtifact[],
): ToolCallResponseInfo => {
const errorMessage = `[Operation Cancelled] Reason: ${reason}`;
return {
callId: request.callId,
responseParts: [
{
functionResponse: {
id: request.callId,
name: request.name,
response: { error: errorMessage },
},
},
],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
contentLength: errorMessage.length,
...(artifacts && artifacts.length > 0 ? { artifacts } : {}),
};
};
function isToolCallResponseInfo(value: unknown): value is ToolCallResponseInfo {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Partial<ToolCallResponseInfo>;
return (
typeof candidate.callId === 'string' &&
Array.isArray(candidate.responseParts)
);
}
function serializeToolResponse(
response: ToolCallResponseInfo,
): Record<string, unknown> {
@ -991,6 +1030,34 @@ function withPostToolBatchAdditionalContext(
return calls;
}
function withPostToolBatchArtifacts(
completedCalls: CompletedToolCall[],
artifacts: ToolArtifact[] | undefined,
): CompletedToolCall[] {
if (!artifacts || artifacts.length === 0 || completedCalls.length === 0) {
return completedCalls;
}
const calls = [...completedCalls];
const lastIndex = calls.length - 1;
const lastCall = calls[lastIndex];
if (!lastCall) {
return completedCalls;
}
// PostToolBatch hook output is batch-level and carries no per-call target.
// Attach it to the last completed call so the bridge receives it once.
const existingArtifacts = lastCall.response.artifacts ?? [];
calls[lastIndex] = {
...lastCall,
response: {
...lastCall.response,
artifacts: [...existingArtifacts, ...artifacts],
},
};
return calls;
}
function withPostToolBatchStop(
completedCalls: CompletedToolCall[],
stopReason: string,
@ -1183,7 +1250,7 @@ export class CoreToolScheduler {
private setStatusInternal(
targetCallId: string,
status: 'cancelled',
reason: string,
reason: string | ToolCallResponseInfo,
): void;
private setStatusInternal(
targetCallId: string,
@ -1295,31 +1362,39 @@ export class CoreToolScheduler {
}
}
const preservedResultDisplay =
this.compactResultDisplayForInteractiveHistory(resultDisplay);
const errorMessage = `[Operation Cancelled] Reason: ${auxiliaryData}`;
const response = isToolCallResponseInfo(auxiliaryData)
? {
...auxiliaryData,
resultDisplay:
auxiliaryData.resultDisplay ?? preservedResultDisplay,
}
: {
callId: currentCall.request.callId,
responseParts: [
{
functionResponse: {
id: currentCall.request.callId,
name: currentCall.request.name,
response: {
error: errorMessage,
},
},
},
],
resultDisplay: preservedResultDisplay,
error: undefined,
errorType: undefined,
contentLength: errorMessage.length,
};
return {
request: currentCall.request,
tool: toolInstance,
invocation,
status: 'cancelled',
response: {
callId: currentCall.request.callId,
responseParts: [
{
functionResponse: {
id: currentCall.request.callId,
name: currentCall.request.name,
response: {
error: errorMessage,
},
},
},
],
resultDisplay:
this.compactResultDisplayForInteractiveHistory(resultDisplay),
error: undefined,
errorType: undefined,
contentLength: errorMessage.length,
},
response,
durationMs,
outcome,
} as CancelledToolCall;
@ -3464,6 +3539,7 @@ export class CoreToolScheduler {
if (aborted) {
// PostToolUseFailure Hook
let cancelMessage = 'User cancelled tool execution.';
let failureHookArtifacts: ToolArtifact[] | undefined;
if (hooksEnabled && messageBus) {
const failureHookResult = await this.withHookSpan(
{
@ -3490,13 +3566,24 @@ export class CoreToolScheduler {
if (failureHookResult.additionalContext) {
cancelMessage += `\n\n${failureHookResult.additionalContext}`;
}
failureHookArtifacts = failureHookResult.artifacts;
}
this.safelyAddToolResultAttributes(
span,
toolName,
`CANCELLED: ${cancelMessage}`,
);
this.setStatusInternal(callId, 'cancelled', cancelMessage);
this.setStatusInternal(
callId,
'cancelled',
failureHookArtifacts && failureHookArtifacts.length > 0
? createCancelledResponse(
scheduledCall.request,
cancelMessage,
failureHookArtifacts,
)
: cancelMessage,
);
setToolSpanCancelled(span);
return; // Both code paths should return here
}
@ -3511,6 +3598,7 @@ export class CoreToolScheduler {
// below, so the head/tail truncator never bisects a <system-reminder>
// envelope or hook-injected context.
let postToolUseAdditionalContext: string | undefined;
let postToolUseArtifacts: ToolArtifact[] | undefined;
let reminderEnvelope: string | undefined;
// PostToolUse Hook
@ -3558,6 +3646,9 @@ export class CoreToolScheduler {
if (postHookResult.additionalContext) {
postToolUseAdditionalContext = postHookResult.additionalContext;
}
if (postHookResult.artifacts && postHookResult.artifacts.length > 0) {
postToolUseArtifacts = postHookResult.artifacts;
}
// Check if hook requested to stop execution
if (postHookResult.shouldStop) {
@ -3818,6 +3909,10 @@ export class CoreToolScheduler {
typeof content === 'string' ? content.length : undefined;
const response = convertToFunctionResponse(toolName, callId, content);
const artifacts = [
...(toolResult.artifacts ?? []),
...(postToolUseArtifacts ?? []),
];
const successResponse: ToolCallResponseInfo = {
callId,
responseParts: response,
@ -3832,6 +3927,7 @@ export class CoreToolScheduler {
...('modelOverride' in toolResult
? { modelOverride: toolResult.modelOverride }
: {}),
...(artifacts.length > 0 ? { artifacts } : {}),
};
this.setStatusInternal(callId, 'success', successResponse);
safeSetStatus(span, { code: SpanStatusCode.OK });
@ -3847,6 +3943,7 @@ export class CoreToolScheduler {
// It is a failure
// PostToolUseFailure Hook
let errorMessage = toolResult.error.message;
let failureHookArtifacts: ToolArtifact[] | undefined;
if (hooksEnabled && messageBus) {
const failureHookResult = await this.withHookSpan(
{
@ -3873,6 +3970,7 @@ export class CoreToolScheduler {
if (failureHookResult.additionalContext) {
errorMessage += `\n\n${failureHookResult.additionalContext}`;
}
failureHookArtifacts = failureHookResult.artifacts;
}
// Truncate oversized error messages (e.g., large stderr)
@ -3902,6 +4000,7 @@ export class CoreToolScheduler {
scheduledCall.request,
error,
toolResult.error.type,
failureHookArtifacts,
);
this.setStatusInternal(callId, 'error', errorResponse);
setToolSpanFailure(
@ -3932,6 +4031,7 @@ export class CoreToolScheduler {
if (aborted) {
// PostToolUseFailure Hook (user interrupt)
let cancelMessage = 'User cancelled tool execution.';
let failureHookArtifacts: ToolArtifact[] | undefined;
if (hooksEnabled && messageBus) {
const failureHookResult = await this.withHookSpan(
{
@ -3958,18 +4058,30 @@ export class CoreToolScheduler {
if (failureHookResult.additionalContext) {
cancelMessage += `\n\n${failureHookResult.additionalContext}`;
}
failureHookArtifacts = failureHookResult.artifacts;
}
this.safelyAddToolResultAttributes(
span,
toolName,
`CANCELLED: ${cancelMessage}`,
);
this.setStatusInternal(callId, 'cancelled', cancelMessage);
this.setStatusInternal(
callId,
'cancelled',
failureHookArtifacts && failureHookArtifacts.length > 0
? createCancelledResponse(
scheduledCall.request,
cancelMessage,
failureHookArtifacts,
)
: cancelMessage,
);
setToolSpanCancelled(span);
return;
} else {
// PostToolUseFailure Hook
let exceptionErrorMessage = errorMessage;
let failureHookArtifacts: ToolArtifact[] | undefined;
if (hooksEnabled && messageBus) {
const failureHookResult = await this.withHookSpan(
{
@ -3996,6 +4108,7 @@ export class CoreToolScheduler {
if (failureHookResult.additionalContext) {
exceptionErrorMessage += `\n\n${failureHookResult.additionalContext}`;
}
failureHookArtifacts = failureHookResult.artifacts;
}
this.safelyAddToolResultAttributes(
span,
@ -4011,6 +4124,7 @@ export class CoreToolScheduler {
? new Error(exceptionErrorMessage)
: new Error(String(executionError)),
ToolErrorType.UNHANDLED_EXCEPTION,
failureHookArtifacts,
),
);
setToolSpanFailure(
@ -4085,6 +4199,7 @@ export class CoreToolScheduler {
success: true,
shouldStop: r.shouldStop,
hasAdditionalContext: !!r.additionalContext,
hasArtifacts: !!r.artifacts?.length,
blockType: r.shouldStop ? 'stop' : undefined,
postBatchStop: r.shouldStop,
postBatchStopReason: r.shouldStop
@ -4112,6 +4227,10 @@ export class CoreToolScheduler {
completedCalls,
batchHookResult.additionalContext,
);
completedCalls = withPostToolBatchArtifacts(
completedCalls,
batchHookResult.artifacts,
);
}
// Per-message budget: offload the largest results if the batch's

View file

@ -374,6 +374,48 @@ describe('toolHookTriggers', () => {
});
});
it('returns PostToolUse artifacts and context when execution stops', async () => {
const mockOutput = {
continue: false,
reason: 'Blocked after audit',
hookSpecificOutput: {
additionalContext: 'Audit details',
artifacts: [
{
title: 'Audit report',
workspacePath: 'reports/audit.html',
},
],
},
};
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: mockOutput,
});
const result = await firePostToolUseHook(
mockMessageBus,
'test-tool',
{},
{},
'test-id',
'auto',
);
expect(result).toEqual({
shouldStop: true,
stopReason: 'Blocked after audit',
additionalContext: 'Audit details',
artifacts: [
{
title: 'Audit report',
workspacePath: 'reports/audit.html',
},
],
});
});
it('should return shouldStop: false with additional context when available', async () => {
const mockOutput = {
hookSpecificOutput: {
@ -401,6 +443,46 @@ describe('toolHookTriggers', () => {
});
});
it('returns PostToolUse artifacts', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {
hookSpecificOutput: {
artifacts: [
{
title: 'Tool report',
workspacePath: 'reports/tool.html',
},
{
title: 'Malformed report',
workspacePath: 123,
},
],
},
},
});
const result = await firePostToolUseHook(
mockMessageBus,
'test-tool',
{},
{},
'test-id',
'auto',
);
expect(result).toEqual({
shouldStop: false,
artifacts: [
{
title: 'Tool report',
workspacePath: 'reports/tool.html',
},
],
});
});
it('should handle hook execution errors gracefully', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockRejectedValue(
@ -502,6 +584,40 @@ describe('toolHookTriggers', () => {
});
});
it('returns PostToolBatch artifacts', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {
hookSpecificOutput: {
artifacts: [
{
title: 'Batch report',
workspacePath: 'batch.html',
},
{
title: 'Bad report',
workspacePath: 123,
},
],
},
},
});
const result = await firePostToolBatchHook(mockMessageBus, []);
expect(result).toEqual({
shouldStop: false,
additionalContext: undefined,
artifacts: [
{
title: 'Batch report',
workspacePath: 'batch.html',
},
],
});
});
it('should stop on deny decisions', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
@ -656,6 +772,44 @@ describe('toolHookTriggers', () => {
});
});
it('returns PostToolUseFailure artifacts', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {
hookSpecificOutput: {
artifacts: [
{
title: 'Failure report',
workspacePath: 'reports/failure.html',
},
{
title: 'Malformed failure report',
metadata: [],
},
],
},
},
});
const result = await firePostToolUseFailureHook(
mockMessageBus,
'test-id',
'test-tool',
{},
'error message',
);
expect(result).toEqual({
artifacts: [
{
title: 'Failure report',
workspacePath: 'reports/failure.html',
},
],
});
});
it('should handle hook execution errors gracefully', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockRejectedValue(

View file

@ -21,6 +21,7 @@ import {
type PostToolBatchToolCall,
} from '../hooks/types.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import type { ToolArtifact } from '../tools/tools.js';
import type { Part, PartListUnion } from '@google/genai';
const debugLogger = createDebugLogger('TOOL_HOOKS');
@ -65,6 +66,8 @@ export interface PostToolUseHookResult {
stopReason?: string;
/** Additional context to append to tool response */
additionalContext?: string;
/** Structured artifacts returned by post-tool hooks. */
artifacts?: ToolArtifact[];
/** See PreToolUseHookResult.hookError. */
hookError?: string;
}
@ -75,6 +78,8 @@ export interface PostToolUseHookResult {
export interface PostToolUseFailureHookResult {
/** Additional context about the failure */
additionalContext?: string;
/** Structured artifacts returned by failure hooks. */
artifacts?: ToolArtifact[];
/** See PreToolUseHookResult.hookError. */
hookError?: string;
}
@ -89,6 +94,8 @@ export interface PostToolBatchHookResult {
stopReason?: string;
/** Additional context to append once for the whole batch */
additionalContext?: string;
/** Structured artifacts returned by batch hooks. */
artifacts?: ToolArtifact[];
/** See PreToolUseHookResult.hookError. */
hookError?: string;
}
@ -279,20 +286,23 @@ export async function firePostToolUseHook(
response.output,
) as PostToolUseHookOutput;
const additionalContext = postToolOutput.getAdditionalContext();
const artifacts = postToolOutput.getArtifacts();
// Check if execution should stop
if (postToolOutput.shouldStopExecution()) {
return {
shouldStop: true,
stopReason: postToolOutput.getEffectiveReason(),
...(additionalContext !== undefined ? { additionalContext } : {}),
...(artifacts.length > 0 ? { artifacts } : {}),
};
}
// Get additional context
const additionalContext = postToolOutput.getAdditionalContext();
return {
shouldStop: false,
additionalContext,
...(additionalContext !== undefined ? { additionalContext } : {}),
...(artifacts.length > 0 ? { artifacts } : {}),
};
} catch (error) {
// Hook errors should not affect tool result
@ -372,9 +382,11 @@ export async function firePostToolUseFailureHook(
response.output,
) as PostToolUseFailureHookOutput;
const additionalContext = failureOutput.getAdditionalContext();
const artifacts = failureOutput.getArtifacts();
return {
additionalContext,
...(additionalContext !== undefined ? { additionalContext } : {}),
...(artifacts.length > 0 ? { artifacts } : {}),
};
} catch (error) {
// Hook errors should not affect error handling
@ -432,11 +444,13 @@ export async function firePostToolBatchHook(
const batchOutput = createHookOutput('PostToolBatch', response.output);
const shouldStop = batchOutput.shouldStopExecution();
const artifacts = batchOutput.getArtifacts();
return {
shouldStop,
stopReason: shouldStop ? batchOutput.getEffectiveReason() : undefined,
additionalContext: batchOutput.getAdditionalContext(),
...(artifacts.length > 0 ? { artifacts } : {}),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);

View file

@ -16,6 +16,7 @@ import {
} from '@google/genai';
import type {
ToolCallConfirmationDetails,
ToolArtifact,
ToolResult,
ToolResultDisplay,
} from '../tools/tools.js';
@ -125,6 +126,7 @@ export interface ToolCallResponseInfo {
errorType: ToolErrorType | undefined;
contentLength?: number;
modelOverride?: string;
artifacts?: ToolArtifact[];
}
function normalizeRequestParts(req: PartListUnion): Part[] {

View file

@ -209,6 +209,47 @@ describe('HookAggregator', () => {
).toBe('ctx\nctx2');
});
it('should concatenate artifact arrays and drop malformed artifacts fields', () => {
const outputs: HookOutput[] = [
{
hookSpecificOutput: {
artifacts: [
{
title: 'Report',
workspacePath: 'report.html',
},
{ workspacePath: 'missing-title.html' },
null,
],
},
},
{
hookSpecificOutput: {
artifacts: { title: 'Malformed' },
other: 'kept',
},
},
];
const results: HookExecutionResult[] = outputs.map((output) => ({
hookConfig: { type: HookType.Command, command: 'echo test' },
eventName: HookEventName.PostToolUse,
success: true,
output,
duration: 100,
}));
const result = aggregator.aggregateResults(
results,
HookEventName.PostToolUse,
);
expect(result.finalOutput?.hookSpecificOutput).toMatchObject({
artifacts: [{ title: 'Report', workspacePath: 'report.html' }],
other: 'kept',
});
});
it('should preserve PostToolBatch stop decisions across multiple hooks', () => {
const outputs: HookOutput[] = [
{ continue: false, stopReason: 'first hook stopped' },

View file

@ -14,8 +14,12 @@ import {
PostToolBatchHookOutput,
StopHookOutput,
PermissionRequestHookOutput,
isToolArtifactLike,
} from './types.js';
import type { HookOutput, HookExecutionResult } from './types.js';
import { createDebugLogger } from '../utils/debugLogger.js';
const debugLogger = createDebugLogger('HOOK_AGGREGATOR');
/**
* Aggregated result from multiple hook executions
@ -142,6 +146,7 @@ export class HookAggregator {
const merged: HookOutput = {};
const reasons: string[] = [];
const additionalContexts: string[] = [];
const artifacts: unknown[] = [];
let hasBlock = false;
let hasContinueFalse = false;
let stopReason: string | undefined;
@ -172,7 +177,19 @@ export class HookAggregator {
// Collect other hookSpecificOutput fields (later values win)
if (output.hookSpecificOutput) {
for (const [key, value] of Object.entries(output.hookSpecificOutput)) {
if (key !== 'additionalContext') {
if (key === 'artifacts' && Array.isArray(value)) {
const validArtifacts = value.filter(isToolArtifactLike);
artifacts.push(...validArtifacts);
if (validArtifacts.length !== value.length) {
debugLogger.warn(
'Dropped malformed hookSpecificOutput.artifacts entries',
);
}
} else if (key === 'artifacts') {
debugLogger.warn(
'Dropped malformed hookSpecificOutput.artifacts; expected array',
);
} else if (key !== 'additionalContext' && key !== 'artifacts') {
otherHookSpecificFields[key] = value;
}
}
@ -217,6 +234,9 @@ export class HookAggregator {
if (additionalContexts.length > 0) {
hookSpecificOutput['additionalContext'] = additionalContexts.join('\n');
}
if (artifacts.length > 0) {
hookSpecificOutput['artifacts'] = artifacts;
}
if (Object.keys(hookSpecificOutput).length > 0) {
merged.hookSpecificOutput = hookSpecificOutput;

View file

@ -11,6 +11,7 @@ import {
DefaultHookOutput,
UserPromptExpansionHookOutput,
HookEventName,
isToolArtifactLike,
} from './types.js';
describe('UserPromptSubmit getAdditionalContext', () => {
@ -143,3 +144,40 @@ describe('terminalSequence on HookOutput', () => {
expect(output.terminalSequence).toBeUndefined();
});
});
describe('isToolArtifactLike', () => {
it('accepts primitive metadata values', () => {
expect(
isToolArtifactLike({
title: 'Report',
metadata: { label: 'daily', score: 1, pinned: true, optional: null },
}),
).toBe(true);
});
it('rejects nested and non-finite metadata values', () => {
expect(
isToolArtifactLike({
title: 'Report',
metadata: { hints: { display: 'card' } },
}),
).toBe(false);
expect(
isToolArtifactLike({
title: 'Report',
metadata: { score: Number.NaN },
}),
).toBe(false);
});
it('rejects artifact sizes the daemon store would drop', () => {
for (const sizeBytes of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
expect(
isToolArtifactLike({
title: 'Report',
sizeBytes,
}),
).toBe(false);
}
});
});

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ChildProcess } from 'child_process';
import type { ToolArtifact } from '../tools/tools.js';
import { createDebugLogger } from '../utils/debugLogger.js';
const debugLogger = createDebugLogger('TRUSTED_HOOKS');
@ -288,6 +289,49 @@ export interface HookOutput {
hookSpecificOutput?: Record<string, unknown>;
}
export function isToolArtifactLike(value: unknown): value is ToolArtifact {
if (typeof value !== 'object' || value === null) {
return false;
}
const artifact = value as Record<string, unknown>;
return (
typeof artifact['title'] === 'string' &&
isOptionalString(artifact, 'kind') &&
isOptionalString(artifact, 'storage') &&
isOptionalString(artifact, 'description') &&
isOptionalString(artifact, 'workspacePath') &&
isOptionalString(artifact, 'managedId') &&
isOptionalString(artifact, 'url') &&
isOptionalString(artifact, 'mimeType') &&
(artifact['sizeBytes'] === undefined ||
(typeof artifact['sizeBytes'] === 'number' &&
Number.isSafeInteger(artifact['sizeBytes']) &&
artifact['sizeBytes'] >= 0)) &&
(artifact['metadata'] === undefined ||
isToolArtifactMetadataLike(artifact['metadata']))
);
}
function isToolArtifactMetadataLike(value: unknown): boolean {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
return Object.values(value).every(
(item) =>
item === null ||
typeof item === 'string' ||
typeof item === 'boolean' ||
(typeof item === 'number' && Number.isFinite(item)),
);
}
function isOptionalString(
value: Record<string, unknown>,
key: string,
): boolean {
return value[key] === undefined || typeof value[key] === 'string';
}
export const MAX_USER_PROMPT_EXPANSION_ADDITIONAL_CONTEXT_LENGTH = 10_000;
export function sanitizeUserPromptExpansionAdditionalContext(
@ -398,6 +442,14 @@ export class DefaultHookOutput implements HookOutput {
return undefined;
}
getArtifacts(): ToolArtifact[] {
const artifacts = this.hookSpecificOutput?.['artifacts'];
if (!Array.isArray(artifacts)) {
return [];
}
return artifacts.filter(isToolArtifactLike);
}
/**
* Check if execution should be blocked and return error info
*/
@ -728,6 +780,7 @@ export interface PostToolUseOutput extends HookOutput {
hookSpecificOutput?: {
hookEventName: 'PostToolUse';
additionalContext?: string;
artifacts?: ToolArtifact[];
};
}
@ -753,6 +806,7 @@ export interface PostToolUseFailureOutput extends HookOutput {
hookSpecificOutput?: {
hookEventName: 'PostToolUseFailure';
additionalContext?: string;
artifacts?: ToolArtifact[];
};
}
@ -788,6 +842,7 @@ export interface PostToolBatchOutput extends HookOutput {
hookSpecificOutput?: {
hookEventName: 'PostToolBatch';
additionalContext?: string;
artifacts?: ToolArtifact[];
};
}

View file

@ -172,6 +172,10 @@ export type {
ArtifactTool,
ArtifactToolParams,
} from './tools/artifact/artifact-tool.js';
export type {
RecordArtifactTool,
RecordArtifactParams,
} from './tools/record-artifact.js';
export type {
ArtifactPublisher,
PublishArtifactInput,
@ -541,11 +545,13 @@ export {
firePreToolUseHook,
firePostToolUseHook,
firePostToolUseFailureHook,
firePostToolBatchHook,
type NotificationHookResult,
type PermissionRequestHookResult,
type PreToolUseHookResult,
type PostToolUseHookResult,
type PostToolUseFailureHookResult,
type PostToolBatchHookResult,
generateToolUseId,
} from './core/toolHookTriggers.js';

View file

@ -70,6 +70,16 @@ describe('ArtifactTool', () => {
expect(res.llmContent).toMatch(/Published artifact/);
expect(res.llmContent).toMatch(/file:\/\//);
expect(openSpy).toHaveBeenCalledTimes(1);
expect(res.artifacts).toMatchObject([
{
kind: 'html',
storage: 'published',
title: 'My Report',
mimeType: 'text/html',
},
]);
expect(res.artifacts?.[0]?.url).toMatch(/^file:\/\//);
expect(res.artifacts?.[0]?.managedId).toBeTruthy();
const published = res.resultFilePaths?.[0];
expect(published).toBeTruthy();

View file

@ -178,6 +178,7 @@ class ArtifactToolInvocation extends BaseToolInvocation<
}
// Publish (idempotent per source path → stable URL).
let managedId: string;
let url: string;
let filePath: string | undefined;
try {
@ -185,6 +186,7 @@ class ArtifactToolInvocation extends BaseToolInvocation<
{ id: artifactIdFromPath(file_path), title, html },
signal,
);
managedId = published.id;
url = published.url;
filePath = published.filePath;
} catch (err) {
@ -225,6 +227,17 @@ class ArtifactToolInvocation extends BaseToolInvocation<
llmContent,
returnDisplay: `Published artifact **${title}**\n\n${url}`,
resultFilePaths: filePath ? [filePath] : undefined,
artifacts: [
{
kind: 'html',
storage: 'published',
title,
url,
managedId,
mimeType: 'text/html',
sizeBytes: bytes,
},
],
};
}
}

View file

@ -0,0 +1,334 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { RecordArtifactTool } from './record-artifact.js';
const signal = new AbortController().signal;
describe('RecordArtifactTool', () => {
it('records a link artifact without touching the resource', async () => {
const tool = new RecordArtifactTool();
const result = await tool
.build({
title: 'Table details',
url: 'https://example.com/tables/orders',
metadata: { table: 'orders' },
})
.execute(signal);
expect(result.error).toBeUndefined();
expect(result.artifacts).toMatchObject([
{
title: 'Table details',
storage: 'external_url',
url: 'https://example.com/tables/orders',
metadata: { table: 'orders' },
},
]);
});
it('records workspace and managed artifacts with inferred storage', async () => {
const tool = new RecordArtifactTool();
await expect(
tool
.build({
title: 'Workspace report',
workspacePath: 'reports/summary.html',
})
.execute(signal),
).resolves.toMatchObject({
artifacts: [
{
title: 'Workspace report',
storage: 'workspace',
workspacePath: 'reports/summary.html',
},
],
});
await expect(
tool
.build({
title: 'Managed preview',
managedId: 'ext-123',
})
.execute(signal),
).resolves.toMatchObject({
artifacts: [
{
title: 'Managed preview',
storage: 'managed',
managedId: 'ext-123',
},
],
});
});
it('rejects published storage', () => {
const tool = new RecordArtifactTool();
expect(() =>
tool.build({
title: 'Forged',
storage: 'published' as never,
url: 'https://example.com/artifact',
}),
).toThrow(/allowed values/);
});
it('requires exactly one locator', () => {
const tool = new RecordArtifactTool();
expect(() =>
tool.build({
title: 'Ambiguous',
workspacePath: 'report.html',
url: 'https://example.com/report',
}),
).toThrow(/exactly one/);
});
it('rejects workspace traversal and unsafe urls before reporting success', () => {
const tool = new RecordArtifactTool();
expect(() =>
tool.build({
title: 'Escape',
workspacePath: '../secret.txt',
}),
).toThrow(/workspacePath/);
expect(() =>
tool.build({
title: 'Credentials',
url: 'https://user:pass@example.com/resource',
}),
).toThrow(/credentials/);
expect(() =>
tool.build({
title: 'FTP',
url: 'ftp://example.com/resource',
}),
).toThrow(/http or https/);
});
it('rejects path-like managed ids before reporting success', () => {
const tool = new RecordArtifactTool();
for (const managedId of ['../secret', 'folder/item', 'folder\\item']) {
expect(() =>
tool.build({
title: 'Managed path',
managedId,
}),
).toThrow(/opaque managed resource id/);
}
});
it('rejects storage values that do not match the locator', () => {
const tool = new RecordArtifactTool();
expect(() =>
tool.build({
title: 'Workspace mismatch',
storage: 'external_url',
workspacePath: 'report.html',
}),
).toThrow(/storage.*workspace/);
});
it('rejects artifact metadata that the daemon store would drop', () => {
const tool = new RecordArtifactTool();
expect(() =>
tool.build({
title: 'Huge metadata',
url: 'https://example.com/resource',
metadata: { value: 'x'.repeat(4096) },
}),
).toThrow(/metadata/);
for (const value of [Number.NaN, Number.POSITIVE_INFINITY]) {
expect(() =>
tool.build({
title: 'Non-finite metadata',
url: 'https://example.com/resource',
metadata: { value },
}),
).toThrow(/metadata/);
}
});
it('rejects invalid artifact sizes before reporting success', () => {
const tool = new RecordArtifactTool();
for (const sizeBytes of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
expect(() =>
tool.build({
title: 'Sized artifact',
url: 'https://example.com/resource',
sizeBytes,
}),
).toThrow(/sizeBytes/);
}
});
it('rejects unsafe display markup before reporting success', () => {
const tool = new RecordArtifactTool();
expect(() =>
tool.build({
title: '<script>alert(1)</script>',
url: 'https://example.com/resource',
}),
).toThrow(/unsafe markup/);
expect(() =>
tool.build({
title: 'External style',
description: '<style>body{display:none}</style>',
url: 'https://example.com/resource',
}),
).toThrow(/unsafe markup/);
expect(() =>
tool.build({
title: 'Entity payload',
description: '&#x3c;script&#x3e;',
url: 'https://example.com/resource',
}),
).toThrow(/unsafe markup/);
expect(() =>
tool.build({
title: 'Script data url',
description: 'data:text/javascript,alert(1)',
url: 'https://example.com/resource',
}),
).toThrow(/unsafe markup/);
expect(() =>
tool.build({
title: 'SVG data url',
description: 'data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9YWxlcnQoMSk+',
url: 'https://example.com/resource',
}),
).toThrow(/unsafe markup/);
expect(() =>
tool.build({
title: 'HTML mime',
mimeType: 'text/html<script>',
url: 'https://example.com/resource',
}),
).toThrow(/unsafe markup/);
expect(() =>
tool.build({
title: 'Workspace payload',
workspacePath: '<img src=x onerror=alert(1)>.html',
}),
).toThrow(/unsafe markup/);
expect(() =>
tool.build({
title: 'Managed payload',
managedId: '<script>alert(1)</script>',
}),
).toThrow(/unsafe markup/);
expect(() =>
tool.build({
title: 'Metadata key',
url: 'https://example.com/resource',
metadata: { '<script>': 'unsafe key' },
}),
).toThrow(/metadata/);
expect(() =>
tool.build({
title: 'Metadata value',
url: 'https://example.com/resource',
metadata: { preview: 'data:text/javascript,alert(1)' },
}),
).toThrow(/metadata/);
});
it('allows benign words ending with on before equals signs', () => {
const tool = new RecordArtifactTool();
expect(() =>
tool.build({
title: 'conversation=value',
description: 'configuration=value',
url: 'https://example.com/resource',
}),
).not.toThrow();
});
it('rejects Unicode control characters before reporting success', () => {
const tool = new RecordArtifactTool();
expect(() =>
tool.build({
title: 'Hidden\u202eTitle',
url: 'https://example.com/resource',
}),
).toThrow(/control characters/);
expect(() =>
tool.build({
title: 'safe\u2028evil',
url: 'https://example.com/resource',
}),
).toThrow(/control characters/);
expect(() =>
tool.build({
title: 'safe\u2066evil',
url: 'https://example.com/resource',
}),
).toThrow(/control characters/);
expect(() =>
tool.build({
title: 'Metadata key',
url: 'https://example.com/resource',
metadata: { 'preview\u200b': 'hidden' },
}),
).toThrow(/metadata/);
});
it('accepts line whitespace in descriptions but not titles', async () => {
const tool = new RecordArtifactTool();
await expect(
tool
.build({
title: 'Multiline report',
description: 'Line one\nLine two\tindented\r\nLine three',
url: 'https://example.com/resource',
})
.execute(signal),
).resolves.toMatchObject({
artifacts: [
{
description: 'Line one\nLine two\tindented\r\nLine three',
},
],
});
expect(() =>
tool.build({
title: 'Bad\nTitle',
url: 'https://example.com/resource',
}),
).toThrow(/control characters/);
});
});

View file

@ -0,0 +1,436 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import type {
ToolArtifact,
ToolArtifactKind,
ToolArtifactStorage,
ToolInvocation,
ToolResult,
} from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
export interface RecordArtifactParams {
title: string;
kind?: ToolArtifactKind;
storage?: Exclude<ToolArtifactStorage, 'published'>;
description?: string;
workspacePath?: string;
managedId?: string;
url?: string;
mimeType?: string;
sizeBytes?: number;
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.
This tool only records metadata. It does not publish, upload, read, write, or verify the referenced resource. Provide exactly one locator: workspacePath, managedId, or url. Use the Artifact tool, not record_artifact, for published interactive HTML artifacts.`;
class RecordArtifactInvocation extends BaseToolInvocation<
RecordArtifactParams,
ToolResult
> {
override getDescription(): string {
return `Recording artifact ${this.params.title}`;
}
execute(_signal: AbortSignal): Promise<ToolResult> {
const artifact: ToolArtifact = {
title: this.params.title.trim(),
kind: this.params.kind,
storage: this.params.storage ?? inferStorage(this.params),
description: trimOptional(this.params.description),
workspacePath: trimOptional(this.params.workspacePath),
managedId: trimOptional(this.params.managedId),
url: trimOptional(this.params.url),
mimeType: trimOptional(this.params.mimeType),
sizeBytes: this.params.sizeBytes,
metadata: this.params.metadata,
};
return Promise.resolve({
llmContent: `Recorded artifact "${artifact.title}".`,
returnDisplay: `Recorded artifact **${artifact.title}**.`,
artifacts: [artifact],
});
}
}
export class RecordArtifactTool extends BaseDeclarativeTool<
RecordArtifactParams,
ToolResult
> {
static readonly Name: string = ToolNames.RECORD_ARTIFACT;
constructor() {
super(
RecordArtifactTool.Name,
ToolDisplayNames.RECORD_ARTIFACT,
DESCRIPTION,
Kind.Other,
{
type: 'object',
properties: {
title: {
type: 'string',
description: 'Concise title shown in the client artifact list.',
},
kind: {
type: 'string',
enum: [
'file',
'link',
'html',
'image',
'video',
'audio',
'pdf',
'notebook',
'other',
],
description: 'Best-effort artifact type for client rendering.',
},
storage: {
type: 'string',
enum: ['workspace', 'external_url', 'managed'],
description:
'Storage class. Omit it to infer from the provided locator.',
},
description: {
type: 'string',
description: 'Optional short description for the user.',
},
workspacePath: {
type: 'string',
description:
'Workspace-relative path for a file produced in the current workspace.',
},
managedId: {
type: 'string',
description:
'Opaque identifier for a resource managed by an extension or tool.',
},
url: {
type: 'string',
description:
'HTTP or HTTPS URL that the user can open for details.',
},
mimeType: {
type: 'string',
description: 'Optional MIME type.',
},
sizeBytes: {
type: 'integer',
minimum: 0,
description: 'Optional size in bytes.',
},
metadata: {
type: 'object',
additionalProperties: {
anyOf: [
{ type: 'string' },
{ type: 'number' },
{ type: 'boolean' },
{ type: 'null' },
],
},
description:
'Small primitive metadata bag for client-specific display hints.',
},
},
required: ['title'],
},
true,
false,
true,
false,
'artifact url link file image report notebook dashboard',
);
}
protected override validateToolParamValues(
params: RecordArtifactParams,
): string | null {
params.title = (params.title ?? '').trim();
const titleError = validateString(params.title, 'title', 200, true);
if (titleError) {
return titleError;
}
const descriptionError = validateString(
params.description,
'description',
1000,
false,
);
if (descriptionError) {
return descriptionError;
}
const mimeTypeError = validateString(
params.mimeType,
'mimeType',
120,
false,
);
if (mimeTypeError) {
return mimeTypeError;
}
if (params.kind && !isArtifactKind(params.kind)) {
return '"kind" must be a supported artifact kind';
}
if (
params.storage &&
params.storage !== 'workspace' &&
params.storage !== 'external_url' &&
params.storage !== 'managed'
) {
return '"storage" must be workspace, external_url, or managed';
}
const locators = [
trimOptional(params.workspacePath),
trimOptional(params.managedId),
trimOptional(params.url),
].filter(Boolean);
if (locators.length !== 1) {
return 'Provide exactly one of "workspacePath", "managedId", or "url"';
}
const inferredStorage = inferStorage(params);
if (params.storage && params.storage !== inferredStorage) {
return `"storage" must be "${inferredStorage}" for the provided locator`;
}
if (params.workspacePath) {
const workspacePathError = validateWorkspacePath(params.workspacePath);
if (workspacePathError) {
return workspacePathError;
}
}
if (params.managedId) {
const managedIdError = validateManagedId(params.managedId);
if (managedIdError) {
return managedIdError;
}
}
if (params.url) {
try {
const parsed = new URL(params.url);
if (parsed.username || parsed.password) {
return '"url" must not include credentials';
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return '"url" must use http or https';
}
} catch {
return '"url" must be a valid URL';
}
}
if (
params.sizeBytes !== undefined &&
(!Number.isSafeInteger(params.sizeBytes) || params.sizeBytes < 0)
) {
return '"sizeBytes" must be a non-negative safe integer';
}
const metadataError = validateMetadata(params.metadata);
if (metadataError) {
return metadataError;
}
return null;
}
protected createInvocation(
params: RecordArtifactParams,
): ToolInvocation<RecordArtifactParams, ToolResult> {
return new RecordArtifactInvocation(params);
}
}
function inferStorage(
params: Pick<RecordArtifactParams, 'workspacePath' | 'managedId' | 'url'>,
): Exclude<ToolArtifactStorage, 'published'> {
if (trimOptional(params.workspacePath)) {
return 'workspace';
}
if (trimOptional(params.managedId)) {
return 'managed';
}
return 'external_url';
}
function trimOptional(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function validateString(
value: string | undefined,
field: string,
maxLength: number,
required: boolean,
): string | null {
const trimmed = value?.trim();
if (!trimmed) {
return required ? `Missing or empty "${field}"` : null;
}
if (trimmed.length > maxLength) {
return `"${field}" exceeds ${maxLength} characters`;
}
if (hasControlCharacter(trimmed, field === 'description')) {
return `"${field}" contains control characters`;
}
if (isDisplayField(field) && hasUnsafeDisplayPayload(trimmed)) {
return `"${field}" contains unsafe markup`;
}
return null;
}
function validateManagedId(value: string): string | null {
const trimmed = value.trim();
const stringError = validateString(trimmed, 'managedId', 200, true);
if (stringError) {
return stringError;
}
if (
trimmed.includes('/') ||
trimmed.includes('\\') ||
trimmed.includes('..') ||
path.isAbsolute(trimmed) ||
path.win32.isAbsolute(trimmed)
) {
return '"managedId" must be an opaque managed resource id';
}
return null;
}
function isDisplayField(field: string): boolean {
return (
field === 'title' ||
field === 'description' ||
field === 'mimeType' ||
field === 'workspacePath' ||
field === 'managedId'
);
}
function hasControlCharacter(
value: string,
allowLineWhitespace = false,
): boolean {
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if (
allowLineWhitespace &&
(code === 0x09 || code === 0x0a || code === 0x0d)
) {
continue;
}
if (
code <= 0x1f ||
code === 0x7f ||
(code >= 0x200b && code <= 0x200f) ||
code === 0x2028 ||
code === 0x2029 ||
(code >= 0x202a && code <= 0x202e) ||
(code >= 0x2066 && code <= 0x2069) ||
code === 0xfeff
) {
return true;
}
}
return false;
}
function hasUnsafeDisplayPayload(value: string): boolean {
return (
/<\s*\/?[a-z!]|&(?:#[0-9]+|#x[0-9a-f]+|[a-z][a-z0-9]+);|javascript\s*:|data\s*:\s*(?:text\/(?:html|javascript)|application\/javascript|image\/svg\+xml)/i.test(
value,
) || /(?:^|[\s"'`<])on[a-z][a-z0-9-]*\s*=/i.test(value)
);
}
function validateWorkspacePath(value: string): string | null {
const trimmed = value.trim();
const stringError = validateString(trimmed, 'workspacePath', 500, true);
if (stringError) {
return stringError;
}
if (path.isAbsolute(trimmed)) {
return '"workspacePath" must be relative to the workspace';
}
const normalized = path.normalize(trimmed);
if (
normalized === '..' ||
normalized.startsWith(`..${path.sep}`) ||
path.isAbsolute(normalized)
) {
return '"workspacePath" must stay inside the workspace';
}
return null;
}
function validateMetadata(
metadata: Record<string, string | number | boolean | null> | undefined,
): string | null {
if (metadata === undefined) {
return null;
}
if (
typeof metadata !== 'object' ||
metadata === null ||
Array.isArray(metadata)
) {
return '"metadata" must be an object';
}
for (const [key, value] of Object.entries(metadata)) {
if (!key) {
return '"metadata" keys must not be empty';
}
if (key.length > 120) {
return '"metadata" keys must be 120 characters or fewer';
}
if (hasControlCharacter(key) || hasUnsafeDisplayPayload(key)) {
return '"metadata" keys contain unsafe content';
}
if (
value !== null &&
typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean'
) {
return '"metadata" values must be primitive';
}
if (typeof value === 'number' && !Number.isFinite(value)) {
return '"metadata" numbers must be finite';
}
if (
typeof value === 'string' &&
(hasControlCharacter(value) || hasUnsafeDisplayPayload(value))
) {
return '"metadata" string values contain unsafe content';
}
}
if (Buffer.byteLength(JSON.stringify(metadata), 'utf8') > 4096) {
return '"metadata" must be 4096 bytes or fewer';
}
return null;
}
function isArtifactKind(kind: string): kind is ToolArtifactKind {
return (
kind === 'file' ||
kind === 'link' ||
kind === 'html' ||
kind === 'image' ||
kind === 'video' ||
kind === 'audio' ||
kind === 'pdf' ||
kind === 'notebook' ||
kind === 'other'
);
}

View file

@ -61,6 +61,7 @@ export const ToolNames = {
// `get_app_state` / `perform_secondary_action` that no longer exist.
WORKFLOW: 'workflow',
ARTIFACT: 'artifact',
RECORD_ARTIFACT: 'record_artifact',
} as const;
/**
@ -107,6 +108,7 @@ export const ToolDisplayNames = {
// computer_use__* display names are not enumerated here (see ToolNames).
WORKFLOW: 'Workflow',
ARTIFACT: 'Artifact',
RECORD_ARTIFACT: 'RecordArtifact',
} as const;
// Migration from old tool names to new tool names

View file

@ -440,6 +440,36 @@ export function isTool(obj: unknown): obj is AnyDeclarativeTool {
);
}
export type ToolArtifactKind =
| 'file'
| 'link'
| 'html'
| 'image'
| 'video'
| 'audio'
| 'pdf'
| 'notebook'
| 'other';
export type ToolArtifactStorage =
| 'workspace'
| 'external_url'
| 'managed'
| 'published';
export interface ToolArtifact {
kind?: ToolArtifactKind;
storage?: ToolArtifactStorage;
title: string;
description?: string;
workspacePath?: string;
managedId?: string;
url?: string;
mimeType?: string;
sizeBytes?: number;
metadata?: Record<string, string | number | boolean | null>;
}
export interface ToolResult {
/**
* Content meant to be included in LLM history.
@ -462,6 +492,13 @@ export interface ToolResult {
*/
resultFilePaths?: string[];
/**
* Structured artifacts produced by this tool call. Daemon/session surfaces
* consume this as metadata only; the producer remains responsible for the
* underlying file, URL, or managed resource lifecycle.
*/
artifacts?: ToolArtifact[];
/**
* If this property is present, the tool call is considered a failure.
*/

View file

@ -39,8 +39,9 @@ const rootDir = join(__dirname, '..');
// Bumped from 131KB to 132KB for the pending prompt queue feature.
// Bumped from 132KB to 133KB for session archive/unarchive APIs and sessionless
// workspace remember (managed memory client methods + event validation).
// Bumped from 133KB to 134KB after merging both surfaces with main.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 134 * 1024;
// Bumped from 133KB to 135KB after merging both surfaces plus session artifact
// APIs and event validation.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 135 * 1024;
// The opt-in `daemon/transports` browser bundle legitimately ships the concrete
// ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so
// it's larger than the default barrel — but still budgeted so a future PR can't

View file

@ -88,6 +88,9 @@ import type {
DaemonRuntimeMcpAddResult,
DaemonRuntimeMcpRemoveResult,
DaemonToolToggleResult,
DaemonSessionArtifactInput,
DaemonSessionArtifactMutationResult,
DaemonSessionArtifactsEnvelope,
DaemonRewindSnapshotInfo,
DaemonRewindResult,
ForkSessionRequest,
@ -2958,6 +2961,50 @@ export class DaemonClient {
this.transport.dispose();
}
// -- Session artifacts ---------------------------------------------------
async listSessionArtifacts(
sessionId: string,
clientId?: string,
): Promise<DaemonSessionArtifactsEnvelope> {
return await this.jsonRequest<DaemonSessionArtifactsEnvelope>(
`/session/${encodeURIComponent(sessionId)}/artifacts`,
'GET /session/:id/artifacts',
{ clientId },
);
}
async addSessionArtifact(
sessionId: string,
artifact: DaemonSessionArtifactInput,
clientId?: string,
): Promise<DaemonSessionArtifactMutationResult> {
return await this.jsonRequest<DaemonSessionArtifactMutationResult>(
`/session/${encodeURIComponent(sessionId)}/artifacts`,
'POST /session/:id/artifacts',
{
method: 'POST',
body: artifact,
clientId,
},
);
}
async removeSessionArtifact(
sessionId: string,
artifactId: string,
clientId?: string,
): Promise<DaemonSessionArtifactMutationResult> {
return await this.jsonRequest<DaemonSessionArtifactMutationResult>(
`/session/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(artifactId)}`,
'DELETE /session/:id/artifacts/:artifactId',
{
method: 'DELETE',
clientId,
},
);
}
// -- Session metadata ----------------------------------------------------
/**

View file

@ -30,6 +30,9 @@ import type {
DaemonSessionLspStatus,
DaemonSessionRecapResult,
DaemonShellCommandResult,
DaemonSessionArtifactInput,
DaemonSessionArtifactMutationResult,
DaemonSessionArtifactsEnvelope,
DaemonSessionState,
DaemonSession,
DaemonSessionStatsStatus,
@ -407,6 +410,33 @@ export class DaemonSessionClient {
return await this.client.heartbeat(this.sessionId, this.clientId);
}
async artifacts(): Promise<DaemonSessionArtifactsEnvelope> {
return await this.client.listSessionArtifacts(
this.sessionId,
this.clientId,
);
}
async addArtifact(
artifact: DaemonSessionArtifactInput,
): Promise<DaemonSessionArtifactMutationResult> {
return await this.client.addSessionArtifact(
this.sessionId,
artifact,
this.clientId,
);
}
async removeArtifact(
artifactId: string,
): Promise<DaemonSessionArtifactMutationResult> {
return await this.client.removeSessionArtifact(
this.sessionId,
artifactId,
this.clientId,
);
}
async setModel(modelId: string): Promise<SetModelResult> {
return await this.client.setSessionModel(
this.sessionId,

View file

@ -243,6 +243,39 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [
}),
},
},
// GET /session/:id/artifacts → _qwen/session/artifacts
{
httpMethod: 'GET',
pattern: /^\/session\/([^/]+)\/artifacts$/,
mapping: {
method: '_qwen/session/artifacts',
extractParams: (segs) => ({ sessionId: segs[0] }),
},
},
// POST /session/:id/artifacts → _qwen/session/artifacts/add
{
httpMethod: 'POST',
pattern: /^\/session\/([^/]+)\/artifacts$/,
mapping: {
method: '_qwen/session/artifacts/add',
extractParams: (segs, body) => ({
...(isRecord(body) ? body : {}),
sessionId: segs[0],
}),
},
},
// DELETE /session/:id/artifacts/:artifactId → _qwen/session/artifacts/remove
{
httpMethod: 'DELETE',
pattern: /^\/session\/([^/]+)\/artifacts\/([^/]+)$/,
mapping: {
method: '_qwen/session/artifacts/remove',
extractParams: (segs) => ({
sessionId: segs[0],
artifactId: segs[1],
}),
},
},
// POST /session/:id/recap → _qwen/session/recap
{
httpMethod: 'POST',

View file

@ -8,6 +8,7 @@ import type {
DaemonEvent,
DaemonErrorKind,
DaemonMcpTransport,
DaemonSessionArtifactChange,
PermissionOutcome,
} from './types.js';
// Single source of truth: the daemon publisher owns the wire literal in
@ -41,6 +42,7 @@ export const DAEMON_KNOWN_EVENT_TYPE_VALUES = [
'session_died',
'session_closed',
'session_metadata_updated',
'artifact_changed',
MID_TURN_MESSAGE_INJECTED_EVENT,
PENDING_PROMPT_ADDED_EVENT,
PENDING_PROMPT_STARTED_EVENT,
@ -280,6 +282,12 @@ export interface DaemonSessionMetadataUpdatedData {
[key: string]: unknown;
}
export interface DaemonArtifactChangedData {
sessionId: string;
change: DaemonSessionArtifactChange;
[key: string]: unknown;
}
/**
* `mid_turn_message_injected` payload. Emitted when the daemon drains
* browser-queued mid-turn messages into the running turn (web-shell mid-turn
@ -869,6 +877,10 @@ export type DaemonSessionMetadataUpdatedEvent = DaemonEventEnvelope<
'session_metadata_updated',
DaemonSessionMetadataUpdatedData
>;
export type DaemonArtifactChangedEvent = DaemonEventEnvelope<
'artifact_changed',
DaemonArtifactChangedData
>;
export type DaemonMidTurnMessageInjectedEvent = DaemonEventEnvelope<
typeof MID_TURN_MESSAGE_INJECTED_EVENT,
DaemonMidTurnMessageInjectedData
@ -1048,6 +1060,7 @@ export type DaemonSessionEvent =
| DaemonSessionDiedEvent
| DaemonSessionClosedEvent
| DaemonSessionMetadataUpdatedEvent
| DaemonArtifactChangedEvent
| DaemonMidTurnMessageInjectedEvent
| DaemonPendingPromptEvent
| DaemonSessionBranchedEvent;
@ -1498,6 +1511,10 @@ export function asKnownDaemonEvent(
return isSessionMetadataUpdatedData(event.data)
? (event as DaemonSessionMetadataUpdatedEvent)
: undefined;
case 'artifact_changed':
return isArtifactChangedData(event.data)
? (event as DaemonArtifactChangedEvent)
: undefined;
case MID_TURN_MESSAGE_INJECTED_EVENT:
return isMidTurnMessageInjectedData(event.data)
? (event as DaemonMidTurnMessageInjectedEvent)
@ -2017,6 +2034,7 @@ export function reduceDaemonSessionEvent(
case 'mcp_server_removed':
case 'settings_reloaded':
case 'extensions_changed':
case 'artifact_changed':
case MID_TURN_MESSAGE_INJECTED_EVENT:
case PENDING_PROMPT_ADDED_EVENT:
case PENDING_PROMPT_STARTED_EVENT:
@ -2452,6 +2470,22 @@ function isSessionMetadataUpdatedData(
);
}
function isArtifactChangedData(
value: unknown,
): value is DaemonArtifactChangedData {
if (!isRecord(value) || !isNonEmptyString(value['sessionId'])) {
return false;
}
const change = value['change'];
if (!isRecord(change) || !isNonEmptyString(change['artifactId'])) {
return false;
}
return (
isNonEmptyString(change['action']) &&
(change['reason'] === undefined || isNonEmptyString(change['reason']))
);
}
function isMidTurnMessageInjectedData(
value: unknown,
): value is DaemonMidTurnMessageInjectedData {

View file

@ -191,6 +191,8 @@ export {
export type {
DaemonAgentChangedData,
DaemonAgentChangedEvent,
DaemonArtifactChangedData,
DaemonArtifactChangedEvent,
DaemonApprovalModeChangedData,
DaemonApprovalModeChangedEvent,
DaemonClientEvictedData,
@ -499,5 +501,15 @@ export type {
PromptTextContent,
SetModelResult,
SetSessionLanguageResult,
DaemonSessionArtifact,
DaemonSessionArtifactChange,
DaemonSessionArtifactInput,
DaemonSessionArtifactKind,
DaemonSessionArtifactMutationResult,
DaemonSessionArtifactRemovalReason,
DaemonSessionArtifactsEnvelope,
DaemonSessionArtifactSource,
DaemonSessionArtifactStatus,
DaemonSessionArtifactStorage,
SessionMetadataResult,
} from './types.js';

View file

@ -235,6 +235,112 @@ export interface SessionMetadataResult {
displayName?: string;
}
type OpenStringUnion<T extends string> = T | (string & {});
/** Known artifact kinds mirrored from the daemon/core contract. */
export type KnownDaemonSessionArtifactKind =
| 'file'
| 'link'
| 'html'
| 'image'
| 'video'
| 'audio'
| 'pdf'
| 'notebook'
| 'other';
export type DaemonSessionArtifactKind =
OpenStringUnion<KnownDaemonSessionArtifactKind>;
export type KnownDaemonSessionArtifactStorage =
| 'workspace'
| 'external_url'
| 'managed'
| 'published';
export type DaemonSessionArtifactStorage =
OpenStringUnion<KnownDaemonSessionArtifactStorage>;
export type KnownDaemonSessionArtifactSource = 'tool' | 'hook' | 'client';
export type DaemonSessionArtifactSource =
OpenStringUnion<KnownDaemonSessionArtifactSource>;
export type KnownDaemonSessionArtifactStatus = 'available' | 'missing';
export type DaemonSessionArtifactStatus =
OpenStringUnion<KnownDaemonSessionArtifactStatus>;
export interface DaemonSessionArtifactInput {
kind?: KnownDaemonSessionArtifactKind;
storage?: Exclude<KnownDaemonSessionArtifactStorage, 'published'>;
title: string;
description?: string;
workspacePath?: string;
managedId?: string;
url?: string;
mimeType?: string;
sizeBytes?: number;
metadata?: Record<string, string | number | boolean | null>;
}
export interface DaemonSessionArtifact {
id: string;
kind: DaemonSessionArtifactKind;
storage: DaemonSessionArtifactStorage;
source: DaemonSessionArtifactSource;
status: DaemonSessionArtifactStatus;
title: string;
description?: string;
workspacePath?: string;
managedId?: string;
url?: string;
mimeType?: string;
sizeBytes?: number;
metadata?: Record<string, string | number | boolean | null>;
clientRetained: boolean;
createdAt: string;
updatedAt: string;
toolCallId?: string;
toolName?: string;
hookEventName?: string;
clientId?: string;
}
export type KnownDaemonSessionArtifactChangeAction =
| 'created'
| 'updated'
| 'removed';
export type DaemonSessionArtifactChangeAction =
OpenStringUnion<KnownDaemonSessionArtifactChangeAction>;
export type KnownDaemonSessionArtifactRemovalReason = 'eviction' | 'explicit';
export type DaemonSessionArtifactRemovalReason =
OpenStringUnion<KnownDaemonSessionArtifactRemovalReason>;
export interface DaemonSessionArtifactChange {
action: DaemonSessionArtifactChangeAction;
artifactId: string;
artifact?: DaemonSessionArtifact;
reason?: DaemonSessionArtifactRemovalReason;
}
export interface DaemonSessionArtifactsEnvelope {
v: 1;
sessionId: string;
artifacts: DaemonSessionArtifact[];
generatedAt: string;
limits: {
maxArtifacts: number;
};
}
export interface DaemonSessionArtifactMutationResult {
v: 1;
sessionId: string;
changes: DaemonSessionArtifactChange[];
}
export type DaemonStatus =
| 'ok'
| 'warning'

View file

@ -184,6 +184,93 @@ describe('DaemonClient', () => {
});
});
describe('session artifacts', () => {
it('lists session artifacts with an encoded session id', async () => {
const envelope = {
v: 1 as const,
sessionId: 'session/1',
artifacts: [],
generatedAt: '2026-07-01T00:00:00.000Z',
limits: { maxArtifacts: 200 },
};
const { fetch, calls } = recordingFetch(() =>
jsonResponse(200, envelope),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(
client.listSessionArtifacts('session/1', 'client-1'),
).resolves.toEqual(envelope);
expect(calls[0]).toMatchObject({
url: 'http://daemon/session/session%2F1/artifacts',
method: 'GET',
headers: {
'x-qwen-client-id': 'client-1',
},
body: null,
});
});
it('adds session artifacts with client identity and JSON body', async () => {
const result = {
v: 1 as const,
sessionId: 'session/1',
changes: [
{
action: 'created' as const,
artifactId: 'artifact-1',
},
],
};
const { fetch, calls } = recordingFetch(() => jsonResponse(200, result));
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
const artifact = {
title: 'Client report',
url: 'https://example.com/report',
};
await expect(
client.addSessionArtifact('session/1', artifact, 'client-1'),
).resolves.toEqual(result);
expect(calls[0]).toMatchObject({
url: 'http://daemon/session/session%2F1/artifacts',
method: 'POST',
headers: {
'content-type': 'application/json',
'x-qwen-client-id': 'client-1',
},
body: JSON.stringify(artifact),
});
});
it('removes session artifacts with encoded ids and client identity', async () => {
const result = {
v: 1 as const,
sessionId: 'session/1',
changes: [
{
action: 'removed' as const,
artifactId: 'artifact/1',
},
],
};
const { fetch, calls } = recordingFetch(() => jsonResponse(200, result));
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(
client.removeSessionArtifact('session/1', 'artifact/1', 'client-1'),
).resolves.toEqual(result);
expect(calls[0]).toMatchObject({
url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1',
method: 'DELETE',
headers: {
'x-qwen-client-id': 'client-1',
},
body: null,
});
});
});
describe('workspace file helpers', () => {
it('validates daemon content hashes with the daemon regex', () => {
expect(isDaemonContentHash(`sha256:${'a'.repeat(64)}`)).toBe(true);

View file

@ -440,6 +440,77 @@ describe('DaemonSessionClient', () => {
expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1');
});
it('forwards artifact helpers through DaemonClient with the bound clientId', async () => {
const listEnvelope = {
v: 1 as const,
sessionId: 's-1',
artifacts: [],
generatedAt: '2026-07-01T00:00:00.000Z',
limits: { maxArtifacts: 200 },
};
const mutationResult = {
v: 1 as const,
sessionId: 's-1',
changes: [],
};
const { fetch, calls } = recordingFetch((req) => {
if (
req.method === 'GET' &&
req.url === 'http://daemon/session/s-1/artifacts'
) {
return jsonResponse(200, listEnvelope);
}
if (
req.method === 'POST' &&
req.url === 'http://daemon/session/s-1/artifacts'
) {
return jsonResponse(200, mutationResult);
}
if (
req.method === 'DELETE' &&
req.url === 'http://daemon/session/s-1/artifacts/artifact-1'
) {
return jsonResponse(200, mutationResult);
}
return jsonResponse(500, {
error: `unexpected ${req.method} ${req.url}`,
});
});
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
const session = new DaemonSessionClient({
client,
session: {
sessionId: 's-1',
workspaceCwd: '/work/a',
attached: true,
clientId: 'client-1',
},
});
await expect(session.artifacts()).resolves.toEqual(listEnvelope);
await expect(
session.addArtifact({
title: 'Client report',
url: 'https://example.com/report',
}),
).resolves.toEqual(mutationResult);
await expect(session.removeArtifact('artifact-1')).resolves.toEqual(
mutationResult,
);
expect(calls.map((call) => call.headers['x-qwen-client-id'])).toEqual([
'client-1',
'client-1',
'client-1',
]);
expect(calls[1]?.body).toBe(
JSON.stringify({
title: 'Client report',
url: 'https://example.com/report',
}),
);
});
it('forwards recap through DaemonClient with the bound clientId and signal', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(200, {

View file

@ -231,6 +231,45 @@ describe('acpRouteTable matchRoute', () => {
expect(result!.mapping.method).toBe('_qwen/session/heartbeat');
});
it('GET /session/:id/artifacts maps to _qwen/session/artifacts', () => {
const result = matchRoute('/session/s8/artifacts', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/session/artifacts');
expect(
result!.mapping.extractParams(result!.segments, undefined, 'GET'),
).toEqual({ sessionId: 's8' });
});
it('POST /session/:id/artifacts maps to _qwen/session/artifacts/add', () => {
const result = matchRoute('/session/s8/artifacts', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/session/artifacts/add');
expect(
result!.mapping.extractParams(
result!.segments,
{
sessionId: 'body-session',
title: 'Lineage',
url: 'https://example.com/lineage',
},
'POST',
),
).toEqual({
sessionId: 's8',
title: 'Lineage',
url: 'https://example.com/lineage',
});
});
it('DELETE /session/:id/artifacts/:artifactId maps to _qwen/session/artifacts/remove', () => {
const result = matchRoute('/session/s8/artifacts/art%201', 'DELETE');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/session/artifacts/remove');
expect(
result!.mapping.extractParams(result!.segments, undefined, 'DELETE'),
).toEqual({ sessionId: 's8', artifactId: 'art 1' });
});
it('POST /session/:id/recap maps to _qwen/session/recap', () => {
const result = matchRoute('/session/s9/recap', 'POST');
expect(result).not.toBeNull();

View file

@ -155,6 +155,86 @@ describe('daemon event schema', () => {
expect(isDaemonEventType(event, 'trust_change_requested')).toBe(true);
});
it('recognizes artifact_changed as a known daemon event', () => {
const event: DaemonEvent = {
id: 3,
v: 1,
type: 'artifact_changed',
data: {
sessionId: 's-1',
change: {
action: 'created',
artifactId: 'art-1',
artifact: {
id: 'art-1',
kind: 'link',
storage: 'external_url',
source: 'client',
status: 'available',
title: 'Lineage',
url: 'https://example.com/lineage',
clientRetained: true,
createdAt: '2026-06-30T00:00:00.000Z',
updatedAt: '2026-06-30T00:00:00.000Z',
},
},
},
};
const known = asKnownDaemonEvent(event);
expect(known).toBe(event);
expect(known?.type).toBe('artifact_changed');
expect(isDaemonEventType(event, 'artifact_changed')).toBe(true);
});
it('keeps artifact_changed events with future artifact literals', () => {
const event: DaemonEvent = {
id: 4,
v: 1,
type: 'artifact_changed',
data: {
sessionId: 's-1',
change: {
action: 'created',
artifactId: 'art-2',
artifact: {
id: 'art-2',
kind: 'diagram',
storage: 'remote_preview',
source: 'extension',
status: 'warming',
title: 'Future artifact',
url: 'https://example.com/future',
clientRetained: false,
createdAt: '2026-06-30T00:00:00.000Z',
updatedAt: '2026-06-30T00:00:00.000Z',
},
},
},
};
expect(asKnownDaemonEvent(event)).toBe(event);
});
it('keeps artifact_changed events with future change literals', () => {
const event: DaemonEvent = {
id: 5,
v: 1,
type: 'artifact_changed',
data: {
sessionId: 's-1',
change: {
action: 'renamed',
artifactId: 'art-3',
reason: 'lifecycle_policy',
},
},
};
expect(asKnownDaemonEvent(event)).toBe(event);
});
it('leaves malformed or unknown events on the raw DaemonEvent path', () => {
expect(
asKnownDaemonEvent({

View file

@ -2780,7 +2780,7 @@
"default": false
},
"artifact": {
"description": "Enable the Artifact tool (experimental). When enabled, the model can publish a self-contained HTML page as an interactive Artifact and open it in the browser. Interactive, non-SDK sessions only. Can also be enabled via QWEN_CODE_ENABLE_ARTIFACT=1, or hard-disabled via QWEN_CODE_DISABLE_ARTIFACT=1.",
"description": "Enable the Artifact tool (experimental). When enabled, the model can publish a self-contained HTML page as an interactive Artifact and open it in the browser. Interactive, non-SDK sessions only. QWEN_CODE_ENABLE_ARTIFACT=1 enables the metadata-only record_artifact tool for non-SDK daemon sessions, and also enables the Artifact tool in interactive sessions. QWEN_CODE_DISABLE_ARTIFACT=1 hard-disables both.",
"type": "boolean",
"default": false
},