qwen-code/integration-tests/chat-transcript-contract.test.ts
Heyang Wang 5003ab3c7f
feat(web-shell): add transcript contract prevalidation (#9388)
* test(web-shell): add transcript contract prevalidation

Freeze reproducible evidence for current transcript paths before any
VS Code or HTML export production migration.

- Add versioned fixtures, closed export schema, and capability gates
- Probe direct-daemon and ACP identity under partial history prepend
- Preserve raw adapter semantics and full write_file Turn Output diffs
- Document the two-MR architecture, security constraints, and blockers

* fix(web-shell): harden transcript prevalidation gates

Make the evidence-only contract suite enforce the review assumptions it
documents while preserving the existing runtime transcript behavior.

- Run the contract suite in the required no-AK integration job
- Fail closed on ambiguous identity probes and deduplicate gate kinds
- Enforce manifest, hash, export safety, and renderer version boundaries
- Cover visible transcript text and stable Desktop packaging semantics
- Record the complete PR comment evaluation and verification outcome

* fix(web-shell): close transcript prevalidation gaps

* fix(web-shell): remove brittle Desktop wiring probe

Keep transcript contract prevalidation at the evidence level it can
actually prove. The previous source-text assertion could both reject
equivalent formatting and pass unreachable packaging code.

- Remove the Desktop script parser and its false behavioral claim
- Mark installed-artifact verification as deferred to Desktop smoke tests
- Clarify MR1 matrix, CI wiring, and provenance evidence boundaries
- Refresh the hash-locked capability matrix fixture

Note: This does not change Web Shell or Desktop production behavior.

---------

Co-authored-by: heyang.why <heyang.why@alibaba-inc.com>
2026-08-19 14:13:12 +00:00

591 lines
18 KiB
TypeScript

import { createHash } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
createDaemonTranscriptState,
DAEMON_ERROR_KINDS,
normalizeDaemonEvent,
reduceDaemonTranscriptEvents,
type DaemonEvent,
type DaemonTranscriptBlock,
} from '@qwen-code/sdk/daemon';
import { projectChatRecordsToDaemonTranscript } from '@qwen-code/sdk/daemon/transcript';
import { transcriptBlocksToDaemonMessages } from '../packages/web-shell/client/adapters/transcriptToMessages.js';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const fixtureRoot = resolve(
repoRoot,
'integration-tests/fixtures/chat-transcript-contract/v1',
);
const caseRoot = resolve(fixtureRoot, 'cases/representative');
interface FixtureManifest {
readonly fixtureVersion: number;
readonly name: string;
readonly generatorVersion?: string;
readonly sources: readonly string[];
readonly consumers: readonly string[];
readonly capabilities: readonly string[];
readonly complete: boolean;
readonly expectedDiagnostics: readonly string[];
readonly normalizedFields?: readonly string[];
readonly hashes: Readonly<Record<string, string>>;
}
interface ExpectedModel {
readonly kinds: readonly string[];
readonly texts: readonly string[];
readonly sourceRecordIds: readonly (readonly string[])[];
}
interface ExpectedRenderItems {
readonly roles: readonly string[];
readonly expectedTextContent: readonly string[];
readonly runtimeFields: readonly string[];
readonly expectedToolArgs: Readonly<Record<string, unknown>>;
readonly expectedToolResult: unknown;
}
interface ExpectedExportContract {
readonly schemaVersion: number;
readonly forbiddenFields: readonly string[];
readonly frozenErrorKinds: readonly string[];
readonly timestamps: number;
readonly implementation: string;
}
interface IdentityCandidateResult {
readonly status: 'fail';
readonly stableUnderPartialPrepend: false;
readonly unstableBlockKinds: readonly string[];
readonly missingNativeTextIdentity: readonly string[];
}
interface ExpectedGate {
readonly overall: 'fail';
readonly selectedVscodePath: null;
readonly candidates: {
readonly directDaemon: IdentityCandidateResult;
readonly acp: IdentityCandidateResult;
};
readonly blockers: readonly string[];
}
function readJson<T>(path: string): T {
return JSON.parse(readFileSync(path, 'utf8')) as T;
}
function readJsonLines<T>(path: string): T[] {
return readFileSync(path, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line) as T);
}
function sha256(path: string): string {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}
function listFixtureEvidenceFiles(
directory: string,
relativeDirectory = '',
): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const relativePath = relativeDirectory
? `${relativeDirectory}/${entry.name}`
: entry.name;
if (entry.isDirectory()) {
return listFixtureEvidenceFiles(
resolve(directory, entry.name),
relativePath,
);
}
return relativePath === 'cases/representative/manifest.json'
? []
: [relativePath];
});
}
function expectManifestToMatchSchema(
manifest: FixtureManifest,
schema: Record<string, unknown>,
): void {
const properties = schema['properties'] as Record<
string,
Record<string, unknown>
>;
const required = schema['required'];
expect(properties).toBeTypeOf('object');
expect(required).toBeInstanceOf(Array);
expect(schema['additionalProperties']).toBe(false);
const allowedKeys = new Set(Object.keys(properties));
for (const key of Object.keys(manifest)) {
expect(allowedKeys.has(key), `manifest property ${key}`).toBe(true);
}
for (const key of required as string[]) {
expect(manifest, `required manifest property ${key}`).toHaveProperty(key);
}
const nameSchema = properties['name'];
expect(manifest.name.length).toBeGreaterThanOrEqual(
nameSchema?.['minLength'] as number,
);
expect(manifest.name.length).toBeLessThanOrEqual(
nameSchema?.['maxLength'] as number,
);
const capabilitySchema = properties['capabilities'];
const capabilityItemSchema = capabilitySchema?.['items'] as Record<
string,
unknown
>;
expect(manifest.capabilities.length).toBeGreaterThanOrEqual(
capabilitySchema?.['minItems'] as number,
);
expect(new Set(manifest.capabilities)).toHaveLength(
manifest.capabilities.length,
);
for (const capability of manifest.capabilities) {
expect(capability).toBeTypeOf('string');
expect(capability.length).toBeLessThanOrEqual(
capabilityItemSchema['maxLength'] as number,
);
}
const hashSchema = properties['hashes']?.['additionalProperties'] as Record<
string,
unknown
>;
const hashPattern = new RegExp(hashSchema['pattern'] as string, 'u');
for (const [relativePath, hash] of Object.entries(manifest.hashes)) {
expect(relativePath).not.toBe('cases/representative/manifest.json');
expect(hash, relativePath).toMatch(hashPattern);
}
}
function collectDeclaredSchemaProperties(
value: unknown,
names = new Set<string>(),
): Set<string> {
if (Array.isArray(value)) {
for (const item of value) collectDeclaredSchemaProperties(item, names);
return names;
}
if (!value || typeof value !== 'object') return names;
for (const [key, item] of Object.entries(value)) {
if (key === 'properties' && item && typeof item === 'object') {
for (const propertyName of Object.keys(item)) names.add(propertyName);
}
collectDeclaredSchemaProperties(item, names);
}
return names;
}
function reduceDaemonEvents(
events: readonly DaemonEvent[],
): readonly DaemonTranscriptBlock[] {
let state = createDaemonTranscriptState({ now: 0 });
for (const event of events) {
state = reduceDaemonTranscriptEvents(state, normalizeDaemonEvent(event), {
now: 0,
});
}
return state.blocks;
}
function reduceAcpUpdates(
updates: readonly unknown[],
): readonly DaemonTranscriptBlock[] {
return reduceDaemonEvents(
updates.map(
(update): DaemonEvent => ({
v: 1,
type: 'session_update',
data: { update },
}),
),
);
}
function blockSemanticKey(block: DaemonTranscriptBlock): string {
switch (block.kind) {
case 'user':
case 'assistant':
case 'thought':
return `${block.kind}:${block.text}`;
case 'tool':
return `tool:${block.toolCallId}`;
case 'permission':
return `permission:${block.requestId}`;
default:
throw new Error(`Unsupported identity probe block kind: ${block.kind}`);
}
}
function indexBlocksBySemanticKey(
blocks: readonly DaemonTranscriptBlock[],
label: 'complete' | 'partial',
): ReadonlyMap<string, DaemonTranscriptBlock> {
const indexed = new Map<string, DaemonTranscriptBlock>();
for (const block of blocks) {
const key = blockSemanticKey(block);
if (indexed.has(key)) {
throw new Error(`Ambiguous ${label} identity probe semantic key: ${key}`);
}
indexed.set(key, block);
}
return indexed;
}
function probeIdentity(
complete: readonly DaemonTranscriptBlock[],
partial: readonly DaemonTranscriptBlock[],
): IdentityCandidateResult {
const completeBySemanticKey = indexBlocksBySemanticKey(complete, 'complete');
const partialBySemanticKey = indexBlocksBySemanticKey(partial, 'partial');
const unstableBlockKinds = [
...new Set(
[...partialBySemanticKey].flatMap(([key, block]) => {
const completeBlock = completeBySemanticKey.get(key);
if (!completeBlock) {
throw new Error(`Missing complete identity probe block: ${key}`);
}
return completeBlock.id !== block.id ? [block.kind] : [];
}),
),
];
const missingNativeTextIdentity = [
...new Set(
complete.flatMap((block) => {
if (
block.kind !== 'user' &&
block.kind !== 'assistant' &&
block.kind !== 'thought'
) {
return [];
}
return block.sourceRecordIds?.length || block.promptId
? []
: [block.kind];
}),
),
];
expect(unstableBlockKinds.length).toBeGreaterThan(0);
return {
status: 'fail',
stableUnderPartialPrepend: false,
unstableBlockKinds,
missingNativeTextIdentity,
};
}
describe('chat transcript contract prevalidation', () => {
it('locks the evidence fixtures, schemas, and fail-first capability decision', () => {
const manifest = readJson<FixtureManifest>(
resolve(caseRoot, 'manifest.json'),
);
const manifestSchema = readJson<Record<string, unknown>>(
resolve(fixtureRoot, 'schema/manifest.schema.json'),
);
const exportSchema = readJson<Record<string, unknown>>(
resolve(fixtureRoot, 'schema/export-transcript-document-v1.schema.json'),
);
const expectedExport = readJson<ExpectedExportContract>(
resolve(caseRoot, 'expected-export.json'),
);
const matrix = readFileSync(
resolve(fixtureRoot, 'capability-matrix.md'),
'utf8',
);
expectManifestToMatchSchema(manifest, manifestSchema);
const manifestWithUnknownProperty = {
...manifest,
unknownProperty: true,
};
expect(() =>
expectManifestToMatchSchema(manifestWithUnknownProperty, manifestSchema),
).toThrow(/manifest property unknownProperty/u);
expect(manifest.fixtureVersion).toBe(1);
expect(manifest.complete).toBe(true);
expect(new Set(manifest.sources)).toEqual(
new Set(['daemon', 'acp', 'chat-records']),
);
expect(new Set(manifest.consumers)).toEqual(
new Set(['web', 'tauri', 'vscode', 'html']),
);
expect(manifest.name).toBe('representative');
expect(manifest.generatorVersion).toBe(
'chat-transcript-prevalidation-evidence-v1',
);
expect(new Set(manifest.capabilities)).toEqual(
new Set([
'semantic-projection',
'runtime-raw-compatibility',
'stable-identity-prepend-probe',
'export-document-schema',
'two-mr-migration-gate',
]),
);
expect(manifest.expectedDiagnostics).toEqual([
'direct_daemon_unstable_identity',
'acp_unstable_identity',
]);
expect(manifest.normalizedFields).toEqual([
'clientReceivedAt',
'createdAt',
'updatedAt',
]);
expect(manifestSchema['additionalProperties']).toBe(false);
expect(exportSchema['additionalProperties']).toBe(false);
const exportDefinitions = exportSchema['$defs'] as Record<string, unknown>;
const blockSchema = exportDefinitions['block'] as {
oneOf: Array<{ $ref: string }>;
};
expect(blockSchema.oneOf).toHaveLength(10);
for (const definitionName of ['statusBlock', 'errorBlock']) {
const definition = exportDefinitions[definitionName] as {
properties: { errorKind: { enum: string[] } };
};
expect(definition.properties.errorKind.enum).toEqual(
expectedExport.frozenErrorKinds,
);
}
for (const errorKind of expectedExport.frozenErrorKinds) {
expect(
DAEMON_ERROR_KINDS,
`Export V1 error kind ${errorKind} must remain supported by the SDK`,
).toContain(errorKind);
}
const declaredExportProperties =
collectDeclaredSchemaProperties(exportSchema);
for (const field of expectedExport.forbiddenFields) {
expect(declaredExportProperties.has(field), field).toBe(false);
}
const permissionOption = exportDefinitions['permissionOption'] as {
properties: { raw: { const: unknown } };
};
const toolBlock = exportDefinitions['toolBlock'] as {
properties: Record<string, unknown>;
};
const statusBlock = exportDefinitions['statusBlock'] as {
properties: Record<string, unknown>;
};
const errorBlock = exportDefinitions['errorBlock'] as {
properties: Record<string, unknown>;
};
expect(toolBlock.properties).not.toHaveProperty('content');
expect(statusBlock.properties).not.toHaveProperty('data');
expect(errorBlock.properties).not.toHaveProperty('data');
expect(permissionOption.properties.raw.const).toBeNull();
expect(expectedExport).toMatchObject({
schemaVersion: 1,
timestamps: 0,
implementation: 'deferred-to-mr2',
});
expect(Object.keys(manifest.hashes).sort()).toEqual(
listFixtureEvidenceFiles(fixtureRoot).sort(),
);
for (const [relativePath, expectedHash] of Object.entries(
manifest.hashes,
)) {
expect(sha256(resolve(fixtureRoot, relativePath))).toBe(expectedHash);
}
const exportProperties = exportSchema['properties'] as Record<
string,
Record<string, unknown>
>;
const rendererVersionPattern = new RegExp(
exportProperties['rendererVersion']?.['pattern'] as string,
'u',
);
for (const validVersion of [
'1.2.3',
'1.2.3-beta.1+build.7',
'a'.repeat(64),
]) {
expect(validVersion, validVersion).toMatch(rendererVersionPattern);
}
for (const invalidVersion of [
'LATEST',
'latest',
'1.0.0 - 2.0.0',
'1.x',
'1.0.0 || 2.0.0',
'^1.2.3',
'~1.2.3',
'*',
'>=1.0.0',
]) {
expect(invalidVersion, invalidVersion).not.toMatch(
rendererVersionPattern,
);
}
expect(matrix).toContain('FAIL — migration blocked');
expect(matrix).toContain('No VS Code transport is selected in MR1');
expect(matrix).not.toMatch(/pass; selected/i);
});
it('preserves current ChatRecord and Web Shell runtime semantics', () => {
const records = readJsonLines<unknown>(
resolve(caseRoot, 'chat-records.jsonl'),
);
const expected = readJson<ExpectedModel>(
resolve(caseRoot, 'expected-model.json'),
);
const expectedRender = readJson<ExpectedRenderItems>(
resolve(caseRoot, 'expected-render-items.json'),
);
const projection = projectChatRecordsToDaemonTranscript(records);
const messages = transcriptBlocksToDaemonMessages(projection.blocks);
const toolBlock = projection.blocks.find((block) => block.kind === 'tool');
const toolMessage = messages.find(
(message) => message.role === 'tool_group',
);
expect(projection.complete).toBe(true);
expect(projection.diagnostics).toEqual([]);
expect(projection.blocks.map((block) => block.kind)).toEqual(
expected.kinds,
);
expect(
projection.blocks.flatMap((block) => {
switch (block.kind) {
case 'user':
case 'assistant':
case 'thought':
return [block.text];
default:
return [];
}
}),
).toEqual(expected.texts);
expect(
projection.blocks.map((block) => block.sourceRecordIds ?? []),
).toEqual(expected.sourceRecordIds);
expect(messages.map((message) => message.role)).toEqual(
expectedRender.roles,
);
expect(
messages.flatMap((message) => {
switch (message.role) {
case 'user':
case 'thinking':
case 'assistant':
return [message.content];
default:
return [];
}
}),
).toEqual(expectedRender.expectedTextContent);
expect(toolBlock).toMatchObject({
rawInput: expectedRender.expectedToolArgs,
rawOutput: expectedRender.expectedToolResult,
});
expect(toolMessage).toMatchObject({
tools: [
{
args: expectedRender.expectedToolArgs,
rawOutput: expectedRender.expectedToolResult,
},
],
});
expect(expectedRender.runtimeFields).toEqual(['rawInput', 'rawOutput']);
});
it('records both VS Code identity candidates as reproducible blockers', () => {
const daemonEvents = readJsonLines<DaemonEvent>(
resolve(caseRoot, 'daemon-events.jsonl'),
);
const acpUpdates = readJsonLines<unknown>(
resolve(caseRoot, 'acp-session-updates.jsonl'),
);
const expectedGate = readJson<ExpectedGate>(
resolve(caseRoot, 'expected-gate.json'),
);
const observedGate: ExpectedGate = {
overall: 'fail',
selectedVscodePath: null,
candidates: {
directDaemon: probeIdentity(
reduceDaemonEvents(daemonEvents),
reduceDaemonEvents(daemonEvents.slice(1)),
),
acp: probeIdentity(
reduceAcpUpdates(acpUpdates),
reduceAcpUpdates(acpUpdates.slice(1)),
),
},
blockers: [
'direct-daemon uses reducer ordinal block IDs that change when history is prepended',
'ACP text updates do not carry a stable source identity and inherit the same ordinal block IDs',
],
};
expect(observedGate).toEqual(expectedGate);
});
it('fails closed on ambiguous identity keys and records kind sets', () => {
const assistantBlock = (
id: string,
text: string,
): DaemonTranscriptBlock => ({
id,
kind: 'assistant',
clientReceivedAt: 0,
createdAt: 0,
updatedAt: 0,
text,
});
expect(() =>
probeIdentity(
[assistantBlock('complete-1', 'duplicate')],
[
assistantBlock('partial-1', 'duplicate'),
assistantBlock('partial-2', 'duplicate'),
],
),
).toThrow(/Ambiguous partial identity probe semantic key/u);
expect(
probeIdentity(
[
assistantBlock('complete-1', 'first'),
assistantBlock('complete-2', 'second'),
],
[
assistantBlock('partial-1', 'first'),
assistantBlock('partial-2', 'second'),
],
),
).toEqual({
status: 'fail',
stableUnderPartialPrepend: false,
unstableBlockKinds: ['assistant'],
missingNativeTextIdentity: ['assistant'],
});
expect(() =>
probeIdentity(
[
{
id: 'status-1',
kind: 'status',
clientReceivedAt: 0,
createdAt: 0,
updatedAt: 0,
text: 'status',
},
],
[],
),
).toThrow(/Unsupported identity probe block kind: status/u);
});
});