Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/cli/src/__tests__/conformance/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,8 @@ function sampleInlineAliasValue(key: InlineAliasKey): unknown {
case 'fontSize':
case 'fontSizeCs':
return 14;
case 'fontFamily':
return 'Courier New';
case 'letterSpacing':
return 0.5;
case 'position':
Expand Down
74 changes: 74 additions & 0 deletions apps/cli/src/__tests__/lib/invoke-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, test } from 'bun:test';
import { extractInvokeInput } from '../../lib/invoke-input';
import { CliError } from '../../lib/errors';

describe('extractInvokeInput', () => {
test('converts replace flat range flags into a single-block SelectionTarget', () => {
const input = extractInvokeInput('replace', {
doc: 'fixture.docx',
blockId: 'p1',
start: 2,
end: 5,
text: 'Updated',
}) as Record<string, unknown>;

expect(input).toEqual({
text: 'Updated',
target: {
kind: 'selection',
start: { kind: 'text', blockId: 'p1', offset: 2 },
end: { kind: 'text', blockId: 'p1', offset: 5 },
},
});
});

test('upgrades legacy TextAddress target-json input for format.apply', () => {
const input = extractInvokeInput('format.apply', {
target: {
kind: 'text',
blockId: 'p1',
range: { start: 0, end: 4 },
},
inline: { bold: true },
}) as Record<string, unknown>;

expect(input).toEqual({
target: {
kind: 'selection',
start: { kind: 'text', blockId: 'p1', offset: 0 },
end: { kind: 'text', blockId: 'p1', offset: 4 },
},
inline: { bold: true },
});
});

test('preserves text-address targets for comments.create', () => {
const input = extractInvokeInput('comments.create', {
blockId: 'p1',
start: 1,
end: 3,
text: 'Review this',
}) as Record<string, unknown>;

expect(input).toEqual({
text: 'Review this',
target: {
kind: 'text',
blockId: 'p1',
range: { start: 1, end: 3 },
},
});
});

test('rejects collapsed legacy text ranges for format operations', () => {
expect(() =>
extractInvokeInput('format.bold', {
target: {
kind: 'text',
blockId: 'p1',
range: { start: 2, end: 2 },
},
}),
).toThrow(CliError);
});
});
74 changes: 74 additions & 0 deletions apps/cli/src/cli/__tests__/schema-ref-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,80 @@ describe('operation-params deriveParamsFromInputSchema with $ref', () => {
expect(modeParam).toBeDefined();
expect(modeParam!.type).toBe('string'); // enum → string
});

test('derives params from top-level allOf by merging object members', () => {
const inputSchema = {
allOf: [
{
oneOf: [
{
type: 'object',
properties: {
target: { $ref: '#/$defs/TextAddress' },
},
required: ['target'],
},
{
type: 'object',
properties: {
ref: { type: 'string' },
},
required: ['ref'],
},
],
},
{
type: 'object',
properties: {
text: { type: 'string' },
},
required: ['text'],
},
],
};
const { params } = deriveParamsFromInputSchema(inputSchema, $defs);
const paramNames = params.map((param) => param.name);

expect(paramNames).toContain('target');
expect(paramNames).toContain('ref');
expect(paramNames).toContain('text');
expect(params.find((param) => param.name === 'text')?.required).toBe(true);
expect(params.find((param) => param.name === 'target')?.required).toBe(false);
expect(params.find((param) => param.name === 'ref')?.required).toBe(false);
});

test('merges duplicate properties across oneOf branches into a single param schema', () => {
const inputSchema = {
oneOf: [
{
type: 'object',
properties: {
target: { $ref: '#/$defs/TextAddress' },
text: { type: 'string' },
},
required: ['target', 'text'],
},
{
type: 'object',
properties: {
target: {
oneOf: [{ $ref: '#/$defs/TextAddress' }, { type: 'string' }],
},
content: { type: 'object' },
},
required: ['target', 'content'],
},
],
};
const { params } = deriveParamsFromInputSchema(inputSchema, $defs);
const targetParam = params.find((param) => param.name === 'target');

expect(targetParam).toBeDefined();
expect(targetParam!.type).toBe('json');
expect((targetParam!.schema as { oneOf: CliTypeSpec[] }).oneOf.length).toBeGreaterThan(1);
expect(params.find((param) => param.name === 'text')).toBeDefined();
expect(params.find((param) => param.name === 'content')).toBeDefined();
});
});

