Skip to content

Commit

Permalink
Initialize translate with history tests (#687)
Browse files Browse the repository at this point in the history
- Add `@history insert` command to insert chat history entries.
- Add the first translation with history test
- Type validation: Improve error message with union type mismatch
  • Loading branch information
curtisman authored Feb 8, 2025
1 parent 83f8e5b commit de90c24
Show file tree
Hide file tree
Showing 15 changed files with 372 additions and 123 deletions.
3 changes: 3 additions & 0 deletions ts/packages/actionSchema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,8 @@ export {
fromJSONActionSchemaFile,
} from "./serialize.js";

// Generic (non-action) Schema
export { validateType } from "./validate.js";

// Schema Config
export { SchemaConfig, ParamSpec, ActionParamSpecs } from "./schemaConfig.js";
40 changes: 40 additions & 0 deletions ts/packages/actionSchema/src/toString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { SchemaType } from "./type.js";

export function toStringSchemaType(
type: SchemaType,
paran: boolean = false,
): string {
let result: string;
switch (type.type) {
case "string":
return "string";
case "number":
return "number";
case "boolean":
return "boolean";
case "undefined":
return "undefined";
case "object":
return `{ ${Object.entries(type.fields)
.map(([name, field]) => {
return `${name}${field.optional ? "?" : ""}: ${toStringSchemaType(field.type)}`;
})
.join(", ")}}`;
case "array":
return `${toStringSchemaType(type.elementType, true)}[]`;
case "type-reference":
return type.definition ? type.definition.name : "unknown";
case "string-union":
result = type.typeEnum.join(" | ");
paran = paran && type.typeEnum.length > 1;
break;
case "type-union":
result = type.types.map((t) => toStringSchemaType(t)).join(" | ");
paran = paran && type.types.length > 1;
break;
}
return paran ? `(${result})` : result;
}
43 changes: 33 additions & 10 deletions ts/packages/actionSchema/src/validate.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,52 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { toStringSchemaType } from "./toString.js";
import {
SchemaTypeArray,
SchemaTypeObject,
SchemaType,
ActionSchemaTypeDefinition,
} from "./type.js";

function errorName(name: string) {
return name === "" ? "Input" : `Field '${name}'`;
}

function indentMessage(message: string) {
return `${message.replace(/\n/g, "\n ")}`;
}

export function validateSchema(
name: string,
expected: SchemaType,
actual: unknown,
coerce: boolean = false, // coerce string to the right primitive type
) {
if (actual === null) {
throw new Error(`'${name}' should not be null`);
throw new Error(`${errorName(name)} should not be null`);
}
switch (expected.type) {
case "type-union": {
const errors: [SchemaType, Error][] = [];
for (const type of expected.types) {
try {
validateSchema(name, type, actual, coerce);
return;
} catch (e) {
// ignore
} catch (e: any) {
errors.push([type, e]);
}
}
throw new Error(`'${name}' does not match any union type`);
const messages = errors
.map(
([type, e], i) =>
`\n-- Type: ${toStringSchemaType(type)}\n-- Error: ${indentMessage(e.message)}`,
)
.join("\n");

throw new Error(
`${errorName(name)} does not match any union type\n${messages}`,
);
}
case "type-reference":
if (expected.definition !== undefined) {
Expand All @@ -37,7 +56,7 @@ export function validateSchema(
case "object":
if (typeof actual !== "object" || Array.isArray(actual)) {
throw new Error(
`'${name}' is not an object, got ${Array.isArray(actual) ? "array" : typeof actual} instead`,
`${errorName(name)} is not an object, got ${Array.isArray(actual) ? "array" : typeof actual} instead`,
);
}
validateObject(
Expand All @@ -50,15 +69,15 @@ export function validateSchema(
case "array":
if (!Array.isArray(actual)) {
throw new Error(
`'${name}' is not an array, got ${typeof actual} instead`,
`${errorName(name)} is not an array, got ${typeof actual} instead`,
);
}
validateArray(name, expected, actual, coerce);
break;
case "string-union":
if (typeof actual !== "string") {
throw new Error(
`'${name}' is not a string, got ${typeof actual} instead`,
`${errorName(name)} is not a string, got ${typeof actual} instead`,
);
}
if (!expected.typeEnum.includes(actual)) {
Expand All @@ -67,7 +86,7 @@ export function validateSchema(
? `${expected.typeEnum[0]}`
: `one of ${expected.typeEnum.map((s) => `'${s}'`).join(",")}`;
throw new Error(
`'${name}' is not ${expectedValues}, got ${actual} instead`,
`${errorName(name)} is not ${expectedValues}, got ${actual} instead`,
);
}
break;
Expand All @@ -92,7 +111,7 @@ export function validateSchema(
}
}
throw new Error(
`'${name}' is not a ${expected.type}, got ${typeof actual} instead`,
`${errorName(name)} is not a ${expected.type}, got ${typeof actual} instead`,
);
}
}
Expand Down Expand Up @@ -131,7 +150,7 @@ function validateObject(
const fullName = name ? `${name}.${fieldName}` : fieldName;
if (actualValue === undefined) {
if (!fieldInfo.optional) {
throw new Error(`Missing required property ${fullName}`);
throw new Error(`Missing required property '${fullName}'`);
}
continue;
}
Expand Down Expand Up @@ -159,3 +178,7 @@ export function validateAction(
) {
validateObject("", actionSchema.type, action, coerce, ["translatorName"]);
}

export function validateType(type: SchemaType, value: any) {
validateSchema("", type, value);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[
{
"request": "play that song again",
"history": {
"user": "play some random songs",
"assistant": { "text": "Now playing: Soruwienf from album Wxifiel with artist Bnefisoe", "source": "player", "entities": [
{"name": "Soruwienf", "type": ["track", "song"], "uniqueId": "a"},
{"name": "Wxifiel", "type": ["album"], "uniqueId": "b"},
{"name": "Bnefisoe", "type": ["artist"], "uniqueId": "c"}
]}
},
"action": {"translatorName": "player","actionName": "playTrack", "parameters": {"trackName": "Soruwienf", "albumName": "Wxifiel", "artists": ["Bnefisoe"]}}
}
]
73 changes: 2 additions & 71 deletions ts/packages/defaultAgentProvider/test/translate.test.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import dotenv from "dotenv";
dotenv.config({ path: new URL("../../../../.env", import.meta.url) });

import { getPackageFilePath } from "../src/utils/getPackageFilePath.js";
import { getDefaultAppAgentProviders } from "../src/defaultAgentProviders.js";
import fs from "node:fs";
import { createDispatcher, Dispatcher } from "agent-dispatcher";

import { defineTranslateTest } from "./translateTestCommon.js";
const dataFiles = ["test/data/translate-e2e.json"];

type TranslateTestRequest = {
request: string;
action: string | string[];
};
type TranslateTestEntry = TranslateTestRequest | TranslateTestRequest[];
type TranslateTestFile = TranslateTestEntry[];

const inputs: TranslateTestEntry[] = (
await Promise.all(
dataFiles.map<Promise<TranslateTestFile>>(async (f) => {
return JSON.parse(
await fs.promises.readFile(getPackageFilePath(f), "utf-8"),
);
}),
)
).flat();

const repeat = 5;
const defaultAppAgentProviders = getDefaultAppAgentProviders(undefined);

describe("translation action stability", () => {
let dispatchers: Dispatcher[];
beforeAll(async () => {
const dispatcherP: Promise<Dispatcher>[] = [];
for (let i = 0; i < repeat; i++) {
dispatcherP.push(
createDispatcher("cli test translate", {
appAgentProviders: defaultAppAgentProviders,
actions: null,
commands: { dispatcher: true },
translation: { history: { enabled: false } },
explainer: { enabled: false },
cache: { enabled: false },
}),
);
}
dispatchers = await Promise.all(dispatcherP);
});
it.each(inputs)("translate '$request'", async (test) => {
const requests = Array.isArray(test) ? test : [test];
await Promise.all(
dispatchers.map(async (dispatcher) => {
for (const { request, action } of requests) {
const result = await dispatcher.processCommand(request);
expect(result?.actions).toBeDefined();

const expected =
typeof action === "string" ? [action] : action;
expect(result?.actions).toHaveLength(expected.length);
for (let i = 0; i < expected.length; i++) {
expect(
`${result?.actions?.[i].translatorName}.${result?.actions?.[i].actionName}`,
).toBe(expected[i]);
}
}
}),
);
});
afterAll(async () => {
await Promise.all(dispatchers.map((d) => d.close()));
dispatchers = [];
});
});
await defineTranslateTest("translate (no history)", dataFiles);
106 changes: 106 additions & 0 deletions ts/packages/defaultAgentProvider/test/translateTestCommon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import dotenv from "dotenv";
dotenv.config({ path: new URL("../../../../.env", import.meta.url) });

import { getPackageFilePath } from "../src/utils/getPackageFilePath.js";
import { getDefaultAppAgentProviders } from "../src/defaultAgentProviders.js";
import fs from "node:fs";
import { createDispatcher, Dispatcher } from "agent-dispatcher";
import { ChatHistoryInput } from "agent-dispatcher/internal";
import { FullAction } from "agent-cache";

type TranslateTestRequest = {
request: string;
action: string | string[] | FullAction | FullAction[];
history?: ChatHistoryInput | ChatHistoryInput[];
match?: "exact" | "partial"; // default to "exact"
};
type TranslateTestEntry = TranslateTestRequest | TranslateTestRequest[];
type TranslateTestFile = TranslateTestEntry[];
const repeat = 5;
const defaultAppAgentProviders = getDefaultAppAgentProviders(undefined);

export async function defineTranslateTest(name: string, dataFiles: string[]) {
const inputs: TranslateTestEntry[] = (
await Promise.all(
dataFiles.map<Promise<TranslateTestFile>>(async (f) => {
return JSON.parse(
await fs.promises.readFile(getPackageFilePath(f), "utf-8"),
);
}),
)
).flat();

describe(`${name} action stability`, () => {
let dispatchers: Dispatcher[];
beforeAll(async () => {
const dispatcherP: Promise<Dispatcher>[] = [];
for (let i = 0; i < repeat; i++) {
dispatcherP.push(
createDispatcher("cli test translate", {
appAgentProviders: defaultAppAgentProviders,
actions: null,
commands: { dispatcher: true },
translation: { history: { enabled: false } },
explainer: { enabled: false },
cache: { enabled: false },
}),
);
}
dispatchers = await Promise.all(dispatcherP);
});
it.each(inputs)(`${name} '$request'`, async (test) => {
const requests = Array.isArray(test) ? test : [test];
await Promise.all(
dispatchers.map(async (dispatcher) => {
for (const {
request,
history,
action,
match,
} of requests) {
if (history !== undefined) {
await dispatcher.processCommand(
`@history insert ${JSON.stringify(history)}`,
);
}
const result = await dispatcher.processCommand(request);
const actions = result?.actions;
expect(actions).toBeDefined();

const expectedValues = Array.isArray(action)
? action
: [action];
expect(actions).toHaveLength(expectedValues.length);
for (let i = 0; i < expectedValues.length; i++) {
const action = actions![i];
const expected = expectedValues[i];
if (typeof expected === "string") {
const actualFullActionName = `${action.translatorName}.${action.actionName}`;
if (match === "partial") {
expect(actualFullActionName).toContain(
expected,
);
} else {
expect(actualFullActionName).toBe(expected);
}
} else {
if (match === "partial") {
expect(action).toMatchObject(expected);
} else {
expect(action).toEqual(expected);
}
}
}
}
}),
);
});
afterAll(async () => {
await Promise.all(dispatchers.map((d) => d.close()));
dispatchers = [];
});
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { defineTranslateTest } from "./translateTestCommon.js";
const dataFiles = ["test/data/translate-history-e2e.json"];

await defineTranslateTest("translate (w/history)", dataFiles);
Loading

0 comments on commit de90c24

Please sign in to comment.