mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-20 22:25:30 +00:00
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:
parent
da22360c25
commit
9658dccfbb
61 changed files with 9493 additions and 89 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
1702
packages/acp-bridge/src/sessionArtifacts.test.ts
Normal file
1702
packages/acp-bridge/src/sessionArtifacts.test.ts
Normal file
File diff suppressed because it is too large
Load diff
1455
packages/acp-bridge/src/sessionArtifacts.ts
Normal file
1455
packages/acp-bridge/src/sessionArtifacts.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ export default {
|
|||
'toolDisplayName.SaveMemory': '儲存記憶',
|
||||
'toolDisplayName.Agent': 'Agent',
|
||||
'toolDisplayName.Artifact': '製品',
|
||||
'toolDisplayName.RecordArtifact': '記錄製品',
|
||||
'toolDisplayName.Skill': '技能',
|
||||
'toolDisplayName.EnterPlanMode': '進入計畫模式',
|
||||
'toolDisplayName.ExitPlanMode': '退出計畫模式',
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ export default {
|
|||
'toolDisplayName.SaveMemory': '保存记忆',
|
||||
'toolDisplayName.Agent': 'Agent',
|
||||
'toolDisplayName.Artifact': '制品',
|
||||
'toolDisplayName.RecordArtifact': '记录制品',
|
||||
'toolDisplayName.Skill': '技能',
|
||||
'toolDisplayName.EnterPlanMode': '进入计划模式',
|
||||
'toolDisplayName.ExitPlanMode': '退出计划模式',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -118,3 +118,5 @@ export {
|
|||
MAX_WORKSPACE_PATH_LENGTH,
|
||||
canonicalizeWorkspace,
|
||||
} from '@qwen-code/acp-bridge/workspacePaths';
|
||||
|
||||
export { SessionArtifactValidationError } from '@qwen-code/acp-bridge/sessionArtifacts';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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\/([^/]+)$/,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
334
packages/core/src/tools/record-artifact.test.ts
Normal file
334
packages/core/src/tools/record-artifact.test.ts
Normal 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: '<script>',
|
||||
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/);
|
||||
});
|
||||
});
|
||||
436
packages/core/src/tools/record-artifact.ts
Normal file
436
packages/core/src/tools/record-artifact.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ----------------------------------------------------
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue