Unified · Split
| @@ -791,6 +791,7 @@ scenario( · 790 unmodified lines | ||
| 791 | 791 | |
| 792 | 792 | const ARTIFACT_TOOLS = [ |
| 793 | 793 | "create-artifact", |
| 794 | + "edit-artifact", | |
| 794 | 795 | "list-artifacts", |
| 795 | 796 | "show-artifact", |
| 796 | 797 | "execute-action", |
| @@ -818,7 +819,12 @@ scenario( · 21 unmodified lines | ||
| 818 | 819 | // URL, gets everything by default. Without this the assertions above would |
| 819 | 820 | // also pass on a server that had simply lost artifacts. |
| 820 | 821 | const defaultTools = yield* defaultSession.listTools(); |
| 821 | − for (const tool of ["create-artifact", "list-artifacts", "show-artifact"] as const) { | |
| 822 | + for (const tool of [ | |
| 823 | + "create-artifact", | |
| 824 | + "edit-artifact", | |
| 825 | + "list-artifacts", | |
| 826 | + "show-artifact", | |
| 827 | + ] as const) { | |
| 822 | 828 | expect(defaultTools, `${tool} is present on a default session`).toContain(tool); |
| 823 | 829 | } |
| 824 | 830 | const defaultSkills = yield* defaultSession.call("skills", {}); |
| @@ -113,22 +113,32 @@ const CREATE_ARTIFACT_SKILL_BODY = [ · 112 unmodified lines | ||
| 113 | 113 | "UPDATING that artifact, not making another one. A second artifact for a revision", |
| 114 | 114 | "leaves the user with two near-identical entries and a stale link to the old one.", |
| 115 | 115 | "", |
| 116 | − "1. `show-artifact({ id })` to get the current source back. Do not rewrite from memory.", | |
| 117 | − "2. Edit that source.", | |
| 118 | − "3. `create-artifact` with the same `artifactId` and the complete edited component.", | |
| 116 | + "For a TWEAK — a new column, a fixed label, a restyled section — use `edit-artifact`:", | |
| 117 | + "exact find-and-replace patches against the stored source, so you send only the", | |
| 118 | + "changed lines instead of re-emitting the whole component.", | |
| 119 | 119 | "", |
| 120 | 120 | "```", |
| 121 | − "code: '<the whole edited component>'", | |
| 122 | 121 | 'artifactId: "art_..."', |
| 122 | + "edits: [{ oldText: '<exact current text>', newText: '<replacement>' }]", | |
| 123 | 123 | "```", |
| 124 | 124 | "", |
| 125 | − "`code` fully replaces what was stored — send the whole component, never a fragment", | |
| 126 | − "or a diff. `title` and `description` are optional on an update: leave them out to", | |
| 127 | − "keep the current ones, pass them to change them. Connection bindings are", | |
| 128 | − "re-resolved from the new code, so a call to a new integration binds on the update", | |
| 129 | − "the same way it would on a create (and pass `connections` if it is ambiguous).", | |
| 130 | − "The artifact keeps its id and its URL, so anyone holding the link sees the new", | |
| 131 | − "version.", | |
| 125 | + "Each `oldText` must match the current source verbatim (whitespace included) and", | |
| 126 | + "exactly once — include surrounding lines to disambiguate, or set", | |
| 127 | + "`replaceAll: true` to change every occurrence. Edits apply in order, each seeing", | |
| 128 | + "the previous one's result, and the batch is atomic: on any miss nothing is saved", | |
| 129 | + "and the error returns the current source in `structuredContent.code`, so rebuild", | |
| 130 | + "the retry from that rather than calling `show-artifact` again. If you don't have", | |
| 131 | + "the source at all (the artifact came from `list-artifacts`), `show-artifact` once", | |
| 132 | + "and edit from what it returns — never from memory.", | |
| 133 | + "", | |
| 134 | + "For a REWRITE, where most of the code changes, edits would be longer than the code:", | |
| 135 | + "call `create-artifact` with the same `artifactId` and the complete new component.", | |
| 136 | + "`code` then fully replaces what was stored — the whole component, never a fragment.", | |
| 137 | + "", | |
| 138 | + "Both paths validate and smoke-render the result identically, keep the artifact's id", | |
| 139 | + "and URL, and re-resolve connection bindings from the resulting code (pass", | |
| 140 | + "`connections` if it is ambiguous). `title` and `description` are optional on both:", | |
| 141 | + "leave them out to keep the current ones.", | |
| 132 | 142 | "", |
| 133 | 143 | "Only omit `artifactId` when the user genuinely wants a NEW, separate UI.", |
| 134 | 144 | "", |
| @@ -331,7 +341,7 @@ const CREATE_ARTIFACT_SKILL_BODY = [ · 196 unmodified lines | ||
| 331 | 341 | "", |
| 332 | 342 | "- `list-artifacts` returns every saved artifact's id, title, description and last-updated time.", |
| 333 | 343 | "- `show-artifact({ id })` re-renders one and returns its source. Match the user's phrasing against the titles and descriptions from `list-artifacts` rather than guessing an id.", |
| 334 | − '- To change one, edit that source and call `create-artifact` with its `artifactId` — see "Changing An Artifact That Already Exists" above.', | |
| 344 | + '- To change one, patch it with `edit-artifact` (or rewrite it with `create-artifact` + `artifactId`) — see "Changing An Artifact That Already Exists" above.', | |
| 335 | 345 | "- Clients that cannot display MCP apps get a link to the artifact in the web app instead; pass that URL on to the user verbatim.", |
| 336 | 346 | ].join("\n"); |
| 337 | 347 | |
| @@ -208,9 +208,10 @@ describe("MCP host — artifact tool visibility", () => { · 207 unmodified lines | ||
| 208 | 208 | NO_APPS_CAPS, |
| 209 | 209 | async (client) => { |
| 210 | 210 | const names = await toolNames(client); |
| 211 | − // The model-facing three are always advertised — they degrade to a | |
| 211 | + // The model-facing tools are always advertised — they degrade to a | |
| 212 | 212 | // deep link rather than disappearing. |
| 213 | 213 | expect(names).toContain("create-artifact"); |
| 214 | + expect(names).toContain("edit-artifact"); | |
| 214 | 215 | expect(names).toContain("list-artifacts"); |
| 215 | 216 | expect(names).toContain("show-artifact"); |
| 216 | 217 | // `execute-action` is only callable from inside a rendered app. |
| @@ -491,6 +492,7 @@ describe("MCP host — artifact tool visibility", () => { · 274 unmodified lines | ||
| 491 | 492 | async (client) => { |
| 492 | 493 | const names = await toolNames(client); |
| 493 | 494 | expect(names).not.toContain("create-artifact"); |
| 495 | + expect(names).not.toContain("edit-artifact"); | |
| 494 | 496 | expect(names).not.toContain("list-artifacts"); |
| 495 | 497 | expect(names).not.toContain("show-artifact"); |
| 496 | 498 | expect(names).not.toContain("execute-action"); |
| @@ -554,6 +556,7 @@ describe("MCP host — artifact tool visibility", () => { · 57 unmodified lines | ||
| 554 | 556 | async (client) => { |
| 555 | 557 | const names = await toolNames(client); |
| 556 | 558 | expect(names).toContain("create-artifact"); |
| 559 | + expect(names).toContain("edit-artifact"); | |
| 557 | 560 | expect(names).toContain("list-artifacts"); |
| 558 | 561 | expect(names).toContain("show-artifact"); |
| 559 | 562 | expect(names).toContain("execute-action"); |
| @@ -1683,6 +1686,295 @@ describe("MCP host — create-artifact update in place", () => { · 1123 unmodified lines | ||
| 1683 | 1686 | }); |
| 1684 | 1687 | }); |
| 1685 | 1688 | |
| 1689 | +// --------------------------------------------------------------------------- | |
| 1690 | +// edit-artifact — patch-based updates | |
| 1691 | +// --------------------------------------------------------------------------- | |
| 1692 | +// | |
| 1693 | +// The contract: an edit is exact find-and-replace against the stored source, | |
| 1694 | +// applied in order, all-or-nothing, and the result goes through the same | |
| 1695 | +// validate → smoke-render → bind → save pipeline as a full create. A failed | |
| 1696 | +// batch changes nothing and hands the current source back so the retry needs | |
| 1697 | +// no show-artifact round trip. | |
| 1698 | + | |
| 1699 | +describe("MCP host — edit-artifact", () => { | |
| 1700 | + const seed = async (client: Client, code: string = COUNTER_CODE) => | |
| 1701 | + client.callTool({ | |
| 1702 | + name: "create-artifact", | |
| 1703 | + arguments: { code, title: "Draft", description: "First pass" }, | |
| 1704 | + }); | |
| 1705 | + | |
| 1706 | + it("patches the stored source and delivers the edited component", async () => { | |
| 1707 | + const store = makeArtifactStore(); | |
| 1708 | + await withClient( | |
| 1709 | + makeStubEngine({}), | |
| 1710 | + APPS_CAPS, | |
| 1711 | + async (client) => { | |
| 1712 | + await seed(client); | |
| 1713 | + const edited = await client.callTool({ | |
| 1714 | + name: "edit-artifact", | |
| 1715 | + arguments: { | |
| 1716 | + artifactId: "art_1", | |
| 1717 | + edits: [{ oldText: "<div>hi</div>", newText: "<div>hi again</div>" }], | |
| 1718 | + }, | |
| 1719 | + }); | |
| 1720 | + expect(edited.isError).toBeFalsy(); | |
| 1721 | + expect(structuredOf(edited)).toEqual({ code: UPDATED_CODE, artifactId: "art_1" }); | |
| 1722 | + expect(store.rows.get("art_1")).toMatchObject({ | |
| 1723 | + code: UPDATED_CODE, | |
| 1724 | + // An edit that says nothing about title/description keeps them. | |
| 1725 | + title: "Draft", | |
| 1726 | + description: "First pass", | |
| 1727 | + }); | |
| 1728 | + // One artifact, not two. | |
| 1729 | + expect(store.rows.size).toBe(1); | |
| 1730 | + }, | |
| 1731 | + { artifacts: store.port }, | |
| 1732 | + ); | |
| 1733 | + }); | |
| 1734 | + | |
| 1735 | + it("applies edits in order, each against the previous result", async () => { | |
| 1736 | + const store = makeArtifactStore(); | |
| 1737 | + await withClient( | |
| 1738 | + makeStubEngine({}), | |
| 1739 | + APPS_CAPS, | |
| 1740 | + async (client) => { | |
| 1741 | + await seed(client); | |
| 1742 | + const edited = await client.callTool({ | |
| 1743 | + name: "edit-artifact", | |
| 1744 | + arguments: { | |
| 1745 | + artifactId: "art_1", | |
| 1746 | + edits: [ | |
| 1747 | + { oldText: "<div>hi</div>", newText: "<div>hello</div>" }, | |
| 1748 | + // Matches only the first edit's output, proving sequencing. | |
| 1749 | + { oldText: "<div>hello</div>", newText: "<div>hello there</div>" }, | |
| 1750 | + ], | |
| 1751 | + }, | |
| 1752 | + }); | |
| 1753 | + expect(edited.isError).toBeFalsy(); | |
| 1754 | + expect(store.rows.get("art_1")?.code).toBe( | |
| 1755 | + "function App() { return <div>hello there</div>; }", | |
| 1756 | + ); | |
| 1757 | + }, | |
| 1758 | + { artifacts: store.port }, | |
| 1759 | + ); | |
| 1760 | + }); | |
| 1761 | + | |
| 1762 | + it("replaces every occurrence only under replaceAll, and refuses ambiguity otherwise", async () => { | |
| 1763 | + const store = makeArtifactStore(); | |
| 1764 | + const REPEATED = "function App() { return <div><b>x</b><b>x</b></div>; }"; | |
| 1765 | + await withClient( | |
| 1766 | + makeStubEngine({}), | |
| 1767 | + APPS_CAPS, | |
| 1768 | + async (client) => { | |
| 1769 | + await seed(client, REPEATED); | |
| 1770 | + | |
| 1771 | + const ambiguous = await client.callTool({ | |
| 1772 | + name: "edit-artifact", | |
| 1773 | + arguments: { | |
| 1774 | + artifactId: "art_1", | |
| 1775 | + edits: [{ oldText: "<b>x</b>", newText: "<b>y</b>" }], | |
| 1776 | + }, | |
| 1777 | + }); | |
| 1778 | + expect(ambiguous.isError).toBe(true); | |
| 1779 | + expect(textOf(ambiguous)).toContain("appears 2 times"); | |
| 1780 | + expect(textOf(ambiguous)).toContain("replaceAll"); | |
| 1781 | + expect(store.rows.get("art_1")?.code).toBe(REPEATED); | |
| 1782 | + | |
| 1783 | + const replaced = await client.callTool({ | |
| 1784 | + name: "edit-artifact", | |
| 1785 | + arguments: { | |
| 1786 | + artifactId: "art_1", | |
| 1787 | + edits: [{ oldText: "<b>x</b>", newText: "<b>y</b>", replaceAll: true }], | |
| 1788 | + }, | |
| 1789 | + }); | |
| 1790 | + expect(replaced.isError).toBeFalsy(); | |
| 1791 | + expect(store.rows.get("art_1")?.code).toBe( | |
| 1792 | + "function App() { return <div><b>y</b><b>y</b></div>; }", | |
| 1793 | + ); | |
| 1794 | + }, | |
| 1795 | + { artifacts: store.port }, | |
| 1796 | + ); | |
| 1797 | + }); | |
| 1798 | + | |
| 1799 | + it("rejects the whole batch on a miss, returns the current source, and saves nothing", async () => { | |
| 1800 | + const store = makeArtifactStore(); | |
| 1801 | + await withClient( | |
| 1802 | + makeStubEngine({}), | |
| 1803 | + APPS_CAPS, | |
| 1804 | + async (client) => { | |
| 1805 | + await seed(client); | |
| 1806 | + const missed = await client.callTool({ | |
| 1807 | + name: "edit-artifact", | |
| 1808 | + arguments: { | |
| 1809 | + artifactId: "art_1", | |
| 1810 | + edits: [ | |
| 1811 | + { oldText: "<div>hi</div>", newText: "<div>hello</div>" }, | |
| 1812 | + { oldText: "not in the source", newText: "anything" }, | |
| 1813 | + ], | |
| 1814 | + }, | |
| 1815 | + }); | |
| 1816 | + expect(missed.isError).toBe(true); | |
| 1817 | + expect(textOf(missed)).toContain("Edit 2 of 2"); | |
| 1818 | + expect(textOf(missed)).toContain("matched nothing"); | |
| 1819 | + // The retry material: the stored source rides along on the error. | |
| 1820 | + expect(structuredOf(missed)).toMatchObject({ code: COUNTER_CODE }); | |
| 1821 | + // Atomic: the first (valid) edit was not half-applied. | |
| 1822 | + expect(store.rows.get("art_1")?.code).toBe(COUNTER_CODE); | |
| 1823 | + expect(store.calls.length).toBe(1); | |
| 1824 | + }, | |
| 1825 | + { artifacts: store.port }, | |
| 1826 | + ); | |
| 1827 | + }); | |
| 1828 | + | |
| 1829 | + it("validates the edited result like a create, and leaves the stored row untouched", async () => { | |
| 1830 | + const store = makeArtifactStore(); | |
| 1831 | + await withClient( | |
| 1832 | + makeStubEngine({}), | |
| 1833 | + APPS_CAPS, | |
| 1834 | + async (client) => { | |
| 1835 | + await seed(client); | |
| 1836 | + const rejected = await client.callTool({ | |
| 1837 | + name: "edit-artifact", | |
| 1838 | + arguments: { | |
| 1839 | + artifactId: "art_1", | |
| 1840 | + edits: [ | |
| 1841 | + { | |
| 1842 | + oldText: "return <div>hi</div>;", | |
| 1843 | + newText: "const Card = 1; return <div>hi</div>;", | |
| 1844 | + }, | |
| 1845 | + ], | |
| 1846 | + }, | |
| 1847 | + }); | |
| 1848 | + expect(rejected.isError).toBe(true); | |
| 1849 | + expect(textOf(rejected)).toContain("cannot be redeclared"); | |
| 1850 | + expect(store.rows.get("art_1")?.code).toBe(COUNTER_CODE); | |
| 1851 | + expect(store.calls.length).toBe(1); | |
| 1852 | + }, | |
| 1853 | + { artifacts: store.port }, | |
| 1854 | + ); | |
| 1855 | + }); | |
| 1856 | + | |
| 1857 | + it("smoke-renders the edited result, so a patch cannot break a working artifact", async () => { | |
| 1858 | + const store = makeArtifactStore(); | |
| 1859 | + const smokeRenderArtifact = vi.fn((code: string) => | |
| 1860 | + Promise.resolve( | |
| 1861 | + code.includes("boom") | |
| 1862 | + ? { status: "failed" as const, message: "boom is not defined" } | |
| 1863 | + : { status: "ok" as const }, | |
| 1864 | + ), | |
| 1865 | + ); | |
| 1866 | + await withClient( | |
| 1867 | + makeStubEngine({}), | |
| 1868 | + APPS_CAPS, | |
| 1869 | + async (client) => { | |
| 1870 | + await seed(client); | |
| 1871 | + const broken = await client.callTool({ | |
| 1872 | + name: "edit-artifact", | |
| 1873 | + arguments: { | |
| 1874 | + artifactId: "art_1", | |
| 1875 | + edits: [{ oldText: "return", newText: "boom(); return" }], | |
| 1876 | + }, | |
| 1877 | + }); | |
| 1878 | + expect(broken.isError).toBe(true); | |
| 1879 | + expect(textOf(broken)).toContain("boom is not defined"); | |
| 1880 | + expect(store.rows.get("art_1")?.code).toBe(COUNTER_CODE); | |
| 1881 | + // The candidate it rendered was the EDITED source, not the stored one. | |
| 1882 | + expect(smokeRenderArtifact).toHaveBeenLastCalledWith( | |
| 1883 | + expect.stringContaining("boom(); return"), | |
| 1884 | + ); | |
| 1885 | + }, | |
| 1886 | + { artifacts: store.port, smokeRenderArtifact }, | |
| 1887 | + ); | |
| 1888 | + }); | |
| 1889 | + | |
| 1890 | + it("refuses an artifact id that is not the caller's, without saying whether it exists", async () => { | |
| 1891 | + const store = makeArtifactStore(); | |
| 1892 | + await withClient( | |
| 1893 | + makeStubEngine({}), | |
| 1894 | + APPS_CAPS, | |
| 1895 | + async (client) => { | |
| 1896 | + const result = await client.callTool({ | |
| 1897 | + name: "edit-artifact", | |
| 1898 | + arguments: { | |
| 1899 | + artifactId: "art_someone_else", | |
| 1900 | + edits: [{ oldText: "a", newText: "b" }], | |
| 1901 | + }, | |
| 1902 | + }); | |
| 1903 | + expect(result.isError).toBe(true); | |
| 1904 | + // The probe-proof answer, with no stored source riding along. | |
| 1905 | + expect(structuredOf(result)).toMatchObject({ error: "artifact_unavailable" }); | |
| 1906 | + expect(structuredOf(result)).not.toHaveProperty("code"); | |
| 1907 | + expect(store.calls).toEqual([]); | |
| 1908 | + }, | |
| 1909 | + { artifacts: store.port }, | |
| 1910 | + ); | |
| 1911 | + }); | |
| 1912 | + | |
| 1913 | + it("re-resolves bindings when an edit introduces an integration", async () => { | |
| 1914 | + const store = makeArtifactStore(); | |
| 1915 | + await withClient( | |
| 1916 | + makeStubEngine({}), | |
| 1917 | + APPS_CAPS, | |
| 1918 | + async (client) => { | |
| 1919 | + await seed(client); | |
| 1920 | + expect(store.calls[0]?.bindings).toEqual({}); | |
| 1921 | + const edited = await client.callTool({ | |
| 1922 | + name: "edit-artifact", | |
| 1923 | + arguments: { | |
| 1924 | + artifactId: "art_1", | |
| 1925 | + edits: [ | |
| 1926 | + { | |
| 1927 | + oldText: "return <div>hi</div>;", | |
| 1928 | + newText: | |
| 1929 | + "const q = useQuery(tools.vercel.domains.getDomains.queryOptions({})); return <div>hi</div>;", | |
| 1930 | + }, | |
| 1931 | + ], | |
| 1932 | + }, | |
| 1933 | + }); | |
| 1934 | + expect(edited.isError).toBeFalsy(); | |
| 1935 | + expect(store.calls[1]?.bindings).toEqual({ | |
| 1936 | + vercel: { | |
| 1937 | + integration: "vercel", | |
| 1938 | + owner: "user", | |
| 1939 | + connection: "personalVercel", | |
| 1940 | + }, | |
| 1941 | + }); | |
| 1942 | + }, | |
| 1943 | + { | |
| 1944 | + artifacts: store.port, | |
| 1945 | + connections: connectionsPort([conn("vercel", "user", "personalVercel")]), | |
| 1946 | + }, | |
| 1947 | + ); | |
| 1948 | + }); | |
| 1949 | + | |
| 1950 | + it("updates the title and description when asked, keeping the patched code", async () => { | |
| 1951 | + const store = makeArtifactStore(); | |
| 1952 | + await withClient( | |
| 1953 | + makeStubEngine({}), | |
| 1954 | + APPS_CAPS, | |
| 1955 | + async (client) => { | |
| 1956 | + await seed(client); | |
| 1957 | + const edited = await client.callTool({ | |
| 1958 | + name: "edit-artifact", | |
| 1959 | + arguments: { | |
| 1960 | + artifactId: "art_1", | |
| 1961 | + edits: [{ oldText: "hi", newText: "hi again" }], | |
| 1962 | + title: "Active users dashboard", | |
| 1963 | + description: "Second pass", | |
| 1964 | + }, | |
| 1965 | + }); | |
| 1966 | + expect(edited.isError).toBeFalsy(); | |
| 1967 | + expect(store.rows.get("art_1")).toMatchObject({ | |
| 1968 | + title: "Active users dashboard", | |
| 1969 | + description: "Second pass", | |
| 1970 | + code: UPDATED_CODE, | |
| 1971 | + }); | |
| 1972 | + }, | |
| 1973 | + { artifacts: store.port }, | |
| 1974 | + ); | |
| 1975 | + }); | |
| 1976 | +}); | |
| 1977 | + | |
| 1686 | 1978 | // --------------------------------------------------------------------------- |
| 1687 | 1979 | // create-artifact — connection binding |
| 1688 | 1980 | // --------------------------------------------------------------------------- |
| @@ -198,6 +198,88 @@ const hookCalledInLoop = (code: string): string | undefined => { · 197 unmodified lines | ||
| 198 | 198 | /** Hooks whose in-loop use is almost always hand-rolled cursor pagination. */ |
| 199 | 199 | const PAGINATION_HOOKS = new Set(["useQuery", "useInfiniteQuery", "useSuspenseQuery"]); |
| 200 | 200 | |
| 201 | +// --------------------------------------------------------------------------- | |
| 202 | +// Edits | |
| 203 | +// --------------------------------------------------------------------------- | |
| 204 | +// | |
| 205 | +// `edit-artifact`'s pure half. An edit is an exact find-and-replace against the | |
| 206 | +// stored source, so a model changing one column of a 300-line dashboard sends | |
| 207 | +// the changed lines rather than re-emitting the whole component — the tool | |
| 208 | +// call's size scales with the edit, not the artifact. | |
| 209 | +// | |
| 210 | +// The semantics are the ones every model already knows from its own file-edit | |
| 211 | +// tool: `oldText` must match verbatim (whitespace included) and exactly once, | |
| 212 | +// unless `replaceAll` opts into every occurrence. Edits apply in order, each | |
| 213 | +// against the result of the previous, and the whole batch is atomic — any | |
| 214 | +// miss rejects the call and nothing is saved. Ambiguity is refused rather than | |
| 215 | +// resolved by picking the first hit, because a wrong pick would save silently | |
| 216 | +// and the user would see the wrong line change. | |
| 217 | + | |
| 218 | +export type ArtifactEdit = { | |
| 219 | + /** Exact text to find in the current source. */ | |
| 220 | + readonly oldText: string; | |
| 221 | + /** What to put in its place. */ | |
| 222 | + readonly newText: string; | |
| 223 | + /** Replace every occurrence instead of requiring `oldText` to be unique. */ | |
| 224 | + readonly replaceAll?: boolean | undefined; | |
| 225 | +}; | |
| 226 | + | |
| 227 | +export type AppliedArtifactEdits = | |
| 228 | + | { readonly ok: true; readonly code: string } | |
| 229 | + | { readonly ok: false; readonly message: string }; | |
| 230 | + | |
| 231 | +const countOccurrences = (haystack: string, needle: string): number => | |
| 232 | + haystack.split(needle).length - 1; | |
| 233 | + | |
| 234 | +/** The edited source, or the first edit that could not be applied. Messages | |
| 235 | + * name the edit by position and say what to do, because the model retries | |
| 236 | + * from the message alone. */ | |
| 237 | +export const applyArtifactEdits = ( | |
| 238 | + code: string, | |
| 239 | + edits: readonly ArtifactEdit[], | |
| 240 | +): AppliedArtifactEdits => { | |
| 241 | + let current = code; | |
| 242 | + for (const [index, edit] of edits.entries()) { | |
| 243 | + const position = `Edit ${index + 1} of ${edits.length}`; | |
| 244 | + if (edit.oldText === "") { | |
| 245 | + return { ok: false, message: `${position} has an empty oldText, which matches nothing.` }; | |
| 246 | + } | |
| 247 | + if (edit.oldText === edit.newText) { | |
| 248 | + return { | |
| 249 | + ok: false, | |
| 250 | + message: `${position} is a no-op: oldText and newText are identical.`, | |
| 251 | + }; | |
| 252 | + } | |
| 253 | + const occurrences = countOccurrences(current, edit.oldText); | |
| 254 | + if (occurrences === 0) { | |
| 255 | + return { | |
| 256 | + ok: false, | |
| 257 | + message: [ | |
| 258 | + `${position} matched nothing: its oldText does not appear in the current source.`, | |
| 259 | + "Copy oldText verbatim from the source, whitespace included", | |
| 260 | + index > 0 ? "— and remember each edit sees the result of the earlier ones." : ".", | |
| 261 | + ].join(" "), | |
| 262 | + }; | |
| 263 | + } | |
| 264 | + if (occurrences > 1 && edit.replaceAll !== true) { | |
| 265 | + return { | |
| 266 | + ok: false, | |
| 267 | + message: [ | |
| 268 | + `${position} is ambiguous: its oldText appears ${occurrences} times in the current source.`, | |
| 269 | + "Include enough surrounding text to make it unique, or set replaceAll: true to change every occurrence.", | |
| 270 | + ].join(" "), | |
| 271 | + }; | |
| 272 | + } | |
| 273 | + // split/join for both arms: `String.replace` gives `$&` and friends in the | |
| 274 | + // replacement special meaning, and generated JSX is full of `$`. | |
| 275 | + current = | |
| 276 | + edit.replaceAll === true | |
| 277 | + ? current.split(edit.oldText).join(edit.newText) | |
| 278 | + : current.replace(edit.oldText, () => edit.newText); | |
| 279 | + } | |
| 280 | + return { ok: true, code: current }; | |
| 281 | +}; | |
| 282 | + | |
| 201 | 283 | // --------------------------------------------------------------------------- |
| 202 | 284 | // Create-time smoke render |
| 203 | 285 | // --------------------------------------------------------------------------- |
| @@ -53,8 +53,10 @@ import { · 52 unmodified lines | ||
| 53 | 53 | } from "@executor-js/execution"; |
| 54 | 54 | import { |
| 55 | 55 | MCP_APPS_SHELL_RESOURCE_URI, |
| 56 | + applyArtifactEdits, | |
| 56 | 57 | smokeRenderRejection, |
| 57 | 58 | validateArtifactCode, |
| 59 | + type ArtifactEdit, | |
| 58 | 60 | type ArtifactSmokeRenderResult, |
| 59 | 61 | } from "./create-artifact"; |
| 60 | 62 | import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code"; |
| @@ -846,6 +848,22 @@ const renderRejectedResult = (reason: string): McpToolResult => ({ · 785 unmodified lines | ||
| 846 | 848 | isError: true, |
| 847 | 849 | }); |
| 848 | 850 | |
| 851 | +/** An edit batch that could not be applied. Carries the current stored source | |
| 852 | + * so the model can rebuild its edits without a `show-artifact` round trip. */ | |
| 853 | +const editRejectedResult = (reason: string, currentCode: string): McpToolResult => ({ | |
| 854 | + content: [ | |
| 855 | + { | |
| 856 | + type: "text", | |
| 857 | + text: [ | |
| 858 | + `edit-artifact rejected: ${reason}`, | |
| 859 | + "Nothing was changed. The artifact's current source is in structuredContent.code — build the retry against it.", | |
| 860 | + ].join("\n"), | |
| 861 | + }, | |
| 862 | + ], | |
| 863 | + structuredContent: { status: "error", error: reason, code: currentCode }, | |
| 864 | + isError: true, | |
| 865 | +}); | |
| 866 | + | |
| 849 | 867 | /** `execute-action` was handed something other than a single proxy-shaped tool |
| 850 | 868 | * call. Names the contract rather than just refusing, since the reader is |
| 851 | 869 | * either a confused iframe or someone probing the app channel by hand. */ |
| @@ -1655,53 +1673,21 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 803 unmodified lines | ||
| 1655 | 1673 | }; |
| 1656 | 1674 | |
| 1657 | 1675 | /** |
| 1658 | − * Bind the integration roles an artifact's code uses, at create time. | |
| 1659 | − * | |
| 1660 | − * Binding happens HERE rather than at render time because this is the only | |
| 1661 | − * moment the author, the code and their connections are all in hand — and | |
| 1662 | − * because a create that can't bind is a create that would have saved a | |
| 1663 | − * broken artifact. The model finds out now, with the candidate list, rather | |
| 1664 | − * than the user finding out later through a query error inside the UI. | |
| 1665 | − * | |
| 1666 | − * `artifactId` turns the same call into an update in place. A model tweaking | |
| 1667 | − * a dashboard has the whole component in hand already — it fetched it with | |
| 1668 | − * `show-artifact`, edited it, and is calling back with the result — so a | |
| 1669 | − * separate `update-artifact` tool would take the same four arguments and | |
| 1670 | − * differ only in whether a row is minted. One tool keeps the catalog small | |
| 1671 | − * and makes the wrong thing (a copy per tweak) the thing the model has to | |
| 1672 | − * ask for rather than the thing it gets by default. | |
| 1673 | − * | |
| 1674 | − * An update replaces the code outright — v1 keeps no version history — and | |
| 1675 | − * re-extracts and re-resolves the bindings from the NEW source, because the | |
| 1676 | − * roles the new code uses are not necessarily the ones the old code did. | |
| 1677 | − * `title` and `description` are optional on an update and absent means keep | |
| 1678 | − * what is stored, so a pure code tweak doesn't have to restate them. | |
| 1676 | + * The shared back half of `create-artifact` and `edit-artifact`: everything | |
| 1677 | + * that happens once the full candidate source is in hand. Static checks, | |
| 1678 | + * the smoke render, binding and the save are identical whether the code | |
| 1679 | + * arrived whole or was assembled from stored source plus edits — sharing | |
| 1680 | + * the pipeline is what guarantees an edit cannot save anything a create | |
| 1681 | + * would have refused. | |
| 1679 | 1682 | */ |
| 1680 | − const createArtifact = (input: { | |
| 1683 | + const validateRenderAndSave = (input: { | |
| 1681 | 1684 | readonly code: string; |
| 1682 | − readonly title?: string; | |
| 1683 | − readonly description?: string; | |
| 1684 | − readonly connections?: Readonly<Record<string, string>>; | |
| 1685 | − readonly artifactId?: string; | |
| 1685 | + readonly title: string; | |
| 1686 | + readonly description?: string | undefined; | |
| 1687 | + readonly connections?: Readonly<Record<string, string>> | undefined; | |
| 1688 | + readonly existing: Artifact | null; | |
| 1686 | 1689 | }): Effect.Effect<McpToolResult, unknown> => |
| 1687 | 1690 | Effect.gen(function* () { |
| 1688 | − // An update reads the existing row FIRST, both to carry its title and | |
| 1689 | − // description forward and to refuse a foreign id before any work. The | |
| 1690 | − // refusal is `artifact_unavailable` — the same answer `execute-action` | |
| 1691 | − // gives — so create-artifact cannot be used to probe which ids exist. | |
| 1692 | − const existing = | |
| 1693 | − input.artifactId === undefined ? null : yield* loadArtifact(input.artifactId); | |
| 1694 | − if (input.artifactId !== undefined && !existing) return actionArtifactUnavailableResult(); | |
| 1695 | − | |
| 1696 | − const title = input.title ?? existing?.title; | |
| 1697 | − if (title === undefined) { | |
| 1698 | − return renderRejectedResult( | |
| 1699 | − "title is required when creating an artifact. Give it a short human-readable name.", | |
| 1700 | − ); | |
| 1701 | − } | |
| 1702 | − // Only an update inherits; a create with no description stores none. | |
| 1703 | − const description = input.description ?? existing?.description ?? undefined; | |
| 1704 | − | |
| 1705 | 1691 | const rejection = validateArtifactCode(input.code); |
| 1706 | 1692 | if (rejection) return renderRejectedResult(rejection); |
| 1707 | 1693 | |
| @@ -1744,10 +1730,10 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 36 unmodified lines | ||
| 1744 | 1730 | |
| 1745 | 1731 | const saveInput = { |
| 1746 | 1732 | code: input.code, |
| 1747 | − title, | |
| 1733 | + title: input.title, | |
| 1748 | 1734 | preview, |
| 1749 | − ...(description === undefined ? {} : { description }), | |
| 1750 | − ...(existing === null ? {} : { existingId: existing.id }), | |
| 1735 | + ...(input.description === undefined ? {} : { description: input.description }), | |
| 1736 | + ...(input.existing === null ? {} : { existingId: input.existing.id }), | |
| 1751 | 1737 | }; |
| 1752 | 1738 | |
| 1753 | 1739 | const roles = extractArtifactRoles(input.code); |
| @@ -1775,6 +1761,61 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 21 unmodified lines | ||
| 1775 | 1761 | "mcp.artifact.role_count": roles.length, |
| 1776 | 1762 | }); |
| 1777 | 1763 | return yield* saveAndDeliverArtifact({ ...saveInput, bindings: resolved.bindings }); |
| 1764 | + }); | |
| 1765 | + | |
| 1766 | + /** | |
| 1767 | + * Bind the integration roles an artifact's code uses, at create time. | |
| 1768 | + * | |
| 1769 | + * Binding happens HERE rather than at render time because this is the only | |
| 1770 | + * moment the author, the code and their connections are all in hand — and | |
| 1771 | + * because a create that can't bind is a create that would have saved a | |
| 1772 | + * broken artifact. The model finds out now, with the candidate list, rather | |
| 1773 | + * than the user finding out later through a query error inside the UI. | |
| 1774 | + * | |
| 1775 | + * `artifactId` turns the same call into an update in place — for a REWRITE, | |
| 1776 | + * where the new source shares little with the old and edits would be longer | |
| 1777 | + * than the code. A tweak belongs on `edit-artifact`, which patches the | |
| 1778 | + * stored source instead of replacing it. Either way one row is kept: a copy | |
| 1779 | + * per revision is the thing the model has to ask for, never the default. | |
| 1780 | + * | |
| 1781 | + * An update replaces the code outright — v1 keeps no version history — and | |
| 1782 | + * re-extracts and re-resolves the bindings from the NEW source, because the | |
| 1783 | + * roles the new code uses are not necessarily the ones the old code did. | |
| 1784 | + * `title` and `description` are optional on an update and absent means keep | |
| 1785 | + * what is stored, so a pure code tweak doesn't have to restate them. | |
| 1786 | + */ | |
| 1787 | + const createArtifact = (input: { | |
| 1788 | + readonly code: string; | |
| 1789 | + readonly title?: string; | |
| 1790 | + readonly description?: string; | |
| 1791 | + readonly connections?: Readonly<Record<string, string>>; | |
| 1792 | + readonly artifactId?: string; | |
| 1793 | + }): Effect.Effect<McpToolResult, unknown> => | |
| 1794 | + Effect.gen(function* () { | |
| 1795 | + // An update reads the existing row FIRST, both to carry its title and | |
| 1796 | + // description forward and to refuse a foreign id before any work. The | |
| 1797 | + // refusal is `artifact_unavailable` — the same answer `execute-action` | |
| 1798 | + // gives — so create-artifact cannot be used to probe which ids exist. | |
| 1799 | + const existing = | |
| 1800 | + input.artifactId === undefined ? null : yield* loadArtifact(input.artifactId); | |
| 1801 | + if (input.artifactId !== undefined && !existing) return actionArtifactUnavailableResult(); | |
| 1802 | + | |
| 1803 | + const title = input.title ?? existing?.title; | |
| 1804 | + if (title === undefined) { | |
| 1805 | + return renderRejectedResult( | |
| 1806 | + "title is required when creating an artifact. Give it a short human-readable name.", | |
| 1807 | + ); | |
| 1808 | + } | |
| 1809 | + // Only an update inherits; a create with no description stores none. | |
| 1810 | + const description = input.description ?? existing?.description ?? undefined; | |
| 1811 | + | |
| 1812 | + return yield* validateRenderAndSave({ | |
| 1813 | + code: input.code, | |
| 1814 | + title, | |
| 1815 | + description, | |
| 1816 | + connections: input.connections, | |
| 1817 | + existing, | |
| 1818 | + }); | |
| 1778 | 1819 | }).pipe( |
| 1779 | 1820 | Effect.withSpan("mcp.host.tool.create_artifact", { |
| 1780 | 1821 | attributes: { |
| @@ -1785,6 +1826,52 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 4 unmodified lines | ||
| 1785 | 1826 | }), |
| 1786 | 1827 | ); |
| 1787 | 1828 | |
| 1829 | + /** | |
| 1830 | + * `edit-artifact`: the update path for tweaks, patching the stored source | |
| 1831 | + * with exact find-and-replace edits so the call scales with the change | |
| 1832 | + * rather than the component. The edited result runs the same | |
| 1833 | + * validate → smoke-render → bind → save pipeline as a full create, so an | |
| 1834 | + * edit cannot save anything a create would have refused. | |
| 1835 | + * | |
| 1836 | + * A failed edit hands the CURRENT source back in `structuredContent.code`. | |
| 1837 | + * The model's usual recovery — `show-artifact`, re-read, retry — is a whole | |
| 1838 | + * extra round trip to fetch a thing this call already loaded; giving it | |
| 1839 | + * back here makes the retry immediate. | |
| 1840 | + */ | |
| 1841 | + const editArtifact = (input: { | |
| 1842 | + readonly artifactId: string; | |
| 1843 | + readonly edits: readonly ArtifactEdit[]; | |
| 1844 | + readonly title?: string; | |
| 1845 | + readonly description?: string; | |
| 1846 | + readonly connections?: Readonly<Record<string, string>>; | |
| 1847 | + }): Effect.Effect<McpToolResult, unknown> => | |
| 1848 | + Effect.gen(function* () { | |
| 1849 | + // Same probe-proof refusal as create-artifact's update arm. | |
| 1850 | + const existing = yield* loadArtifact(input.artifactId); | |
| 1851 | + if (!existing) return actionArtifactUnavailableResult(); | |
| 1852 | + | |
| 1853 | + const applied = applyArtifactEdits(existing.code, input.edits); | |
| 1854 | + if (!applied.ok) return editRejectedResult(applied.message, existing.code); | |
| 1855 | + | |
| 1856 | + yield* Effect.annotateCurrentSpan({ | |
| 1857 | + "mcp.artifact.edit_count": input.edits.length, | |
| 1858 | + }); | |
| 1859 | + return yield* validateRenderAndSave({ | |
| 1860 | + code: applied.code, | |
| 1861 | + title: input.title ?? existing.title, | |
| 1862 | + description: input.description ?? existing.description ?? undefined, | |
| 1863 | + connections: input.connections, | |
| 1864 | + existing, | |
| 1865 | + }); | |
| 1866 | + }).pipe( | |
| 1867 | + Effect.withSpan("mcp.host.tool.edit_artifact", { | |
| 1868 | + attributes: { | |
| 1869 | + "mcp.tool.name": "edit-artifact", | |
| 1870 | + "mcp.artifact.id": input.artifactId, | |
| 1871 | + }, | |
| 1872 | + }), | |
| 1873 | + ); | |
| 1874 | + | |
| 1788 | 1875 | const listArtifacts = (): Effect.Effect<McpToolResult, unknown> => |
| 1789 | 1876 | Effect.gen(function* () { |
| 1790 | 1877 | if (!artifacts) return artifactsUnavailableResult(); |
| @@ -1863,7 +1950,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 72 unmodified lines | ||
| 1863 | 1950 | 'To use two accounts of the same integration, tag each call site with a role — `tools.linear("prod").issues.list` and `tools.linear("staging").issues.list` — and map every role in `connections`.', |
| 1864 | 1951 | "All data access is declarative `tools.*`: `.queryOptions()` to read, `.infiniteQueryOptions()` to page through a cursor, `.mutationOptions()` to write. There is no `run()` and no arbitrary code — never hand-roll `useQuery({ queryKey, queryFn })`, or invalidation breaks.", |
| 1865 | 1952 | "To read every page of a paginated tool, call `useInfiniteQuery(tools.<integration>.<tool>.infiniteQueryOptions(args, { cursorKey, getNextPageParam }))` once and render `data.pages`. Never call hooks inside a loop — a `useQuery` per page is rejected.", |
| 1866 | − "To CHANGE an artifact that already exists — a tweak, a fix, a new column — pass its `artifactId` and it is updated in place. Fetch the current source with `show-artifact` first, edit that, and send the whole component back. Never create a second artifact for a revision of an existing one.", | |
| 1953 | + "To CHANGE an artifact that already exists, use `edit-artifact` — it patches the stored source with find-and-replace edits, so a tweak costs only the changed lines. Only use create-artifact with `artifactId` for a full rewrite, sending the complete new component. Never create a second artifact for a revision of an existing one.", | |
| 1867 | 1954 | "Clients that cannot display MCP apps receive a link to the saved artifact instead; pass it to the user.", |
| 1868 | 1955 | ].join("\n"), |
| 1869 | 1956 | inputSchema: { |
| @@ -1874,7 +1961,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 4 unmodified lines | ||
| 1874 | 1961 | .min(1) |
| 1875 | 1962 | .optional() |
| 1876 | 1963 | .describe( |
| 1877 | − "The artifact to update in place, from `list-artifacts` or a previous create. Omit to create a new one. `code` fully replaces the stored source and the connection bindings are re-resolved from it, so send the complete component, not a fragment.", | |
| 1964 | + "The artifact to REWRITE in place, from `list-artifacts` or a previous create. Omit to create a new one. `code` fully replaces the stored source and the connection bindings are re-resolved from it, so send the complete component, not a fragment. For a tweak, use `edit-artifact` instead.", | |
| 1878 | 1965 | ), |
| 1879 | 1966 | connections: z |
| 1880 | 1967 | .record(z.string(), z.string()) |
| @@ -1910,6 +1997,72 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 29 unmodified lines | ||
| 1910 | 1997 | }), |
| 1911 | 1998 | ); |
| 1912 | 1999 | |
| 2000 | + yield* Effect.sync(() => | |
| 2001 | + registerAppTool( | |
| 2002 | + server, | |
| 2003 | + "edit-artifact", | |
| 2004 | + { | |
| 2005 | + description: [ | |
| 2006 | + "Change an existing artifact by patching its stored source with exact find-and-replace edits, and re-render it.", | |
| 2007 | + "PREFER THIS over create-artifact for tweaks — a new column, a fixed label, a restyled section — because you send only the changed lines, not the whole component. Use create-artifact with `artifactId` only for a rewrite where most of the code changes.", | |
| 2008 | + "Each edit's `oldText` must appear EXACTLY ONCE in the current source, verbatim (whitespace included); include enough surrounding lines to make it unique, or set `replaceAll: true` to change every occurrence. Edits apply in order, each seeing the previous one's result.", | |
| 2009 | + "The batch is atomic: if any edit fails to match, nothing is saved and the error returns the current source in structuredContent.code — rebuild the edits from that instead of calling show-artifact again.", | |
| 2010 | + "The edited component is validated and smoke-rendered exactly like a create, and connection bindings are re-resolved from the result; pass `connections` if an edit introduces an ambiguous integration.", | |
| 2011 | + ].join("\n"), | |
| 2012 | + inputSchema: { | |
| 2013 | + artifactId: z | |
| 2014 | + .string() | |
| 2015 | + .trim() | |
| 2016 | + .min(1) | |
| 2017 | + .describe("The artifact to edit, from `list-artifacts` or a previous create."), | |
| 2018 | + edits: z | |
| 2019 | + .array( | |
| 2020 | + z.object({ | |
| 2021 | + oldText: z | |
| 2022 | + .string() | |
| 2023 | + .min(1) | |
| 2024 | + .describe( | |
| 2025 | + "Exact text to find in the current source, whitespace included. Must match exactly once unless replaceAll is true.", | |
| 2026 | + ), | |
| 2027 | + newText: z.string().describe("The replacement text."), | |
| 2028 | + replaceAll: z | |
| 2029 | + .boolean() | |
| 2030 | + .optional() | |
| 2031 | + .describe("Replace every occurrence instead of requiring a unique match."), | |
| 2032 | + }), | |
| 2033 | + ) | |
| 2034 | + .min(1) | |
| 2035 | + .describe("Find-and-replace edits, applied in order. All-or-nothing."), | |
| 2036 | + connections: z | |
| 2037 | + .record(z.string(), z.string()) | |
| 2038 | + .optional() | |
| 2039 | + .describe( | |
| 2040 | + "Connection for each integration role the EDITED code uses, exactly as on create-artifact. Only needed when an edit introduces an integration with several connections.", | |
| 2041 | + ), | |
| 2042 | + title: z | |
| 2043 | + .string() | |
| 2044 | + .trim() | |
| 2045 | + .min(1) | |
| 2046 | + .optional() | |
| 2047 | + .describe("New title. Omit to keep the current one."), | |
| 2048 | + description: z | |
| 2049 | + .string() | |
| 2050 | + .optional() | |
| 2051 | + .describe("New description. Omit to keep the current one."), | |
| 2052 | + }, | |
| 2053 | + _meta: { | |
| 2054 | + ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, | |
| 2055 | + }, | |
| 2056 | + }, | |
| 2057 | + ({ artifactId, edits, connections, title, description }) => | |
| 2058 | + runToolEffect(editArtifact({ artifactId, edits, connections, title, description })), | |
| 2059 | + ), | |
| 2060 | + ).pipe( | |
| 2061 | + Effect.withSpan("mcp.host.register_tool", { | |
| 2062 | + attributes: { "mcp.tool.name": "edit-artifact" }, | |
| 2063 | + }), | |
| 2064 | + ); | |
| 2065 | + | |
| 1913 | 2066 | yield* Effect.sync(() => |
| 1914 | 2067 | server.registerTool( |
| 1915 | 2068 | "list-artifacts", |