// ---------------------------------------------------------------------------
Expand Down
129 changes: 127 additions & 2 deletions apps/cli/src/cli/operation-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ const USER_EMAIL_PARAM: CliOperationParamSpec = {
type JsonSchema = Record<string, unknown>;
const AGENT_HIDDEN_PARAM_NAMES = new Set(['out']);

type ObjectSchemaVariant = {
properties: Record<string, JsonSchema>;
required: Set<string>;
};

function resolveRef(schema: JsonSchema, $defs?: Record<string, JsonSchema>): JsonSchema {
if (schema.$ref && $defs) {
const prefix = '#/$defs/';
Expand All @@ -90,6 +95,108 @@ function resolveRef(schema: JsonSchema, $defs?: Record<string, JsonSchema>): Jso
return schema;
}

function hasObjectShape(schema: JsonSchema): boolean {
return schema.type === 'object' || schema.properties != null || schema.required != null;
}

function cloneVariant(variant: ObjectSchemaVariant): ObjectSchemaVariant {
return {
properties: { ...variant.properties },
required: new Set(variant.required),
};
}

function directObjectVariant(schema: JsonSchema): ObjectSchemaVariant {
return {
properties: {
...(((schema.properties as Record<string, JsonSchema> | undefined) ?? {}) as Record<string, JsonSchema>),
},
required: new Set<string>(((schema.required as string[] | undefined) ?? []) as string[]),
};
}

function schemasEqual(left: JsonSchema, right: JsonSchema): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}

function mergePropertySchemas(left: JsonSchema, right: JsonSchema): JsonSchema {
if (schemasEqual(left, right)) return left;

const variants: JsonSchema[] = [];
const appendVariant = (schema: JsonSchema) => {
if (variants.some((candidate) => schemasEqual(candidate, schema))) return;
variants.push(schema);
};

if (Array.isArray(left.oneOf)) {
for (const entry of left.oneOf as JsonSchema[]) appendVariant(entry);
} else {
appendVariant(left);
}

if (Array.isArray(right.oneOf)) {
for (const entry of right.oneOf as JsonSchema[]) appendVariant(entry);
} else {
appendVariant(right);
}

return variants.length === 1 ? variants[0]! : { oneOf: variants };
}

function mergeObjectVariants(left: ObjectSchemaVariant, right: ObjectSchemaVariant): ObjectSchemaVariant {
const merged = cloneVariant(left);
for (const [name, schema] of Object.entries(right.properties)) {
const existing = merged.properties[name];
merged.properties[name] = existing ? mergePropertySchemas(existing, schema) : schema;
}
for (const key of right.required) {
merged.required.add(key);
}
return merged;
}

function extractObjectSchemaVariants(rawSchema: JsonSchema, $defs?: Record<string, JsonSchema>): ObjectSchemaVariant[] {
const schema = resolveRef(rawSchema, $defs);
const directVariants = hasObjectShape(schema) ? [directObjectVariant(schema)] : [];
let variants = directVariants.length > 0 ? directVariants.map(cloneVariant) : [];

if (Array.isArray(schema.allOf)) {
variants = variants.length > 0 ? variants : [{ properties: {}, required: new Set<string>() }];
for (const member of schema.allOf as JsonSchema[]) {
const memberVariants = extractObjectSchemaVariants(member, $defs);
if (memberVariants.length === 0) continue;

const nextVariants: ObjectSchemaVariant[] = [];
for (const base of variants) {
for (const part of memberVariants) {
nextVariants.push(mergeObjectVariants(base, part));
}
}
variants = nextVariants;
}
}

const alternativeKeyword = Array.isArray(schema.oneOf) ? 'oneOf' : Array.isArray(schema.anyOf) ? 'anyOf' : null;
if (alternativeKeyword) {
const branches = (schema[alternativeKeyword] as JsonSchema[]).flatMap((member) =>
extractObjectSchemaVariants(member, $defs),
);
if (branches.length > 0) {
const baseVariants = variants.length > 0 ? variants : [{ properties: {}, required: new Set<string>() }];
const nextVariants: ObjectSchemaVariant[] = [];
for (const base of baseVariants) {
for (const branch of branches) {
nextVariants.push(mergeObjectVariants(base, branch));
}
}
variants = nextVariants;
}
}

if (variants.length > 0) return variants;
return hasObjectShape(schema) ? [directObjectVariant(schema)] : [];
}

function schemaToParamType(schema: JsonSchema, $defs?: Record<string, JsonSchema>): CliOperationParamSpec['type'] {
schema = resolveRef(schema, $defs);
if (schema.type === 'string') return 'string';
Expand Down Expand Up @@ -167,8 +274,26 @@ function deriveParamsFromInputSchema(
} {
const params: CliOperationParamSpec[] = [];
const positionalParams: string[] = [];
const properties = (inputSchema.properties ?? {}) as Record<string, JsonSchema>;
const required = new Set<string>((inputSchema.required as string[]) ?? []);
const variants = extractObjectSchemaVariants(inputSchema, $defs);
const properties: Record<string, JsonSchema> = {};
const requiredCounts = new Map<string, number>();

for (const variant of variants) {
for (const [name, schema] of Object.entries(variant.properties)) {
const existing = properties[name];
properties[name] = existing ? mergePropertySchemas(existing, schema) : schema;
}
for (const name of variant.required) {
requiredCounts.set(name, (requiredCounts.get(name) ?? 0) + 1);
}
}

const required = new Set<string>();
for (const [name] of Object.entries(properties)) {
if (variants.length > 0 && requiredCounts.get(name) === variants.length) {
required.add(name);
}
}

for (const [name, rawPropSchema] of Object.entries(properties)) {
const propSchema = resolveRef(rawPropSchema, $defs);
Expand Down
Loading
Loading