Skip to content

Commit 3b96d80

Browse files
authored
Merge pull request #1398 from Open-Source-Legal/claude/resolve-issue-1385-BCMkJ
Fix inline-agent default instructions for document triggers (#1385)
2 parents 380c694 + 0f803ec commit 3b96d80

9 files changed

Lines changed: 85 additions & 42 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323

2424
### Fixed
2525

26+
- **`CreateCorpusActionModal` opened with the wrong default agent instructions for document triggers** (Issue #1385, `frontend/src/components/corpuses/CreateCorpusActionModal.tsx:136-144,168-171`): the `inlineAgentInstructions` state was initialised with `DEFAULT_MODERATOR_INSTRUCTIONS` even though the default trigger is `add_document` (a document trigger). The trigger-change handler at line 611 swaps to `DEFAULT_DOCUMENT_AGENT_INSTRUCTIONS`, but a user who created an inline agent on the default-selected trigger without first re-selecting the trigger would submit the moderator copy as the new agent's system instructions. Initialised both the `useState` default and `resetForm()` to `DEFAULT_DOCUMENT_AGENT_INSTRUCTIONS` so the pre-interaction value matches the default trigger. Updated `frontend/tests/CreateCorpusActionModal.ct.tsx` "inline-agent create: full happy path" mutation mock to expect `DEFAULT_DOCUMENT_AGENT_INSTRUCTIONS` — the previous mock variable masked this bug because `MockedProvider` was matching the stale moderator default rather than the trigger-appropriate one.
27+
28+
### Changed
29+
30+
- **Test/type cleanup follow-ups from the PR #1383 review** (Issue #1385):
31+
- Pinned the `isProcessing` contract for SYNC_CONTENT in `frontend/tests/CorpusChat.ct.tsx` "SYNC_CONTENT renders a complete message immediately": added an `expect(input).toBeEnabled()` assertion after the reply renders, locking the documented invariant that `setIsProcessing(true)` is owned solely by `ASYNC_START` and that a SYNC_CONTENT-only reply must never disable the input.
32+
- Consolidated the duplicated `::: oc-component` fence dispatcher: extracted `OcComponentBlock` interface and a new `buildOcComponentCustomBlocks(renderMarkdown)` helper into `frontend/src/utils/camlComponents.ts`. Both `frontend/src/hooks/useCamlComponentRenderer.tsx` and `frontend/src/components/corpuses/caml/CamlDirectiveRenderer.tsx` now share the same helper instead of each casting `block` independently.
33+
- Replaced `route: any` and `page: any` escape hatches with the proper `Route` and `Page` types from `@playwright/test` in `frontend/tests/CorpusDescriptionEditor.ct.tsx` (`setupMdRoute` and the abort-route test).
34+
- Migrated `.version-number` CSS-class locators in `frontend/tests/CorpusDescriptionEditor.ct.tsx` to a semantic `data-testid="version-number"` matcher (`page.getByTestId("version-number")`); added the test id to the rendered version-row in `frontend/src/components/corpuses/CorpusDescriptionEditor.tsx`.
35+
2636
- **`test_superuser_sees_all_queryset` miscounts personal corpuses by 1** (Issue #1394, `opencontractserver/tests/test_visibility_managers.py`, `opencontractserver/tests/test_resolvers.py`): Two `VisibleToUserTests.test_superuser_sees_all_queryset` cases asserted that `Corpus.objects.visible_to_user(superuser).count() == 4` (public + private + 2 personal), but the actual count is 5 because the test DB starts with a pre-existing personal corpus owned by django-guardian's `AnonymousUser` (created during fixture setup before/around the username-based skip in `opencontractserver/users/signals.py::user_created_signal`). The assertion is now scoped to corpuses created by the test's two users (`creator__in=[self.user, self.superuser]`), making it resilient to any fixture-level corpuses that exist at test DB init time. Production code is unchanged.
2737
- **Merged `frontend` Codecov flag drops to ~33% on every commit where Frontend CI's CT job fails** (`frontend/package.json` `test:coverage:ct`): the script chained `playwright test ... && mkdir -p ... && nyc report ...`, so a failing CT run short-circuited before `nyc report` could turn the per-test JSON files in `.nyc_output` into an `lcov.info`. The downstream `Upload CT Coverage to Codecov` step (`if: success() || failure()`) then errored with "No coverage reports found" and `frontend-component` did not upload for that SHA. Codecov's server-side aggregation of the `frontend` flag was left with only `frontend-unit` (~23%) and `frontend-e2e` (~24%), pulling the merged number down to ~33% even though the previous commit was at ~67% — observed on six consecutive main commits 2026-04-26T01:02..02:58Z (`2d7033f8`..`be5bcfc8`) before recovering on `30298391`. Mirrored the existing `test:e2e:coverage` pattern (`; CT_EXIT=$?; nyc report ... || echo "No coverage data to report"; exit $CT_EXIT`) so `nyc report` runs regardless of test outcome and the lcov ships even on red CT runs. `frontend-component` will still report a slightly lower number when tests fail (failed tests register fewer hits), but it will report — keeping the merged `frontend` flag's denominator stable.
2838
- **`User.__init__` shared-state mutation re-introduced by branch merge** (`opencontractserver/users/models.py:172-180` removed): PR #1374 (commit `50ed6740`) deleted the `User.__init__` override that mutated `Field.validators[0]` on every instantiation, but a subsequent merge (`b68c1cb4 → 6d2cddbf`) resurrected the override along with its mypy-narrowing changes. The current main on commit `6d2cddbf` therefore reproduced the original `#1358` bug: `User(...)` rebound `username_field.validators[0]` and clobbered any third-party validator prepended to the list. Removed the `__init__` override entirely; the class-body declaration `validators=[UserUnicodeUsernameValidator()]` on the `username` field (still present from PR #1374) is the canonical and only declaration. Also dropped the now-unused `Field` import. Regression coverage from PR #1374 (`opencontractserver/tests/test_user_username_validator.py`) was already on main and is what surfaced the regression in CI.

frontend/src/components/corpuses/CorpusDescriptionEditor.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1078,7 +1078,10 @@ export const CorpusDescriptionEditor: React.FC<
10781078
whileTap={{ scale: 0.98 }}
10791079
>
10801080
<div className="version-header">
1081-
<div className="version-number">
1081+
<div
1082+
className="version-number"
1083+
data-testid="version-number"
1084+
>
10821085
Version {revision.version}
10831086
{revision.version === currentVersion && (
10841087
<span className="version-badge">

frontend/src/components/corpuses/CreateCorpusActionModal.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,13 @@ export const CreateCorpusActionModal: React.FC<
133133
const [inlineAgentName, setInlineAgentName] = React.useState("");
134134
const [inlineAgentDescription, setInlineAgentDescription] =
135135
React.useState("");
136+
// Initial trigger is "add_document" (a document trigger), so the default
137+
// instructions must match — otherwise the textarea opens with the moderator
138+
// copy that only applies to thread/message triggers. The dropdown's onChange
139+
// swaps this value when the trigger changes; this initialiser keeps the
140+
// pre-interaction state aligned with the default trigger.
136141
const [inlineAgentInstructions, setInlineAgentInstructions] = React.useState(
137-
DEFAULT_MODERATOR_INSTRUCTIONS
142+
DEFAULT_DOCUMENT_AGENT_INSTRUCTIONS
138143
);
139144
const [selectedModerationTools, setSelectedModerationTools] = React.useState<
140145
string[]
@@ -162,7 +167,9 @@ export const CreateCorpusActionModal: React.FC<
162167
setUseInlineAgent(true);
163168
setInlineAgentName("");
164169
setInlineAgentDescription("");
165-
setInlineAgentInstructions(DEFAULT_MODERATOR_INSTRUCTIONS);
170+
// Reset to the default-trigger instructions; the trigger itself is reset to
171+
// "add_document" above, so the document-agent default is the matching pair.
172+
setInlineAgentInstructions(DEFAULT_DOCUMENT_AGENT_INSTRUCTIONS);
166173
setSelectedModerationTools(DEFAULT_MODERATION_TOOLS.map((t) => t.name));
167174
setSelectedDocumentTools([]);
168175
setDisabled(false);

frontend/src/components/corpuses/caml/CamlDirectiveRenderer.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
DirectiveHandlerContext,
2727
} from "./directiveRegistry";
2828
import {
29-
OC_COMPONENT_FENCE,
29+
buildOcComponentCustomBlocks,
3030
resolveComponentMarker,
3131
type CamlComponentRegistry,
3232
} from "../../../utils/camlComponents";
@@ -193,14 +193,7 @@ export const CamlDirectiveRenderer: React.FC<CamlDirectiveRendererProps> = ({
193193
// plain `[component:TYPE ...]` marker, so we delegate to the same
194194
// renderMarkdown path that handles inline markers.
195195
const customBlocks = useMemo(
196-
() => ({
197-
[OC_COMPONENT_FENCE]: (block: unknown) => {
198-
const body = (
199-
(block as { body?: string } | undefined)?.body ?? ""
200-
).trim();
201-
return renderMarkdown(body);
202-
},
203-
}),
196+
() => buildOcComponentCustomBlocks(renderMarkdown),
204197
[renderMarkdown]
205198
);
206199

frontend/src/hooks/useCamlComponentRenderer.tsx

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,11 @@ import { MarkdownMessageRenderer } from "../components/threads/MarkdownMessageRe
2222
import { ErrorBoundary } from "../components/widgets/ErrorBoundary";
2323
import { ComponentEmbedErrorFallback } from "../components/widgets/ComponentEmbedErrorFallback";
2424
import {
25-
OC_COMPONENT_FENCE,
25+
buildOcComponentCustomBlocks,
2626
resolveComponentMarker,
2727
} from "../utils/camlComponents";
2828
export type { CamlComponentRegistry } from "../utils/camlComponents";
2929

30-
interface OcComponentBlock {
31-
type: string;
32-
body?: string;
33-
attrs?: Record<string, string>;
34-
}
35-
3630
export interface CamlComponentRendererBindings {
3731
/** Pass to `<CamlArticle renderMarkdown={...}>`. */
3832
renderMarkdown: (md: string) => React.ReactNode;
@@ -76,12 +70,7 @@ export function useCamlComponentRenderer(
7670
);
7771

7872
const customBlocks = useMemo(
79-
() => ({
80-
[OC_COMPONENT_FENCE]: (block: unknown) => {
81-
const body = ((block as OcComponentBlock)?.body ?? "").trim();
82-
return renderMarkdown(body);
83-
},
84-
}),
73+
() => buildOcComponentCustomBlocks(renderMarkdown),
8574
[renderMarkdown]
8675
);
8776

frontend/src/utils/camlComponents.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,18 @@ export function buildComponentMarker(
137137
*/
138138
export const OC_COMPONENT_FENCE = "oc-component";
139139

140+
/**
141+
* Shape of a parsed `::: oc-component` block produced by @os-legal/caml.
142+
* Used by `customBlocks` handlers when the renderer dispatches an
143+
* `oc-component` fence. Only `body` is read by the OpenContracts dispatcher;
144+
* the other fields are included for completeness.
145+
*/
146+
export interface OcComponentBlock {
147+
type: string;
148+
body?: string;
149+
attrs?: Record<string, string>;
150+
}
151+
140152
/**
141153
* Wrap a marker in a CAML fence ready for insertion into the editor source.
142154
*
@@ -180,3 +192,23 @@ export function resolveComponentMarker(
180192
if (!Component) return null;
181193
return React.createElement(Component, { key, ...parsed.props });
182194
}
195+
196+
/**
197+
* Build the `customBlocks` object passed to `<CamlArticle>` for the
198+
* project-specific `::: oc-component` fence.
199+
*
200+
* Both `useCamlComponentRenderer` and `CamlDirectiveRenderer` need to forward
201+
* the fence body through their own `renderMarkdown` so the inline-marker path
202+
* can resolve registered components. Centralising the lookup keeps the
203+
* `OcComponentBlock` cast and the body-extraction logic in one place.
204+
*/
205+
export function buildOcComponentCustomBlocks(
206+
renderMarkdown: (md: string) => React.ReactNode
207+
): Record<string, (block: unknown) => React.ReactNode> {
208+
return {
209+
[OC_COMPONENT_FENCE]: (block: unknown) => {
210+
const body = ((block as OcComponentBlock | undefined)?.body ?? "").trim();
211+
return renderMarkdown(body);
212+
},
213+
};
214+
}

frontend/tests/CorpusChat.ct.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,6 +1000,12 @@ test.describe("CorpusChat", () => {
10001000
timeout: 10000,
10011001
});
10021002

1003+
// SYNC_CONTENT arrives without a preceding ASYNC_START, so isProcessing
1004+
// must never flip to true and the input must remain interactive after the
1005+
// reply lands. Pinning this guards the contract documented in CorpusChat:
1006+
// ASYNC_START is the only setter for setIsProcessing(true).
1007+
await expect(input).toBeEnabled({ timeout: 5000 });
1008+
10031009
await component.unmount();
10041010
});
10051011

frontend/tests/CorpusDescriptionEditor.ct.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from "react";
2+
import type { Page, Route } from "@playwright/test";
23
import { test, expect } from "./utils/coverage";
34
import { MockedResponse } from "@apollo/client/testing";
45
import { CorpusDescriptionEditorTestWrapper } from "./CorpusDescriptionEditorTestWrapper";
@@ -87,8 +88,8 @@ const buildCorpusMockNoMd = (): MockedResponse => ({
8788
},
8889
});
8990

90-
const setupMdRoute = async (page: any, body: string = INITIAL_MD) => {
91-
await page.route("**/test-md/**", async (route: any) => {
91+
const setupMdRoute = async (page: Page, body: string = INITIAL_MD) => {
92+
await page.route("**/test-md/**", async (route: Route) => {
9293
await route.fulfill({
9394
status: 200,
9495
contentType: "text/markdown",
@@ -237,7 +238,7 @@ test.describe("CorpusDescriptionEditor", () => {
237238
await expect(page.getByText("Version History")).toBeVisible({
238239
timeout: 5000,
239240
});
240-
await expect(page.locator(".version-number")).toContainText("Version 1");
241+
await expect(page.getByTestId("version-number")).toContainText("Version 1");
241242

242243
// Hide again
243244
await page
@@ -271,7 +272,7 @@ test.describe("CorpusDescriptionEditor", () => {
271272
.getByRole("button", { name: /Show History/, exact: false })
272273
.click();
273274

274-
const versionNumber = page.locator(".version-number");
275+
const versionNumber = page.getByTestId("version-number");
275276
await expect(versionNumber).toContainText("Version 1", { timeout: 5000 });
276277

277278
// Click the version row to expand details
@@ -425,7 +426,7 @@ test.describe("CorpusDescriptionEditor", () => {
425426
await page
426427
.getByRole("button", { name: /Show History/, exact: false })
427428
.click();
428-
const versionNumber = page.locator(".version-number");
429+
const versionNumber = page.getByTestId("version-number");
429430
await expect(versionNumber).toContainText("Version 1", { timeout: 5000 });
430431
await versionNumber.first().click();
431432

@@ -488,7 +489,7 @@ test.describe("CorpusDescriptionEditor", () => {
488489
await page
489490
.getByRole("button", { name: /Show History/, exact: false })
490491
.click();
491-
const versionNumber = page.locator(".version-number");
492+
const versionNumber = page.getByTestId("version-number");
492493
await expect(versionNumber).toContainText("Version 1", { timeout: 5000 });
493494
await versionNumber.first().click();
494495

@@ -628,7 +629,7 @@ test.describe("CorpusDescriptionEditor", () => {
628629
.getByRole("button", { name: /Show History/, exact: false })
629630
.click();
630631

631-
const versionNumber = page.locator(".version-number");
632+
const versionNumber = page.getByTestId("version-number");
632633
await expect(versionNumber).toContainText("Version 1", { timeout: 5000 });
633634
await versionNumber.first().click();
634635

@@ -662,7 +663,7 @@ test.describe("CorpusDescriptionEditor", () => {
662663
.getByRole("button", { name: /Show History/, exact: false })
663664
.click();
664665

665-
const versionNumber = page.locator(".version-number");
666+
const versionNumber = page.getByTestId("version-number");
666667
await expect(versionNumber).toContainText("Version 1", { timeout: 5000 });
667668

668669
// Expand
@@ -718,7 +719,7 @@ test.describe("CorpusDescriptionEditor", () => {
718719
.click();
719720

720721
// Expand the OLDER version (v1) — not the current one
721-
const versionRows = page.locator(".version-number");
722+
const versionRows = page.getByTestId("version-number");
722723
const olderRow = versionRows.filter({ hasText: "Version 1" }).first();
723724
await olderRow.click();
724725

@@ -745,7 +746,7 @@ test.describe("CorpusDescriptionEditor", () => {
745746
}) => {
746747
// Abort the network request so the fetch promise rejects and the
747748
// component's .catch() branch (setCurrentContent("")) runs.
748-
await page.route("**/test-md/**", async (route: any) => {
749+
await page.route("**/test-md/**", async (route: Route) => {
749750
await route.abort("failed");
750751
});
751752

frontend/tests/CreateCorpusActionModal.ct.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
CREATE_CORPUS_ACTION,
1414
UPDATE_CORPUS_ACTION,
1515
} from "../src/graphql/mutations";
16-
import { DEFAULT_MODERATOR_INSTRUCTIONS } from "../src/assets/configurations/constants";
16+
import { DEFAULT_DOCUMENT_AGENT_INSTRUCTIONS } from "../src/assets/configurations/constants";
1717
import { docScreenshot } from "./utils/docScreenshot";
1818

1919
const TEST_CORPUS_ID = "corpus-123";
@@ -900,8 +900,10 @@ test.describe("CreateCorpusActionModal", () => {
900900
}) => {
901901
let successCount = 0;
902902

903-
// DEFAULT_MODERATOR_INSTRUCTIONS is imported from the source module so the
904-
// mutation variables stay in sync if the default ever changes.
903+
// The default trigger is "add_document" (a document trigger), so the
904+
// textarea initialises with DEFAULT_DOCUMENT_AGENT_INSTRUCTIONS. Importing
905+
// the constant keeps the mutation variables in sync if the default copy
906+
// ever changes.
905907
const inlineCreateMock: MockedResponse = {
906908
request: {
907909
query: CREATE_CORPUS_ACTION,
@@ -917,10 +919,10 @@ test.describe("CreateCorpusActionModal", () => {
917919
createAgentInline: true,
918920
inlineAgentName: "Inline Doc Agent",
919921
inlineAgentDescription: undefined,
920-
// The component initializes the agent-instructions textarea with
921-
// DEFAULT_MODERATOR_INSTRUCTIONS; this test does not fill it and
922-
// relies on that default matching the mutation variable.
923-
inlineAgentInstructions: DEFAULT_MODERATOR_INSTRUCTIONS,
922+
// The test does not fill the agent-instructions textarea, so the
923+
// submitted value is whatever the component initialises it to for the
924+
// default (document-add) trigger.
925+
inlineAgentInstructions: DEFAULT_DOCUMENT_AGENT_INSTRUCTIONS,
924926
inlineAgentTools: ["read_document_text", "update_document_summary"],
925927
disabled: false,
926928
runOnAllCorpuses: false,

0 commit comments

Comments
 (0)