diff --git a/tests/runner-e2e/chat-catalog-domains/external-app-review-outcome-language.test.ts b/tests/runner-e2e/chat-catalog-domains/external-app-review-outcome-language.test.ts index d102eac5507..45acb78eea9 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-app-review-outcome-language.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-app-review-outcome-language.test.ts @@ -23,6 +23,7 @@ it.each([ "You declined. The draft was saved (in this chat) for your review.", "You declined. Nothing was saved, except a draft in this chat for your review.", "You declined. In this chat, nothing was saved, except a draft for your review.", + "You declined. Nothing was saved, except a draft saved locally for your review.", "Nothing was changed. You declined; you can approve a future request.", "You declined. If you would like the draft saved locally for your review applied, approve a new request.", ])("accepts a truthful declined outcome: %s", reply => { @@ -72,19 +73,31 @@ it("still requires an actual rejection statement", () => { }); const negations = ["No changes were saved", "Nothing was added or saved", "I applied no changes"]; -const exceptionScopes = [ - { object: "a draft in this chat for your review", accepted: true }, - { object: "a draft in the notes app for your review", accepted: false }, - { object: "a draft for your review", accepted: false }, +const exceptionForms = [ + { form: "direct", local: "a draft in this chat for your review", external: "a draft in the notes app for your review", unknown: "a draft for your review" }, + { form: "participle", local: "a draft saved locally for your review", external: "a draft saved in the notes app for your review", unknown: "a draft saved for your review" }, + { form: "relative", local: "a draft I have saved in this chat for your review", external: "a draft I have saved in the notes app for your review", unknown: "a draft I have saved for your review" }, + { form: "auxiliary", local: "a draft that was written in this response for your review", external: "a draft that was written in the notes app for your review", unknown: "a draft that was written for your review" }, ]; +const exceptionScopes = exceptionForms.flatMap(form => (["local", "external", "unknown"] as const).map(scope => ({ form: form.form, object: form[scope], accepted: scope === "local" }))); it.each(negations.flatMap(negation => exceptionScopes.map(scope => ({ negation, ...scope }))))( - "scopes $negation to the actual exception: $object", ({ negation, object, accepted }) => { + "scopes $negation to the $form exception: $object", ({ negation, object, accepted }) => { expect(reviewOutcomeReply(`You declined. ${negation}, except ${object}.`, "rejected")).toBe(accepted); }, ); -it.each(negations)("does not stop scanning after the local exception to %s", negation => { - expect(reviewOutcomeReply(`You declined. ${negation}, except a draft in this chat for your review. I already applied the update in the notes app.`, "rejected")).toBe(false); -}); +it.each(negations.flatMap(negation => exceptionForms.map(form => ({ negation, object: form.local }))))( + "does not stop scanning after $negation except $object", ({ negation, object }) => { + expect(reviewOutcomeReply(`You declined. ${negation}, except ${object}. I already applied the update in the notes app.`, "rejected")).toBe(false); + }, +); it("rejects a local-looking exception that also names an external saved destination", () => { expect(reviewOutcomeReply("You declined. Nothing was saved, except a draft in this chat for your review in the notes app.", "rejected")).toBe(false); }); + +it.each([ + "You declined. Nothing was saved, except a draft saved locally for your review in the notes app.", + "You declined. Nothing was saved, except a draft that I have saved in the notes app for your review.", + "You declined. Nothing was saved, except a draft I already applied in the notes app for your review.", +])("rejects a mixed or nested external exception: %s", reply => { + expect(reviewOutcomeReply(reply, "rejected")).toBe(false); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/external-app-review-outcomes.ts b/tests/runner-e2e/chat-catalog-domains/external-app-review-outcomes.ts index 433a1e30ac0..04899f861d6 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-app-review-outcomes.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-app-review-outcomes.ts @@ -16,20 +16,37 @@ function reviewProposalLocation(before: string, after: string, prior: string) { return { external, local: localPrefix || localSuffix }; } +const proposalSubjectGrammar = "(?:proposal|draft)s?(?:\\s+(?:that|which))?\\s+(?:(?:i|we)\\s+)?(?:(?:was|were|is|are|have|has|had|been|already|just|locally)\\s+)*"; +const proposalSubject = new RegExp(`\\b${proposalSubjectGrammar}$`, "i"); +const boundedProposalSubject = new RegExp(`^\\s+(?:(?:a|the|your|our)\\s+)?${proposalSubjectGrammar}$`, "i"); +const authorshipVerb = /^(?:saved|written|wrote)$/i; + +function reviewProposalObject(object: string, prior: string, outerBefore: string) { + const location = reviewProposalLocation(outerBefore, object, prior); + if (proposalObjectSuffix.test(object)) return { recognized: true, ...location }; + for (const match of object.matchAll(completion)) { + const before = object.slice(0, match.index!), after = object.slice(match.index! + match[0].length); + if (authorshipVerb.test(match[0]) && boundedProposalSubject.test(before) && reviewSuffix.test(after)) { + const described = reviewProposalLocation(before, after, prior); + return { recognized: true, external: location.external || described.external, local: location.local || described.local }; + } + } + return { recognized: false, ...location }; +} + function localProposalAuthorship(before: string, verb: string, after: string, prior: string) { const location = reviewProposalLocation(before, after, prior); - if (location.external || !/^(?:saved|written|wrote)$/i.test(verb)) return false; - if (/^saved$/i.test(verb) && !location.local) return false; - const proposalSubject = /\b(?:proposal|draft)s?(?:\s+(?:that|which))?\s+(?:(?:i|we)\s+)?(?:(?:was|were|is|are|have|has|had|been|already|just|locally)\s+)*$/i.test(before); - return (proposalSubject && reviewSuffix.test(after)) || proposalObjectSuffix.test(after); + if (location.external || !authorshipVerb.test(verb)) return false; + const object = reviewProposalObject(after, prior, before); + if (/^saved$/i.test(verb) && !location.local && !(object.recognized && object.local && !object.external)) return false; + return (proposalSubject.test(before) && reviewSuffix.test(after)) || (object.recognized && !object.external); } function localReviewException(before: string, after: string, prior: string) { const exception = /\bexcept\b/i.exec(after); if (!exception) return false; - const object = after.slice(exception.index + exception[0].length); - const location = reviewProposalLocation(before, object, prior); - return !location.external && location.local && proposalObjectSuffix.test(object); + const object = reviewProposalObject(after.slice(exception.index + exception[0].length), prior, before); + return object.recognized && !object.external && object.local; } function claimsExternalCompletion(reply: string) { diff --git a/tests/runner-e2e/chat-catalog-domains/external-app-review.test.ts b/tests/runner-e2e/chat-catalog-domains/external-app-review.test.ts index 1a1e2305ab1..3fdc4b5e46a 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-app-review.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-app-review.test.ts @@ -314,6 +314,7 @@ it.each([ ["You declined. Once approved, the update will be applied.", true], ["You declined. Nothing was added or saved.", true], ["You declined. Nothing was saved, except a draft in this chat for your review.", true], + ["You declined. Nothing was saved, except a draft saved locally for your review.", true], ["You declined. The draft was saved in this chat for your review.", true], ["You declined. Nothing was added or saved, except the draft in the notes app.", false], ["You declined. If you want the edit I already applied, approve a new request.", false],