@noodleseed/one 0.139.2 → 0.139.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +2 -2
- package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-refs.js +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/skill-content.js +2 -2
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.d.ts +5 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.js +20 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-interactive.js +23 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.d.ts +10 -2
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.js +18 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.js +1 -0
- package/node_modules/@noodle-borg/authoring/dist/model-tool-visibility.js +24 -4
- package/node_modules/@noodle-borg/authoring/dist/server.d.ts +4 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +29 -6
- package/package.json +1 -1
|
@@ -62,13 +62,13 @@ export const BUNDLED_EXAMPLE_FILES = [
|
|
|
62
62
|
{ relPath: "examples/food-ordering/package.json", content: "{\n \"name\": \"food-ordering\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run test\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@vitejs/plugin-react\": \"latest\",\n \"@noodleseed/one\": \"latest\",\n \"@types/react\": \"latest\",\n \"@types/react-dom\": \"latest\",\n \"react\": \"latest\",\n \"react-dom\": \"latest\",\n \"vite\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
|
|
63
63
|
{ relPath: "examples/food-ordering/src/agent-guide.ts", content: "import type { AgentGuideSource } from '@noodleseed/one';\n\n/** Product guidance for the model-visible ordering and planning workflows. */\nexport const FOOD_ORDERING_AGENT_GUIDE = {\n description:\n 'Use Food Ordering to browse synthetic local options, build a reviewable cart, and hand checkout to the user.',\n useWhen: [\n 'The user wants to browse nearby food or assemble an order.',\n 'The user wants a structured pickup or delivery plan before ordering.',\n ],\n workflows: [\n {\n id: 'build_order',\n title: 'Browse and build an order',\n steps: [\n {\n capability: { kind: 'tool', name: 'open_ordering' },\n guidance:\n 'Open the ordering app so the user can choose a store, review the cart, and control checkout handoff.',\n },\n ],\n },\n {\n id: 'summarize_options',\n title: 'Summarize available options',\n steps: [{ capability: { kind: 'tool', name: 'summarize_ordering_options' } }],\n },\n {\n id: 'plan_fulfilment',\n title: 'Plan pickup or delivery',\n steps: [\n {\n capability: { kind: 'tool', name: 'plan_order' },\n guidance: 'Collect the user’s fulfilment preference without claiming to place an order.',\n },\n ],\n },\n ],\n boundaries: [\n 'Treat every store, menu item, price, and service area in this example as synthetic.',\n 'Never claim checkout or payment completed; the final order happens only after the external handoff.',\n ],\n examples: [\n { prompt: 'Help me put together a noodle order.', workflow: 'build_order' },\n { prompt: 'What food options are available?', workflow: 'summarize_options' },\n { prompt: 'Plan a delivery for Friday.', workflow: 'plan_fulfilment' },\n ],\n} as const satisfies AgentGuideSource;\n" },
|
|
64
64
|
{ relPath: "examples/food-ordering/src/helpers.ts", content: "import type { ServerDefinition } from '@noodleseed/one';\n\nexport {\n ActionBar,\n AppShell,\n AsyncBoundary,\n ChoiceGroup,\n createViewStore,\n DataCard,\n DataList,\n Feedback,\n Field,\n Form,\n HandoffButton,\n QuantityStepper,\n ShellNav,\n StatusBadge,\n SubmitButton,\n View,\n ViewStack,\n} from '@noodleseed/one/react';\n\nimport { generateHelpers } from '@noodleseed/one/react';\n\nexport type AppType = ServerDefinition;\n\nexport const {\n useCallTool,\n useAppFlow,\n useHandoff,\n useLayout,\n useOpenExternal,\n useSendFollowUpMessage,\n useToolInfo,\n useUpdateModelContext,\n useViewState,\n useWidgetLifecycle,\n useWidgetReady,\n} = generateHelpers<AppType>();\n" },
|
|
65
|
-
{ relPath: "examples/food-ordering/src/server.ts", content: "import { annotations, asset, connector, resource, server, tool, z } from '@noodleseed/one';\nimport { FOOD_ORDERING_AGENT_GUIDE } from './agent-guide.js';\n\nconst heroImage = asset('assets/noodle-bowl.jpg');\nconst storesScreenshot = asset('assets/food-ordering-stores.png');\nconst menuScreenshot = asset('assets/food-ordering-menu.png');\nconst handoffScreenshot = asset('assets/food-ordering-handoff.png');\n\nconst state = connector('noodle_state')\n .version('1.0.0')\n .operation('read_state', {\n type: 'read',\n input: z.object({\n handle: z.string(),\n key: z.string().optional(),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n })\n .operation('patch_state', {\n type: 'action',\n input: z.object({\n handle: z.string(),\n expectedRevision: z.number().int(),\n value: z.record(z.string(), z.unknown()),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n });\n\nconst stores = [\n {\n id: 'harbor-noodles',\n name: 'Harbor Noodles',\n cuisine: 'Noodles',\n address: '18 Pier Lane',\n open: true,\n etaMinutes: 24,\n rating: 4.8,\n },\n {\n id: 'garden-wraps',\n name: 'Garden Wraps',\n cuisine: 'Vegetarian',\n address: '44 Market Street',\n open: true,\n etaMinutes: 18,\n rating: 4.6,\n },\n {\n id: 'midnight-tacos',\n name: 'Midnight Tacos',\n cuisine: 'Mexican',\n address: '7 Station Road',\n open: false,\n etaMinutes: 35,\n rating: 4.7,\n },\n] as const;\n\nconst menu = [\n {\n id: 'spicy_miso',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Spicy Miso Bowl',\n price: 16,\n description: 'Miso broth, wheat noodles, chili crisp, egg, and greens.',\n modifiers: ['extra_noodles', 'soft_egg', 'chili_crisp'],\n },\n {\n id: 'ginger_tofu',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Ginger Tofu Bowl',\n price: 15,\n description: 'Tofu, ginger broth, mushrooms, and scallions.',\n modifiers: ['extra_tofu', 'brown_rice', 'no_mushroom'],\n },\n {\n id: 'green_falafel',\n storeId: 'garden-wraps',\n category: 'Wraps',\n name: 'Green Falafel Wrap',\n price: 13,\n description: 'Falafel, herbs, pickles, tahini, and crisp vegetables.',\n modifiers: ['extra_tahini', 'add_fries', 'gluten_free_wrap'],\n },\n {\n id: 'sweet_potato',\n storeId: 'garden-wraps',\n category: 'Plates',\n name: 'Sweet Potato Plate',\n price: 14,\n description: 'Roasted sweet potato, grains, greens, and lemon yogurt.',\n modifiers: ['vegan_yogurt', 'extra_greens', 'hot_sauce'],\n },\n] as const;\n\nconst cartLine = z.object({\n itemId: z.string(),\n quantity: z.number().int().min(1),\n modifiers: z.array(z.string()).default([]),\n note: z.string().optional(),\n});\n\nconst cartInput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine).default([]),\n customer: z.string().default('Guest'),\n notes: z.string().optional(),\n subtotal: z.number().default(0),\n expectedRevision: z.number().int().min(0).default(0),\n});\n\nconst storeShape = z.object({\n id: z.string(),\n name: z.string(),\n cuisine: z.string(),\n address: z.string(),\n open: z.boolean(),\n etaMinutes: z.number(),\n rating: z.number(),\n});\n\nconst menuItemShape = z.object({\n id: z.string(),\n storeId: z.string(),\n category: z.string(),\n name: z.string(),\n price: z.number(),\n description: z.string(),\n // Nested lists count toward the output budget too: unbounded modifiers multiply by every item in a\n // menu payload, so the ceiling is declared here rather than only on the outer array.\n modifiers: z.array(z.string()).max(10),\n});\n\nconst cartOutput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n status: z.enum(['draft', 'review', 'handoff']),\n checkoutUrl: z.string().optional(),\n});\n\nconst cartStateSchema = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n // `.default()`/`.optional()` state fields are optional on write: a cart save that omits\n // `status` still validates, and a fresh cart starts in `draft`.\n status: z.enum(['draft', 'review', 'handoff']).default('draft'),\n checkoutUrl: z.string().optional(),\n});\n\ntype CartLine = {\n readonly itemId: string;\n readonly quantity: number;\n readonly modifiers: readonly string[];\n readonly note?: string;\n};\n\ntype CartInput = {\n readonly selectedStoreId?: string;\n readonly lines: readonly CartLine[];\n readonly customer: string;\n readonly notes?: string;\n readonly subtotal: number;\n readonly expectedRevision: number;\n};\n\nconst readOnly = annotations.readOnly();\n// These writes are app-only controls inside the cart widget. The widget already presents the\n// reviewed state and explicit button; `confirm: false` documents direct execution and is equivalent\n// to omission because action/open-world hints alone never enable the confirmation gate.\nconst action = annotations.openAction({ destructive: false, confirm: false });\n\nfunction checkoutUrl(customer: string): string {\n return `https://orders.example.com/checkout?customer=${encodeURIComponent(customer)}`;\n}\n\nfunction cartValue(input: CartInput, status: 'draft' | 'review' | 'handoff') {\n return {\n selectedStoreId: input.selectedStoreId,\n lines: input.lines,\n customer: input.customer,\n notes: input.notes,\n subtotal: input.subtotal,\n status,\n ...(status === 'handoff' ? { checkoutUrl: 'https://orders.example.com/checkout' } : {}),\n };\n}\n\nexport default server(\n 'food_ordering',\n {\n title: 'Food Ordering',\n version: '1.0.0',\n agentGuide: FOOD_ORDERING_AGENT_GUIDE,\n distribution: {\n listing: {\n summary: 'Build a pickup noodle order.',\n description:\n 'Food Ordering is a synthetic MCP App that demonstrates store discovery, menu browsing, a caller-scoped cart, fulfilment planning, and explicit checkout handoff.',\n keywords: ['food', 'ordering', 'delivery'],\n },\n publisher: {\n name: 'Noodle Seed Examples',\n websiteUrl: 'https://noodleseed.com',\n },\n support: {\n documentationUrl: 'https://docs.noodleseed.com/examples/food-ordering',\n supportUrl: 'https://noodleseed.com/support',\n },\n legal: {\n privacyPolicyUrl: 'https://noodleseed.com/privacy',\n termsOfServiceUrl: 'https://noodleseed.com/terms',\n },\n assets: {\n icon: { source: heroImage, alt: 'Food Ordering noodle bowl' },\n screenshots: [\n {\n source: storesScreenshot,\n alt: 'Food Ordering MCP App showing nearby stores',\n prompt: 'Help me build a noodle order for pickup.',\n },\n {\n source: menuScreenshot,\n alt: 'Food Ordering MCP App showing the Harbor Noodles menu',\n prompt: 'Show me the Harbor Noodles menu.',\n },\n {\n source: handoffScreenshot,\n alt: 'Food Ordering MCP App reviewing a checkout handoff',\n prompt: 'Review my spicy miso bowl order before checkout.',\n },\n ],\n },\n review: {\n instructions:\n 'Use the synthetic menu and guest cart. No account or reviewer credential is required.',\n scenarios: [\n {\n id: 'build_order',\n prompt: 'Help me build a noodle order for pickup.',\n expected:\n 'The ordering app opens with stores and menu items; checkout remains a handoff.',\n shouldInvoke: true,\n tools: ['open_ordering'],\n },\n {\n id: 'browse_menu',\n prompt: 'Show me vegetarian menu options nearby.',\n expected: 'The app shows matching stores and bounded menu choices.',\n shouldInvoke: true,\n tools: ['search_stores', 'load_menu'],\n },\n {\n id: 'compare_options',\n prompt: 'Compare the quickest open food options for me.',\n expected: 'The app grounds its comparison in the synthetic store data.',\n shouldInvoke: true,\n tools: ['search_stores', 'summarize_ordering_options'],\n },\n {\n id: 'plan_pickup',\n prompt: 'Plan a pickup order for Friday.',\n expected: 'The app collects the missing fulfilment details before planning the order.',\n shouldInvoke: true,\n tools: ['plan_order'],\n },\n {\n id: 'review_checkout',\n prompt: 'Review my cart before I continue to checkout.',\n expected: 'The app shows the cart and keeps payment on the explicit external handoff.',\n shouldInvoke: true,\n tools: ['read_cart', 'prepare_checkout'],\n },\n // Negative scenarios are non-invocation cases, so they never declare expected tools.\n {\n id: 'unrelated_weather',\n prompt: 'Will it rain tomorrow?',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n {\n id: 'unrelated_email',\n prompt: 'Draft an email to my manager.',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n {\n id: 'unrelated_travel',\n prompt: 'Book me a flight to Lisbon.',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n ],\n },\n },\n use: { state },\n context: {\n defaults: { locale: 'en-US', timeZone: 'America/New_York' },\n ambient: {\n output: z.object({ serviceArea: z.string(), orderingDate: z.string() }),\n fulfil: ({ context }) => ({\n serviceArea: 'Harbor District',\n orderingDate: context.temporal.localDate,\n }),\n },\n },\n state: {\n handles: {\n cart: {\n kind: 'cart',\n version: 'v1',\n scope: 'caller',\n ttlSeconds: 7200,\n schema: cartStateSchema,\n },\n },\n },\n branding: {\n name: 'Food Ordering',\n accent: '#0F8F5F',\n surface: '#F7F7F5',\n surfaceDark: '#111820',\n logo: {\n uri: heroImage,\n alt: 'Food Ordering noodle bowl',\n },\n radius: 'lg',\n density: 'comfortable',\n },\n handoff: {\n allowedDomains: ['https://orders.example.com'],\n },\n },\n [\n tool('open_ordering', {\n title: 'Open food ordering',\n description:\n 'Open a complete food-ordering widget with store discovery, menu browsing, cart review, and checkout handoff.',\n annotations: readOnly,\n modelVisibility: {\n latestMessageIncludesAny: [\n 'order',\n 'food',\n 'menu',\n 'restaurant',\n 'cart',\n 'pickup',\n 'delivery',\n 'checkout',\n ],\n },\n input: z.object({\n query: z.string().optional(),\n customer: z.string().default('Guest'),\n }),\n // List outputs declare a ceiling so a host and the model both know the payload is bounded.\n // A recorded `fulfil` cannot slice, so the cap belongs on the shape; connector-backed lists take\n // a pagination input instead. `noodle check` reports `tool_design_output_bounds` without one.\n output: z.object({\n status: z.string(),\n customer: z.string(),\n stores: z.array(storeShape).max(20),\n featuredItems: z.array(menuItemShape).max(20),\n localDate: z.string(),\n serviceArea: z.string(),\n location: z.object({\n latitude: z.number().optional(),\n longitude: z.number().optional(),\n }),\n fallback: z.string(),\n }),\n fulfil: ({ input, context }) => ({\n status: 'Ready to build a food order.',\n customer: input.customer,\n stores,\n featuredItems: menu,\n localDate: context.temporal.localDate,\n serviceArea: context.ambient.serviceArea,\n location: {\n latitude: context.location.latitude.optional(),\n longitude: context.location.longitude.optional(),\n },\n fallback: 'Open stores: Harbor Noodles (Noodles), Garden Wraps (Vegetarian).',\n }),\n viewTitle: 'Food ordering',\n domain: 'https://orders.example.com',\n view: {\n component: 'ordering-flow',\n entry: './views/ordering-flow.tsx',\n },\n viewDescription:\n 'A complete consumer ordering surface with app-only helper tools, cart state, and checkout handoff.',\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n tool('search_stores', {\n title: 'Search stores',\n visibility: ['app'],\n description: 'Filter synthetic restaurants for the ordering widget.',\n annotations: readOnly,\n input: z.object({\n query: z.string().optional(),\n openOnly: z.boolean().default(false),\n }),\n output: z.object({ stores: z.array(storeShape) }),\n fulfil: () => ({ stores }),\n }),\n tool('load_menu', {\n title: 'Load store menu',\n visibility: ['app'],\n description: 'Load synthetic menu categories and items for one store.',\n annotations: readOnly,\n input: z.object({ storeId: z.string() }),\n output: z.object({\n storeId: z.string(),\n stores: z.array(storeShape),\n items: z.array(menuItemShape),\n }),\n fulfil: ({ input }) => ({ storeId: input.storeId, stores, items: menu }),\n }),\n tool('load_item', {\n title: 'Load menu item',\n visibility: ['app'],\n description: 'Load item details and modifier options for the ordering widget.',\n annotations: readOnly,\n input: z.object({ itemId: z.string() }),\n output: z.object({ itemId: z.string(), items: z.array(menuItemShape) }),\n fulfil: ({ input }) => ({ itemId: input.itemId, items: menu }),\n }),\n tool('read_cart', {\n title: 'Read ordering cart',\n visibility: ['app'],\n description: 'Read the caller-scoped ordering cart state.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n value: z.unknown(),\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ connectors }) => {\n const state = connectors.state.readState({ handle: 'cart' });\n return { value: state.value, revision: state.revision, status: state.status };\n },\n }),\n tool('sync_cart', {\n title: 'Update ordering cart',\n visibility: ['app'],\n description: 'Patch the caller-scoped ordering cart with the widget cart mirror.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'draft');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return { cart, revision: state.revision, status: state.status };\n },\n }),\n tool('prepare_checkout', {\n title: 'Prepare checkout handoff',\n visibility: ['app'],\n description: 'Prepare the caller-scoped cart for checkout handoff.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n checkoutUrl: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'handoff');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return {\n cart,\n revision: state.revision,\n checkoutUrl: cart.checkoutUrl ?? checkoutUrl(input.customer),\n };\n },\n }),\n tool('summarize_ordering_options', {\n title: 'Summarize ordering options',\n description: 'Summarize available stores and menu examples without opening the widget.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n stores: z.array(storeShape).max(20),\n featuredItems: z.array(menuItemShape).max(20),\n }),\n fulfil: () => ({ stores, featuredItems: menu }),\n }),\n tool('plan_order', {\n title: 'Plan an order',\n description:\n 'Collect a fulfilment method and requested date as structured input, then return a reviewable order plan without placing an order.',\n annotations: readOnly,\n input: z.object({ customer: z.string().default('Guest') }),\n output: z.object({\n customer: z.string(),\n method: z.enum(['pickup', 'delivery']),\n requestedDate: z.string(),\n serviceArea: z.string(),\n }),\n fulfil: ({ input, context, elicit }) => {\n const preference = elicit({\n id: 'choose_fulfilment',\n message: 'How should we fulfil this order?',\n input: z.object({\n method: z.enum(['pickup', 'delivery']).describe('Fulfilment method'),\n requestedDate: z.string().describe('Requested date').meta({ format: 'date' }),\n }),\n });\n return {\n customer: input.customer,\n method: preference.method,\n requestedDate: preference.requestedDate,\n serviceArea: context.ambient.serviceArea,\n };\n },\n }),\n tool('show_capabilities', {\n title: 'Show capabilities',\n description: 'Return a concise summary for the standalone widget capability preview.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({ status: z.string(), note: z.string() }),\n fulfil: () => ({\n status: 'Food Ordering widget capabilities are ready.',\n note: 'Standalone preview covers React views, helper tools, cart state, handoff, CSP, and permissions.',\n }),\n viewName: 'capabilities_card',\n viewTitle: 'Food Ordering capabilities',\n viewDescription: 'Standalone widget resource for previewing the ordering capability surface.',\n domain: 'https://orders.example.com',\n view: { component: 'capabilities-card', entry: './views/capabilities-card.tsx' },\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n resource('food_ordering_guide', {\n uri: 'docs://food-ordering',\n title: 'Food Ordering widget guide',\n description: 'Synthetic guide resource for the consumer ordering flagship.',\n mimeType: 'text/markdown',\n // Return the resource body directly; the runtime maps it into MCP `contents` using the\n // resource's own uri + mimeType. Do not return a `{ contents: [...] }` wrapper — that double-wraps.\n fulfil: () =>\n [\n '# Food Ordering Widget Guide',\n '',\n '- Demonstrates a multi-step ordering widget, app-only helper tools, typed cart state, and handoff.',\n '- Store, menu, and checkout data are synthetic and contain no customer credentials.',\n '- Checkout opens an allowlisted example URL; payment and final ordering remain out of scope.',\n ].join('\\n'),\n }),\n ],\n);\n" },
|
|
65
|
+
{ relPath: "examples/food-ordering/src/server.ts", content: "import { annotations, asset, connector, resource, server, tool, z } from '@noodleseed/one';\nimport { FOOD_ORDERING_AGENT_GUIDE } from './agent-guide.js';\n\nconst heroImage = asset('assets/noodle-bowl.jpg');\nconst storesScreenshot = asset('assets/food-ordering-stores.png');\nconst menuScreenshot = asset('assets/food-ordering-menu.png');\nconst handoffScreenshot = asset('assets/food-ordering-handoff.png');\n\nconst state = connector('noodle_state')\n .version('1.0.0')\n .operation('read_state', {\n type: 'read',\n input: z.object({\n handle: z.string(),\n key: z.string().optional(),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n })\n .operation('patch_state', {\n type: 'action',\n input: z.object({\n handle: z.string(),\n expectedRevision: z.number().int(),\n value: z.record(z.string(), z.unknown()),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n });\n\nconst stores = [\n {\n id: 'harbor-noodles',\n name: 'Harbor Noodles',\n cuisine: 'Noodles',\n address: '18 Pier Lane',\n open: true,\n etaMinutes: 24,\n rating: 4.8,\n },\n {\n id: 'garden-wraps',\n name: 'Garden Wraps',\n cuisine: 'Vegetarian',\n address: '44 Market Street',\n open: true,\n etaMinutes: 18,\n rating: 4.6,\n },\n {\n id: 'midnight-tacos',\n name: 'Midnight Tacos',\n cuisine: 'Mexican',\n address: '7 Station Road',\n open: false,\n etaMinutes: 35,\n rating: 4.7,\n },\n] as const;\n\nconst menu = [\n {\n id: 'spicy_miso',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Spicy Miso Bowl',\n price: 16,\n description: 'Miso broth, wheat noodles, chili crisp, egg, and greens.',\n modifiers: ['extra_noodles', 'soft_egg', 'chili_crisp'],\n },\n {\n id: 'ginger_tofu',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Ginger Tofu Bowl',\n price: 15,\n description: 'Tofu, ginger broth, mushrooms, and scallions.',\n modifiers: ['extra_tofu', 'brown_rice', 'no_mushroom'],\n },\n {\n id: 'green_falafel',\n storeId: 'garden-wraps',\n category: 'Wraps',\n name: 'Green Falafel Wrap',\n price: 13,\n description: 'Falafel, herbs, pickles, tahini, and crisp vegetables.',\n modifiers: ['extra_tahini', 'add_fries', 'gluten_free_wrap'],\n },\n {\n id: 'sweet_potato',\n storeId: 'garden-wraps',\n category: 'Plates',\n name: 'Sweet Potato Plate',\n price: 14,\n description: 'Roasted sweet potato, grains, greens, and lemon yogurt.',\n modifiers: ['vegan_yogurt', 'extra_greens', 'hot_sauce'],\n },\n] as const;\n\nconst cartLine = z.object({\n itemId: z.string(),\n quantity: z.number().int().min(1),\n modifiers: z.array(z.string()).default([]),\n note: z.string().optional(),\n});\n\nconst cartInput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine).default([]),\n customer: z.string().default('Guest'),\n notes: z.string().optional(),\n subtotal: z.number().default(0),\n expectedRevision: z.number().int().min(0).default(0),\n});\n\nconst storeShape = z.object({\n id: z.string(),\n name: z.string(),\n cuisine: z.string(),\n address: z.string(),\n open: z.boolean(),\n etaMinutes: z.number(),\n rating: z.number(),\n});\n\nconst menuItemShape = z.object({\n id: z.string(),\n storeId: z.string(),\n category: z.string(),\n name: z.string(),\n price: z.number(),\n description: z.string(),\n // Nested lists count toward the output budget too: unbounded modifiers multiply by every item in a\n // menu payload, so the ceiling is declared here rather than only on the outer array.\n modifiers: z.array(z.string()).max(10),\n});\n\nconst cartOutput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n status: z.enum(['draft', 'review', 'handoff']),\n checkoutUrl: z.string().optional(),\n});\n\nconst cartStateSchema = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n // `.default()`/`.optional()` state fields are optional on write: a cart save that omits\n // `status` still validates, and a fresh cart starts in `draft`.\n status: z.enum(['draft', 'review', 'handoff']).default('draft'),\n checkoutUrl: z.string().optional(),\n});\n\ntype CartLine = {\n readonly itemId: string;\n readonly quantity: number;\n readonly modifiers: readonly string[];\n readonly note?: string;\n};\n\ntype CartInput = {\n readonly selectedStoreId?: string;\n readonly lines: readonly CartLine[];\n readonly customer: string;\n readonly notes?: string;\n readonly subtotal: number;\n readonly expectedRevision: number;\n};\n\nconst readOnly = annotations.readOnly();\n// These writes are app-only controls inside the cart widget. The widget already presents the\n// reviewed state and explicit button; `confirm: false` documents direct execution and is equivalent\n// to omission because action/open-world hints alone never enable the confirmation gate.\nconst action = annotations.openAction({ destructive: false, confirm: false });\n\nfunction checkoutUrl(customer: string): string {\n return `https://orders.example.com/checkout?customer=${encodeURIComponent(customer)}`;\n}\n\nfunction cartValue(input: CartInput, status: 'draft' | 'review' | 'handoff') {\n return {\n selectedStoreId: input.selectedStoreId,\n lines: input.lines,\n customer: input.customer,\n notes: input.notes,\n subtotal: input.subtotal,\n status,\n ...(status === 'handoff' ? { checkoutUrl: 'https://orders.example.com/checkout' } : {}),\n };\n}\n\nexport default server(\n 'food_ordering',\n {\n title: 'Food Ordering',\n version: '1.0.0',\n agentGuide: FOOD_ORDERING_AGENT_GUIDE,\n distribution: {\n listing: {\n summary: 'Build a pickup noodle order.',\n description:\n 'Food Ordering is a synthetic MCP App that demonstrates store discovery, menu browsing, a caller-scoped cart, fulfilment planning, and explicit checkout handoff.',\n keywords: ['food', 'ordering', 'delivery'],\n },\n publisher: {\n name: 'Noodle Seed Examples',\n websiteUrl: 'https://noodleseed.com',\n },\n support: {\n documentationUrl: 'https://docs.noodleseed.com/examples/food-ordering',\n supportUrl: 'https://noodleseed.com/support',\n },\n legal: {\n privacyPolicyUrl: 'https://noodleseed.com/privacy',\n termsOfServiceUrl: 'https://noodleseed.com/terms',\n },\n assets: {\n icon: { source: heroImage, alt: 'Food Ordering noodle bowl' },\n screenshots: [\n {\n source: storesScreenshot,\n alt: 'Food Ordering MCP App showing nearby stores',\n prompt: 'Help me build a noodle order for pickup.',\n },\n {\n source: menuScreenshot,\n alt: 'Food Ordering MCP App showing the Harbor Noodles menu',\n prompt: 'Show me the Harbor Noodles menu.',\n },\n {\n source: handoffScreenshot,\n alt: 'Food Ordering MCP App reviewing a checkout handoff',\n prompt: 'Review my spicy miso bowl order before checkout.',\n },\n ],\n },\n review: {\n instructions:\n 'Use the synthetic menu and guest cart. No account or reviewer credential is required.',\n scenarios: [\n {\n id: 'build_order',\n prompt: 'Help me build a noodle order for pickup.',\n expected:\n 'The ordering app opens with stores and menu items; checkout remains a handoff.',\n shouldInvoke: true,\n tools: ['open_ordering'],\n },\n {\n id: 'browse_menu',\n prompt: 'Show me vegetarian menu options nearby.',\n expected: 'The app shows matching stores and bounded menu choices.',\n shouldInvoke: true,\n tools: ['search_stores', 'load_menu'],\n },\n {\n id: 'compare_options',\n prompt: 'Compare the quickest open food options for me.',\n expected: 'The app grounds its comparison in the synthetic store data.',\n shouldInvoke: true,\n tools: ['search_stores', 'summarize_ordering_options'],\n },\n {\n id: 'plan_pickup',\n prompt: 'Plan a pickup order for Friday.',\n expected: 'The app collects the missing fulfilment details before planning the order.',\n shouldInvoke: true,\n tools: ['plan_order'],\n },\n {\n id: 'review_checkout',\n prompt: 'Review my cart before I continue to checkout.',\n expected: 'The app shows the cart and keeps payment on the explicit external handoff.',\n shouldInvoke: true,\n tools: ['read_cart', 'prepare_checkout'],\n },\n // Negative scenarios are non-invocation cases, so they never declare expected tools.\n {\n id: 'unrelated_weather',\n prompt: 'Will it rain tomorrow?',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n {\n id: 'unrelated_email',\n prompt: 'Draft an email to my manager.',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n {\n id: 'unrelated_travel',\n prompt: 'Book me a flight to Lisbon.',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n ],\n },\n },\n use: { state },\n context: {\n defaults: { locale: 'en-US', timeZone: 'America/New_York' },\n ambient: {\n output: z.object({ serviceArea: z.string(), orderingDate: z.string() }),\n fulfil: ({ context }) => ({\n serviceArea: 'Harbor District',\n orderingDate: context.temporal.localDate,\n }),\n },\n },\n state: {\n handles: {\n cart: {\n kind: 'cart',\n version: 'v1',\n scope: 'caller',\n ttlSeconds: 7200,\n schema: cartStateSchema,\n },\n },\n },\n branding: {\n name: 'Food Ordering',\n accent: '#0F8F5F',\n surface: '#F7F7F5',\n surfaceDark: '#111820',\n logo: {\n uri: heroImage,\n alt: 'Food Ordering noodle bowl',\n },\n radius: 'lg',\n density: 'comfortable',\n },\n handoff: {\n allowedDomains: ['https://orders.example.com'],\n },\n },\n [\n tool('open_ordering', {\n title: 'Open food ordering',\n description:\n 'Open a complete food-ordering widget with store discovery, menu browsing, cart review, and checkout handoff.',\n annotations: readOnly,\n modelVisibility: {\n latestMessageIncludesAny: [\n 'order',\n 'food',\n 'menu',\n 'restaurant',\n 'cart',\n 'pickup',\n 'delivery',\n 'checkout',\n ],\n oncePerSession: true,\n },\n input: z.object({\n query: z.string().optional(),\n customer: z.string().default('Guest'),\n }),\n // List outputs declare a ceiling so a host and the model both know the payload is bounded.\n // A recorded `fulfil` cannot slice, so the cap belongs on the shape; connector-backed lists take\n // a pagination input instead. `noodle check` reports `tool_design_output_bounds` without one.\n output: z.object({\n status: z.string(),\n customer: z.string(),\n stores: z.array(storeShape).max(20),\n featuredItems: z.array(menuItemShape).max(20),\n localDate: z.string(),\n serviceArea: z.string(),\n location: z.object({\n latitude: z.number().optional(),\n longitude: z.number().optional(),\n }),\n fallback: z.string(),\n }),\n fulfil: ({ input, context }) => ({\n status: 'Ready to build a food order.',\n customer: input.customer,\n stores,\n featuredItems: menu,\n localDate: context.temporal.localDate,\n serviceArea: context.ambient.serviceArea,\n location: {\n latitude: context.location.latitude.optional(),\n longitude: context.location.longitude.optional(),\n },\n fallback: 'Open stores: Harbor Noodles (Noodles), Garden Wraps (Vegetarian).',\n }),\n viewTitle: 'Food ordering',\n domain: 'https://orders.example.com',\n view: {\n component: 'ordering-flow',\n entry: './views/ordering-flow.tsx',\n },\n viewDescription:\n 'A complete consumer ordering surface with app-only helper tools, cart state, and checkout handoff.',\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n tool('search_stores', {\n title: 'Search stores',\n visibility: ['app'],\n description: 'Filter synthetic restaurants for the ordering widget.',\n annotations: readOnly,\n input: z.object({\n query: z.string().optional(),\n openOnly: z.boolean().default(false),\n }),\n output: z.object({ stores: z.array(storeShape) }),\n fulfil: () => ({ stores }),\n }),\n tool('load_menu', {\n title: 'Load store menu',\n visibility: ['app'],\n description: 'Load synthetic menu categories and items for one store.',\n annotations: readOnly,\n input: z.object({ storeId: z.string() }),\n output: z.object({\n storeId: z.string(),\n stores: z.array(storeShape),\n items: z.array(menuItemShape),\n }),\n fulfil: ({ input }) => ({ storeId: input.storeId, stores, items: menu }),\n }),\n tool('load_item', {\n title: 'Load menu item',\n visibility: ['app'],\n description: 'Load item details and modifier options for the ordering widget.',\n annotations: readOnly,\n input: z.object({ itemId: z.string() }),\n output: z.object({ itemId: z.string(), items: z.array(menuItemShape) }),\n fulfil: ({ input }) => ({ itemId: input.itemId, items: menu }),\n }),\n tool('read_cart', {\n title: 'Read ordering cart',\n visibility: ['app'],\n description: 'Read the caller-scoped ordering cart state.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n value: z.unknown(),\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ connectors }) => {\n const state = connectors.state.readState({ handle: 'cart' });\n return { value: state.value, revision: state.revision, status: state.status };\n },\n }),\n tool('sync_cart', {\n title: 'Update ordering cart',\n visibility: ['app'],\n description: 'Patch the caller-scoped ordering cart with the widget cart mirror.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'draft');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return { cart, revision: state.revision, status: state.status };\n },\n }),\n tool('prepare_checkout', {\n title: 'Prepare checkout handoff',\n visibility: ['app'],\n description: 'Prepare the caller-scoped cart for checkout handoff.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n checkoutUrl: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'handoff');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return {\n cart,\n revision: state.revision,\n checkoutUrl: cart.checkoutUrl ?? checkoutUrl(input.customer),\n };\n },\n }),\n tool('summarize_ordering_options', {\n title: 'Summarize ordering options',\n description: 'Summarize available stores and menu examples without opening the widget.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n stores: z.array(storeShape).max(20),\n featuredItems: z.array(menuItemShape).max(20),\n }),\n fulfil: () => ({ stores, featuredItems: menu }),\n }),\n tool('plan_order', {\n title: 'Plan an order',\n description:\n 'Collect a fulfilment method and requested date as structured input, then return a reviewable order plan without placing an order.',\n annotations: readOnly,\n input: z.object({ customer: z.string().default('Guest') }),\n output: z.object({\n customer: z.string(),\n method: z.enum(['pickup', 'delivery']),\n requestedDate: z.string(),\n serviceArea: z.string(),\n }),\n fulfil: ({ input, context, elicit }) => {\n const preference = elicit({\n id: 'choose_fulfilment',\n message: 'How should we fulfil this order?',\n input: z.object({\n method: z.enum(['pickup', 'delivery']).describe('Fulfilment method'),\n requestedDate: z.string().describe('Requested date').meta({ format: 'date' }),\n }),\n });\n return {\n customer: input.customer,\n method: preference.method,\n requestedDate: preference.requestedDate,\n serviceArea: context.ambient.serviceArea,\n };\n },\n }),\n tool('show_capabilities', {\n title: 'Show capabilities',\n description: 'Return a concise summary for the standalone widget capability preview.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({ status: z.string(), note: z.string() }),\n fulfil: () => ({\n status: 'Food Ordering widget capabilities are ready.',\n note: 'Standalone preview covers React views, helper tools, cart state, handoff, CSP, and permissions.',\n }),\n viewName: 'capabilities_card',\n viewTitle: 'Food Ordering capabilities',\n viewDescription: 'Standalone widget resource for previewing the ordering capability surface.',\n domain: 'https://orders.example.com',\n view: { component: 'capabilities-card', entry: './views/capabilities-card.tsx' },\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n resource('food_ordering_guide', {\n uri: 'docs://food-ordering',\n title: 'Food Ordering widget guide',\n description: 'Synthetic guide resource for the consumer ordering flagship.',\n mimeType: 'text/markdown',\n // Return the resource body directly; the runtime maps it into MCP `contents` using the\n // resource's own uri + mimeType. Do not return a `{ contents: [...] }` wrapper — that double-wraps.\n fulfil: () =>\n [\n '# Food Ordering Widget Guide',\n '',\n '- Demonstrates a multi-step ordering widget, app-only helper tools, typed cart state, and handoff.',\n '- Store, menu, and checkout data are synthetic and contain no customer credentials.',\n '- Checkout opens an allowlisted example URL; payment and final ordering remain out of scope.',\n ].join('\\n'),\n }),\n ],\n);\n" },
|
|
66
66
|
{ relPath: "examples/food-ordering/src/views/capabilities-card.tsx", content: "import { useLayout, useOpenExternal, useSendFollowUpMessage, useToolInfo } from '../helpers.js';\nimport './widget-style.css';\n\nconst capabilities: readonly {\n readonly name: string;\n readonly description: string;\n}[] = [\n {\n name: 'React view resource',\n description: 'Compiled widget HTML with a hydrated React entrypoint.',\n },\n {\n name: 'Widget helper calls',\n description: 'App-only tools route through the same host-mediated path.',\n },\n {\n name: 'Durable cart state',\n description: 'Caller-scoped cart records use Noodle state handles with revisions.',\n },\n {\n name: 'External handoff',\n description: 'Checkout opens through declared allowlisted domains.',\n },\n {\n name: 'Model context',\n description: 'Relevant UI state is mirrored through data-llm.',\n },\n {\n name: 'CSP and permissions',\n description: 'Resource metadata declares network and clipboard needs.',\n },\n] as const;\n\nexport default function CapabilitiesCard() {\n const { theme } = useLayout();\n const toolInfo = useToolInfo('show_capabilities');\n const openExternal = useOpenExternal();\n const sendFollowUpMessage = useSendFollowUpMessage();\n const structured = toolInfo.structuredContent as\n | { readonly status?: string; readonly note?: string }\n | undefined;\n\n return (\n <main\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n data-llm={`Food Ordering capabilities: ${capabilities.map((item) => item.name).join(', ')}`}\n >\n <section className=\"nw-card\">\n <header className=\"nw-header\">\n <span className=\"nw-icon\" aria-hidden=\"true\">\n <PuzzleIcon />\n </span>\n <div className=\"nw-title-block\">\n <h1 className=\"nw-title\">MCP capabilities</h1>\n <p className=\"nw-subtitle\">\n {structured?.status ?? 'Food Ordering widget capabilities are ready.'}\n </p>\n </div>\n <span className=\"nw-chip\">Connected</span>\n </header>\n <div className=\"nw-body\">\n <p className=\"nw-section-title\">Available features</p>\n <ul className=\"nw-feature-list\">\n {capabilities.map((capability) => (\n <li className=\"nw-feature\" key={capability.name}>\n <span className=\"nw-check\" aria-hidden=\"true\">\n <CheckIcon />\n </span>\n <span>\n <span className=\"nw-feature-name\">{capability.name}</span>\n <span className=\"nw-feature-desc\">{capability.description}</span>\n </span>\n </li>\n ))}\n </ul>\n <div className=\"nw-actions\">\n <button\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n onClick={() =>\n sendFollowUpMessage({\n prompt: 'Summarize what the Food Ordering MCP App widget demonstrates.',\n })\n }\n >\n <SparkIcon />\n Ask assistant\n </button>\n <button\n className=\"nw-button\"\n type=\"button\"\n onClick={() => openExternal('https://example.com/noodle-widget-docs')}\n >\n <ExternalIcon />\n Open docs\n </button>\n </div>\n {structured?.note ? <p className=\"nw-note\">{structured.note}</p> : null}\n </div>\n <footer className=\"nw-footer\">\n <span className=\"nw-meta\">\n <ShieldIcon />\n Secure widget surface\n </span>\n <span>v1.0.0</span>\n </footer>\n </section>\n </main>\n );\n}\n\nfunction PuzzleIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M8 4h5v4h3a2 2 0 0 1 0 4h-3v3H9v-3H6a2 2 0 0 1 0-4h2V4Z\" />\n <path d=\"M13 15v5H4v-5\" />\n <path d=\"M13 20h7v-8\" />\n </svg>\n );\n}\n\nfunction CheckIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"m6 12 4 4 8-8\" />\n </svg>\n );\n}\n\nfunction SparkIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"m12 3 1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z\" />\n </svg>\n );\n}\n\nfunction ExternalIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M14 4h6v6\" />\n <path d=\"m20 4-9 9\" />\n <path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\" />\n </svg>\n );\n}\n\nfunction ShieldIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M12 3 5 6v5c0 4.4 2.8 8.3 7 10 4.2-1.7 7-5.6 7-10V6l-7-3Z\" />\n <path d=\"m9 12 2 2 4-4\" />\n </svg>\n );\n}\n" },
|
|
67
67
|
{ relPath: "examples/food-ordering/src/views/ordering-flow.tsx", content: "import { useEffect, useMemo, useState } from 'react';\nimport {\n ActionBar,\n AppShell,\n AsyncBoundary,\n ChoiceGroup,\n createViewStore,\n DataCard,\n DataList,\n Feedback,\n Field,\n Form,\n HandoffButton,\n QuantityStepper,\n ShellNav,\n StatusBadge,\n SubmitButton,\n useAppFlow,\n useCallTool,\n useHandoff,\n useLayout,\n useSendFollowUpMessage,\n useToolInfo,\n useUpdateModelContext,\n useViewState,\n useWidgetLifecycle,\n useWidgetReady,\n View,\n ViewStack,\n} from '../helpers.js';\nimport { isOrderingEntryResult, type MenuItem, type Store } from './ordering-result.js';\nimport './widget-style.css';\n\ntype ViewName = 'stores' | 'menu' | 'item' | 'cart' | 'review' | 'handoff';\n\ntype CartLine = {\n readonly itemId: string;\n readonly quantity: number;\n readonly modifiers: readonly string[];\n readonly note?: string;\n};\n\ntype CartState = {\n readonly selectedStoreId?: string;\n readonly lines: readonly CartLine[];\n readonly customer: string;\n readonly notes?: string;\n readonly subtotal: number;\n readonly status: 'draft' | 'review' | 'handoff';\n readonly checkoutUrl?: string;\n};\n\nconst zeroCart: CartState = {\n lines: [],\n customer: 'Guest',\n subtotal: 0,\n status: 'draft',\n};\n\nconst useCartStore = createViewStore<CartState>('cart', zeroCart);\nconst useCartRevisionStore = createViewStore('cart_revision', { value: 0 });\n\nfunction structured<T>(value: unknown): T | undefined {\n return (value as { structuredContent?: T } | undefined)?.structuredContent;\n}\n\nfunction currency(value: number): string {\n return `$${value.toFixed(2)}`;\n}\n\nfunction modifierLabel(value: string): string {\n return value\n .split('_')\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ');\n}\n\nexport default function OrderingFlow() {\n const ready = useWidgetReady();\n const { displayMode, supports, theme } = useLayout();\n const toolInfo = useToolInfo('open_ordering');\n const isPending = !ready || Object.keys(toolInfo).length === 0;\n const entry = isOrderingEntryResult(toolInfo.structuredContent)\n ? toolInfo.structuredContent\n : undefined;\n const flow = useAppFlow<ViewName>({\n key: 'ordering_flow',\n initialView: 'stores',\n views: ['stores', 'menu', 'item', 'cart', 'review', 'handoff'],\n });\n const view = flow.activeView;\n const setView = flow.navigate;\n const handoff = useHandoff();\n const sendFollowUpMessage = useSendFollowUpMessage();\n const updateModelContext = useUpdateModelContext();\n const publishLifecycle = useWidgetLifecycle('ordering-flow');\n const searchStores = useCallTool('search_stores');\n const loadMenu = useCallTool('load_menu');\n const loadItem = useCallTool('load_item');\n const readCart = useCallTool('read_cart');\n const syncCart = useCallTool('sync_cart');\n const prepareCheckout = useCallTool('prepare_checkout');\n\n const [query, setQuery] = useViewState('query', '');\n const [customer, setCustomer] = useViewState('customer', entry?.customer ?? 'Guest');\n const [selectedStoreId, setSelectedStoreId] = useViewState<string | undefined>(\n 'selected_store',\n entry?.stores?.[0]?.id,\n );\n const [selectedItemId, setSelectedItemId] = useViewState<string | undefined>(\n 'selected_item',\n entry?.featuredItems?.[0]?.id,\n );\n const cartStore = useCartStore();\n const revisionStore = useCartRevisionStore((state) => state.value);\n const cart =\n cartStore.state.customer === 'Guest' && entry?.customer\n ? { ...cartStore.state, customer: entry.customer }\n : cartStore.state;\n const revision = revisionStore.selected;\n const setCart = cartStore.setState;\n const setRevision = (value: number) => revisionStore.setState({ value });\n const [quantity, setQuantity] = useState(1);\n const [selectedModifiers, setSelectedModifiers] = useState<readonly string[]>([]);\n const [lifecycle, setLifecycle] = useState<'active' | 'submitted'>('active');\n\n const storeData = structured<{ readonly stores?: readonly Store[] }>(searchStores.data);\n const menuData = structured<{\n readonly stores?: readonly Store[];\n readonly items?: readonly MenuItem[];\n }>(loadMenu.data);\n const itemData = structured<{ readonly items?: readonly MenuItem[] }>(loadItem.data);\n const checkoutData = structured<{ readonly checkoutUrl?: string; readonly cart?: CartState }>(\n prepareCheckout.data,\n );\n const stores = storeData?.stores ?? entry?.stores ?? [];\n const queryText = query.trim().toLowerCase();\n const displayedStores = stores.filter(\n (store) =>\n queryText.length === 0 ||\n store.name.toLowerCase().includes(queryText) ||\n store.cuisine.toLowerCase().includes(queryText),\n );\n const allItems = menuData?.items ?? itemData?.items ?? entry?.featuredItems ?? [];\n const items = allItems.filter((item) => item.storeId === selectedStoreId);\n const currentStore =\n stores.find((store) => store.id === selectedStoreId) ?? menuData?.stores?.[0] ?? stores[0];\n const currentItem = allItems.find((item) => item.id === selectedItemId) ?? items[0];\n const lineItems = useMemo(\n () =>\n cart.lines.map((line) => ({\n ...line,\n item: [...(entry?.featuredItems ?? []), ...allItems].find(\n (item) => item.id === line.itemId,\n ),\n })),\n [cart.lines, entry?.featuredItems, allItems],\n );\n const llmSummary = `${view} view for ${customer}; ${cart.lines.length} cart lines; subtotal ${currency(\n cart.subtotal,\n )}`;\n const canUpdateModelContext = supports?.modelContext === true;\n\n useEffect(() => {\n if (!entry || !canUpdateModelContext) return;\n // Each call replaces model context, so publish one cohesive snapshot of current surface state.\n void updateModelContext({\n content: [{ type: 'text', text: `Food ordering: ${llmSummary}; ${lifecycle}.` }],\n structuredContent: {\n widget: { name: 'ordering-flow', lifecycle },\n ordering: {\n view,\n customer,\n cartLines: cart.lines.length,\n subtotal: cart.subtotal,\n selectedStoreId: selectedStoreId ?? null,\n selectedItemId: selectedItemId ?? null,\n },\n },\n });\n }, [\n canUpdateModelContext,\n cart.lines.length,\n cart.subtotal,\n customer,\n entry,\n lifecycle,\n llmSummary,\n selectedItemId,\n selectedStoreId,\n updateModelContext,\n view,\n ]);\n\n async function chooseStore(store: Store) {\n setSelectedStoreId(store.id);\n setView('menu');\n const result = await loadMenu.callTool({ storeId: store.id });\n const loaded = structured<{ readonly items?: readonly MenuItem[] }>(result);\n setSelectedItemId(loaded?.items?.find((item) => item.storeId === store.id)?.id);\n }\n\n async function chooseItem(item: MenuItem) {\n setSelectedItemId(item.id);\n setSelectedModifiers([]);\n setQuantity(1);\n setView('item');\n await loadItem.callTool({ itemId: item.id });\n }\n\n async function persistCart(nextCart: CartState, nextView: ViewName) {\n const result = await syncCart.callTool({\n selectedStoreId: nextCart.selectedStoreId,\n lines: nextCart.lines,\n customer: nextCart.customer,\n notes: nextCart.notes,\n subtotal: nextCart.subtotal,\n expectedRevision: revision,\n });\n const synced = structured<{ readonly cart?: CartState; readonly revision?: number }>(result);\n setCart(synced?.cart ?? nextCart);\n setRevision(synced?.revision ?? revision + 1);\n setView(nextView);\n }\n\n async function addCurrentItem() {\n if (!currentItem) return;\n const nextLines = [\n ...cart.lines,\n {\n itemId: currentItem.id,\n quantity,\n modifiers: selectedModifiers,\n },\n ];\n await persistCart(\n {\n ...cart,\n selectedStoreId: currentStore?.id ?? selectedStoreId,\n lines: nextLines,\n customer,\n subtotal: nextLines.reduce(\n (sum, line) =>\n sum +\n ([...(entry?.featuredItems ?? []), ...allItems].find((item) => item.id === line.itemId)\n ?.price ?? 0) *\n line.quantity,\n 0,\n ),\n status: 'draft',\n },\n 'cart',\n );\n }\n\n async function prepareHandoff() {\n const result = await prepareCheckout.callTool({\n selectedStoreId: cart.selectedStoreId,\n lines: cart.lines,\n customer,\n notes: cart.notes,\n subtotal: cart.subtotal,\n expectedRevision: revision,\n });\n const prepared = structured<{\n readonly cart?: CartState;\n readonly revision?: number;\n readonly checkoutUrl?: string;\n }>(result);\n const preparedCart = prepared?.cart ?? cart;\n setCart(preparedCart);\n setRevision(prepared?.revision ?? revision + 1);\n setLifecycle('submitted');\n setView('handoff');\n if (canUpdateModelContext) {\n await publishLifecycle('submitted', {\n view: 'handoff',\n customer,\n cartLines: preparedCart.lines.length,\n subtotal: preparedCart.subtotal,\n selectedStoreId: preparedCart.selectedStoreId ?? null,\n selectedItemId: selectedItemId ?? null,\n });\n }\n if (supports?.followUpMessage) {\n await sendFollowUpMessage({\n prompt: 'Checkout is prepared. Confirm the cart summary and explain the final handoff.',\n });\n }\n }\n\n if (isPending) {\n return (\n <Feedback status=\"loading\" title=\"Loading\">\n Loading food ordering…\n </Feedback>\n );\n }\n if (toolInfo.isError) {\n return (\n <Feedback status=\"error\" title=\"Ordering unavailable\">\n Could not load food ordering.\n </Feedback>\n );\n }\n if (!entry) {\n return (\n <Feedback status=\"error\" title=\"Invalid ordering result\">\n Food ordering result was incomplete.\n </Feedback>\n );\n }\n\n return (\n <AppShell\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n data-llm={llmSummary}\n title=\"Food Ordering\"\n subtitle={entry?.status ?? 'Find a store, build a cart, and hand off checkout.'}\n icon={<BowlIcon />}\n badge={displayMode === 'fullscreen' ? 'Fullscreen' : view}\n footer={\n <>\n <span className=\"nw-meta\">\n <BagIcon />\n {cart.lines.length} items\n </span>\n <span className=\"nw-meta\">Revision {revision}</span>\n </>\n }\n >\n <ShellNav\n activeView={view}\n aria-label=\"Ordering steps\"\n items={(['stores', 'menu', 'cart', 'review', 'handoff'] as const).map((step) => ({\n view: step,\n label: step,\n }))}\n onNavigate={setView}\n />\n <div className=\"nw-body\">\n <ViewStack flow={flow}>\n <View name=\"stores\">\n <Form onSubmit={() => void searchStores.callTool({ query, openOnly: false })}>\n <div className=\"nw-field-grid\">\n <Field className=\"nw-field\" label=\"Customer\">\n <input\n className=\"nw-input\"\n value={customer}\n onChange={(event) => setCustomer(event.currentTarget.value)}\n />\n </Field>\n <Field className=\"nw-field\" label=\"Search\">\n <input\n className=\"nw-input\"\n value={query}\n placeholder=\"Noodles\"\n onChange={(event) => setQuery(event.currentTarget.value)}\n />\n </Field>\n </div>\n <ActionBar className=\"nw-actions\">\n <SubmitButton\n type=\"submit\"\n className=\"nw-button nw-button-primary\"\n disabled={!ready}\n pending={searchStores.isPending}\n pendingLabel=\"Searching...\"\n >\n <SearchIcon />\n Search stores\n </SubmitButton>\n <SubmitButton\n type=\"button\"\n className=\"nw-button\"\n pending={readCart.isPending}\n pendingLabel=\"Loading...\"\n onClick={async () => {\n const result = await readCart.callTool({});\n const stored = structured<{\n readonly value?: CartState;\n readonly revision?: number;\n }>(result);\n if (stored?.value?.lines) setCart(stored.value);\n setRevision(stored?.revision ?? revision);\n }}\n >\n <RefreshIcon />\n Load cart\n </SubmitButton>\n </ActionBar>\n </Form>\n <AsyncBoundary\n state={searchStores}\n isEmpty={displayedStores.length === 0}\n empty=\"No matching stores\"\n >\n <StoreList stores={displayedStores} onChoose={chooseStore} />\n </AsyncBoundary>\n </View>\n\n <View name=\"menu\">\n <SectionHeader title={currentStore?.name ?? 'Menu'} detail={currentStore?.address} />\n <AsyncBoundary\n state={loadMenu}\n isEmpty={items.length === 0}\n empty=\"No menu items loaded\"\n >\n <ItemList items={items} onChoose={chooseItem} />\n </AsyncBoundary>\n </View>\n\n <View name=\"item\">\n {currentItem ? (\n <>\n <SectionHeader title={currentItem.name} detail={currentItem.description} />\n <div className=\"nw-field-grid\">\n <Field className=\"nw-field\" label=\"Quantity\">\n <QuantityStepper value={quantity} min={1} onChange={setQuantity} />\n </Field>\n <div className=\"nw-summary nw-summary-compact\">\n <div className=\"nw-summary-row\">\n <dt>Price</dt>\n <dd>{currency(currentItem.price)}</dd>\n </div>\n </div>\n </div>\n <ChoiceGroup\n className=\"nw-modifier-grid\"\n values={currentItem.modifiers}\n selected={selectedModifiers}\n onChange={setSelectedModifiers}\n labelFor={modifierLabel}\n />\n <ActionBar className=\"nw-actions\">\n <SubmitButton\n type=\"button\"\n className=\"nw-button nw-button-primary\"\n pending={syncCart.isPending}\n pendingLabel=\"Adding...\"\n onClick={addCurrentItem}\n >\n <BagIcon />\n Add to cart\n </SubmitButton>\n <button className=\"nw-button\" type=\"button\" onClick={() => setView('menu')}>\n Back to menu\n </button>\n </ActionBar>\n </>\n ) : null}\n </View>\n\n <View name=\"cart\">\n <SectionHeader\n title=\"Cart\"\n detail={`${cart.lines.length} line${cart.lines.length === 1 ? '' : 's'}`}\n />\n <CartSummary lineItems={lineItems} subtotal={cart.subtotal} />\n <Field className=\"nw-field\" label=\"Notes\">\n <input\n className=\"nw-input\"\n value={cart.notes ?? ''}\n placeholder=\"Utensils, pickup name, or allergies\"\n onChange={(event) => setCart({ ...cart, notes: event.currentTarget.value })}\n />\n </Field>\n <ActionBar className=\"nw-actions\">\n <SubmitButton\n type=\"button\"\n className=\"nw-button\"\n pending={syncCart.isPending}\n pendingLabel=\"Saving...\"\n onClick={() => persistCart({ ...cart, customer, status: 'draft' }, 'review')}\n >\n <SaveIcon />\n Save cart\n </SubmitButton>\n <SubmitButton\n type=\"button\"\n className=\"nw-button nw-button-primary\"\n disabled={cart.lines.length === 0}\n pending={prepareCheckout.isPending}\n pendingLabel=\"Preparing...\"\n onClick={prepareHandoff}\n >\n <ExternalIcon />\n Prepare checkout\n </SubmitButton>\n </ActionBar>\n </View>\n\n <View name=\"review\">\n <SectionHeader\n title=\"Review order\"\n detail={`${cart.lines.length} line${cart.lines.length === 1 ? '' : 's'}`}\n />\n <CartSummary lineItems={lineItems} subtotal={cart.subtotal} />\n <ActionBar className=\"nw-actions\">\n <button className=\"nw-button\" type=\"button\" onClick={() => setView('cart')}>\n Edit cart\n </button>\n <SubmitButton\n type=\"button\"\n className=\"nw-button nw-button-primary\"\n disabled={cart.lines.length === 0}\n pending={prepareCheckout.isPending}\n pendingLabel=\"Preparing...\"\n onClick={prepareHandoff}\n >\n <ExternalIcon />\n Prepare checkout\n </SubmitButton>\n </ActionBar>\n </View>\n\n <View name=\"handoff\">\n <SectionHeader\n title=\"Checkout handoff\"\n detail=\"Payment stays with the ordering site.\"\n />\n <CartSummary lineItems={lineItems} subtotal={cart.subtotal} />\n <p className=\"nw-note\">\n The MCP App prepared a synthetic checkout URL and will open only the allowlisted\n example domain.\n </p>\n <ActionBar className=\"nw-actions\">\n <HandoffButton\n handoff={handoff}\n target={checkoutData?.checkoutUrl ?? cart.checkoutUrl ?? ''}\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n pendingLabel=\"Opening...\"\n >\n <ExternalIcon />\n Continue checkout\n </HandoffButton>\n <button\n className=\"nw-button\"\n type=\"button\"\n onClick={() =>\n sendFollowUpMessage({\n prompt: `Summarize my food order for ${customer} before checkout.`,\n })\n }\n >\n <SparkIcon />\n Ask assistant\n </button>\n </ActionBar>\n </View>\n </ViewStack>\n </div>\n </AppShell>\n );\n}\n\nfunction SectionHeader({ title, detail }: { readonly title: string; readonly detail?: string }) {\n return (\n <div className=\"nw-section-head\">\n <p className=\"nw-section-title\">{title}</p>\n {detail ? <p className=\"nw-section-detail\">{detail}</p> : null}\n </div>\n );\n}\n\nfunction StoreList({\n stores,\n onChoose,\n}: {\n readonly stores: readonly Store[];\n readonly onChoose: (store: Store) => void;\n}) {\n return (\n <DataList className=\"nw-menu-list\">\n {stores.map((store) => (\n <DataCard\n as=\"button\"\n className=\"nw-menu-item\"\n key={store.id}\n type=\"button\"\n onClick={() => onChoose(store)}\n >\n <span>\n <span className=\"nw-menu-name\">{store.name}</span>\n <span className=\"nw-menu-desc\">\n {store.cuisine} · {store.address}\n </span>\n </span>\n <StatusBadge className=\"nw-status\" tone={store.open ? 'success' : 'neutral'}>\n {store.open ? `${store.etaMinutes}m` : 'Closed'}\n </StatusBadge>\n </DataCard>\n ))}\n </DataList>\n );\n}\n\nfunction ItemList({\n items,\n onChoose,\n}: {\n readonly items: readonly MenuItem[];\n readonly onChoose: (item: MenuItem) => void;\n}) {\n return (\n <DataList className=\"nw-menu-list\">\n {items.map((item) => (\n <DataCard\n as=\"button\"\n className=\"nw-menu-item\"\n key={item.id}\n type=\"button\"\n onClick={() => onChoose(item)}\n >\n <span>\n <span className=\"nw-menu-name\">{item.name}</span>\n <span className=\"nw-menu-desc\">{item.description}</span>\n </span>\n <span className=\"nw-price\">{currency(item.price)}</span>\n </DataCard>\n ))}\n </DataList>\n );\n}\n\nfunction CartSummary({\n lineItems,\n subtotal,\n}: {\n readonly lineItems: readonly (CartLine & { readonly item?: MenuItem })[];\n readonly subtotal: number;\n}) {\n return (\n <dl className=\"nw-summary\">\n {lineItems.length === 0 ? (\n <div className=\"nw-summary-row\">\n <dt>No items yet</dt>\n <dd>{currency(0)}</dd>\n </div>\n ) : (\n lineItems.map((line, index) => (\n <div className=\"nw-summary-row\" key={`${line.itemId}-${index}`}>\n <dt>\n {line.quantity} x {line.item?.name ?? line.itemId}\n {line.modifiers.length > 0 ? (\n <span className=\"nw-line-note\">{line.modifiers.map(modifierLabel).join(', ')}</span>\n ) : null}\n </dt>\n <dd>{currency((line.item?.price ?? 0) * line.quantity)}</dd>\n </div>\n ))\n )}\n <div className=\"nw-summary-row nw-total\">\n <dt>Subtotal</dt>\n <dd>{currency(subtotal)}</dd>\n </div>\n </dl>\n );\n}\n\nfunction BowlIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M4 11h16a7 7 0 0 1-7 7h-2a7 7 0 0 1-7-7Z\" />\n <path d=\"M7 21h10\" />\n <path d=\"M8 6c0-1 1-1 1-2\" />\n <path d=\"M12 6c0-1 1-1 1-2\" />\n <path d=\"M16 6c0-1 1-1 1-2\" />\n </svg>\n );\n}\n\nfunction SearchIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m20 20-4-4\" />\n </svg>\n );\n}\n\nfunction RefreshIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M20 12a8 8 0 1 1-2.3-5.6\" />\n <path d=\"M20 4v5h-5\" />\n </svg>\n );\n}\n\nfunction BagIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M6 8h12l-1 12H7L6 8Z\" />\n <path d=\"M9 8a3 3 0 0 1 6 0\" />\n </svg>\n );\n}\n\nfunction SaveIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M5 4h12l2 2v14H5V4Z\" />\n <path d=\"M8 4v6h8\" />\n <path d=\"M8 20v-5h8v5\" />\n </svg>\n );\n}\n\nfunction ExternalIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M14 4h6v6\" />\n <path d=\"m20 4-9 9\" />\n <path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\" />\n </svg>\n );\n}\n\nfunction SparkIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"m12 3 1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z\" />\n </svg>\n );\n}\n" },
|
|
68
68
|
{ relPath: "examples/food-ordering/src/views/ordering-result.ts", content: "export type Store = {\n readonly id: string;\n readonly name: string;\n readonly cuisine: string;\n readonly address: string;\n readonly open: boolean;\n readonly etaMinutes: number;\n readonly rating: number;\n};\n\nexport type MenuItem = {\n readonly id: string;\n readonly storeId: string;\n readonly category: string;\n readonly name: string;\n readonly price: number;\n readonly description: string;\n readonly modifiers: readonly string[];\n};\n\nexport type OrderingEntryResult = {\n readonly customer: string;\n readonly stores: readonly Store[];\n readonly featuredItems: readonly MenuItem[];\n readonly status: string;\n};\n\nfunction nonEmptyString(value: unknown): value is string {\n return typeof value === 'string' && value.trim().length > 0;\n}\n\nfunction isStore(value: unknown): value is Store {\n if (value === null || typeof value !== 'object') return false;\n const store = value as Partial<Store>;\n return (\n nonEmptyString(store.id) &&\n nonEmptyString(store.name) &&\n nonEmptyString(store.cuisine) &&\n nonEmptyString(store.address) &&\n typeof store.open === 'boolean' &&\n typeof store.etaMinutes === 'number' &&\n Number.isFinite(store.etaMinutes) &&\n typeof store.rating === 'number' &&\n Number.isFinite(store.rating)\n );\n}\n\nfunction isMenuItem(value: unknown): value is MenuItem {\n if (value === null || typeof value !== 'object') return false;\n const item = value as Partial<MenuItem>;\n return (\n nonEmptyString(item.id) &&\n nonEmptyString(item.storeId) &&\n nonEmptyString(item.category) &&\n nonEmptyString(item.name) &&\n typeof item.price === 'number' &&\n Number.isFinite(item.price) &&\n nonEmptyString(item.description) &&\n Array.isArray(item.modifiers) &&\n item.modifiers.every(nonEmptyString)\n );\n}\n\nexport function isOrderingEntryResult(value: unknown): value is OrderingEntryResult {\n if (value === null || typeof value !== 'object') return false;\n const entry = value as Partial<OrderingEntryResult>;\n if (\n !nonEmptyString(entry.customer) ||\n !nonEmptyString(entry.status) ||\n !Array.isArray(entry.stores) ||\n !entry.stores.every(isStore) ||\n !Array.isArray(entry.featuredItems) ||\n !entry.featuredItems.every(isMenuItem)\n ) {\n return false;\n }\n const storeIds = new Set(entry.stores.map((store) => store.id));\n const itemIds = new Set(entry.featuredItems.map((item) => item.id));\n return (\n storeIds.size === entry.stores.length &&\n itemIds.size === entry.featuredItems.length &&\n entry.featuredItems.every((item) => storeIds.has(item.storeId))\n );\n}\n" },
|
|
69
69
|
{ relPath: "examples/food-ordering/src/views/widget-style.css", content: ":root {\n color-scheme: light dark;\n font-family:\n Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --nw-bg: #ffffff;\n --nw-surface: #f7f7f5;\n --nw-surface-strong: #ffffff;\n --nw-text: #1f2328;\n --nw-muted: #677079;\n --nw-border: #d9dee3;\n --nw-accent: #0f8f5f;\n --nw-accent-strong: #047857;\n --nw-accent-soft: #e8f7ef;\n --nw-info: #1d6feb;\n --nw-info-soft: #eaf2ff;\n --nw-warn: #b7791f;\n --nw-danger: #c2410c;\n --nw-shadow: 0 18px 50px rgb(15 23 42 / 12%);\n --nw-radius: 8px;\n}\n\n.dark,\n[data-theme=\"dark\"] {\n --nw-bg: #111820;\n --nw-surface: #16212a;\n --nw-surface-strong: #101820;\n --nw-text: #f4f7f8;\n --nw-muted: #a4b0ba;\n --nw-border: #33414c;\n --nw-accent: #25c385;\n --nw-accent-strong: #19a974;\n --nw-accent-soft: #123a2b;\n --nw-info: #4c9aff;\n --nw-info-soft: #132b4d;\n --nw-warn: #f0b35b;\n --nw-danger: #fb7b54;\n --nw-shadow: 0 18px 50px rgb(0 0 0 / 28%);\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\nbutton,\ninput,\nselect {\n font: inherit;\n}\n\n.nw-shell {\n min-height: 100vh;\n padding: 14px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-card {\n width: min(100%, 560px);\n margin: 0 auto;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-surface-strong);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nw-card-wide {\n width: min(100%, 720px);\n}\n\n.nsr-card {\n width: min(100%, 560px);\n margin: 0 auto;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-surface-strong);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nsr-card-wide {\n width: min(100%, 720px);\n}\n\n.nw-header {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 14px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nsr-header {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 14px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nw-icon {\n display: inline-grid;\n width: 32px;\n height: 32px;\n flex: 0 0 auto;\n place-items: center;\n border-radius: 8px;\n background: linear-gradient(145deg, var(--nw-accent), var(--nw-accent-strong));\n color: white;\n}\n\n.nsr-icon {\n display: inline-grid;\n width: 32px;\n height: 32px;\n flex: 0 0 auto;\n place-items: center;\n border-radius: 8px;\n background: linear-gradient(145deg, var(--nw-accent), var(--nw-accent-strong));\n color: white;\n}\n\n.nw-icon svg,\n.nsr-icon svg,\n.nw-button svg,\n.nw-meta svg {\n width: 16px;\n height: 16px;\n stroke: currentColor;\n stroke-width: 2;\n fill: none;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-title-block {\n min-width: 0;\n flex: 1;\n}\n\n.nsr-title-block {\n min-width: 0;\n flex: 1;\n}\n\n.nw-title {\n margin: 0;\n font-size: 16px;\n line-height: 1.2;\n font-weight: 700;\n letter-spacing: 0;\n}\n\n.nsr-title {\n margin: 0;\n font-size: 16px;\n line-height: 1.2;\n font-weight: 700;\n letter-spacing: 0;\n}\n\n.nw-subtitle {\n margin: 4px 0 0;\n color: var(--nw-muted);\n font-size: 12px;\n line-height: 1.35;\n}\n\n.nsr-subtitle {\n margin: 4px 0 0;\n color: var(--nw-muted);\n font-size: 12px;\n line-height: 1.35;\n}\n\n.nw-chip {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-height: 24px;\n padding: 3px 9px;\n border: 1px solid var(--nw-border);\n border-radius: 999px;\n background: var(--nw-surface);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 600;\n white-space: nowrap;\n}\n\n.nsr-chip {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-height: 24px;\n padding: 3px 9px;\n border: 1px solid var(--nw-border);\n border-radius: 999px;\n background: var(--nw-surface);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 600;\n white-space: nowrap;\n}\n\n.nw-chip::before {\n width: 7px;\n height: 7px;\n border-radius: 99px;\n background: var(--nw-accent);\n content: \"\";\n}\n\n.nsr-chip::before {\n width: 7px;\n height: 7px;\n border-radius: 99px;\n background: var(--nw-accent);\n content: \"\";\n}\n\n.nw-tabs {\n display: grid;\n grid-template-columns: repeat(5, minmax(0, 1fr));\n gap: 1px;\n border-bottom: 1px solid var(--nw-border);\n background: var(--nw-border);\n}\n\n.nsr-tabs {\n display: grid;\n grid-template-columns: repeat(5, minmax(0, 1fr));\n gap: 1px;\n border-bottom: 1px solid var(--nw-border);\n background: var(--nw-border);\n}\n\n.nw-tab {\n min-width: 0;\n min-height: 34px;\n border: 0;\n background: var(--nw-surface-strong);\n color: var(--nw-muted);\n font-size: 12px;\n font-weight: 700;\n text-transform: capitalize;\n cursor: pointer;\n}\n\n.nsr-tab {\n min-width: 0;\n min-height: 34px;\n border: 0;\n background: var(--nw-surface-strong);\n color: var(--nw-muted);\n font-size: 12px;\n font-weight: 700;\n text-transform: capitalize;\n cursor: pointer;\n}\n\n.nw-tab[aria-current=\"step\"] {\n color: var(--nw-text);\n background: var(--nw-accent-soft);\n}\n\n.nsr-tab[aria-current=\"step\"] {\n color: var(--nw-text);\n background: var(--nw-accent-soft);\n}\n\n.nw-body {\n display: grid;\n gap: 14px;\n padding: 14px;\n}\n\n.nw-grid {\n display: grid;\n grid-template-columns: minmax(0, 1fr) minmax(180px, 0.75fr);\n gap: 14px;\n}\n\n.nw-section-title {\n margin: 0 0 8px;\n font-size: 12px;\n font-weight: 700;\n}\n\n.nw-section-head {\n display: grid;\n gap: 2px;\n}\n\n.nw-section-detail {\n margin: 0;\n color: var(--nw-muted);\n font-size: 12px;\n}\n\n.nw-menu-list,\n.nw-summary-list,\n.nw-feature-list {\n display: grid;\n gap: 8px;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.nw-menu-item {\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto;\n gap: 10px;\n width: 100%;\n padding: 10px;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-bg);\n color: var(--nw-text);\n text-align: left;\n cursor: pointer;\n}\n\n.nw-menu-item[aria-pressed=\"true\"] {\n border-color: var(--nw-accent);\n background: var(--nw-accent-soft);\n}\n\n.nw-menu-name,\n.nw-feature-name {\n display: block;\n font-size: 13px;\n font-weight: 700;\n}\n\n.nw-menu-desc,\n.nw-feature-desc {\n display: block;\n margin-top: 3px;\n color: var(--nw-muted);\n font-size: 12px;\n line-height: 1.35;\n}\n\n.nw-price {\n align-self: center;\n color: var(--nw-text);\n font-size: 13px;\n font-weight: 700;\n}\n\n.nw-status {\n align-self: center;\n color: var(--nw-muted);\n font-size: 12px;\n font-weight: 800;\n white-space: nowrap;\n}\n\n.nw-status-ok {\n color: var(--nw-accent-strong);\n}\n\n.nw-field-grid {\n display: grid;\n grid-template-columns: 1fr 120px;\n gap: 10px;\n}\n\n.nw-field {\n display: grid;\n gap: 5px;\n color: var(--nw-muted);\n font-size: 12px;\n font-weight: 650;\n}\n\n.nsr-field-label {\n color: var(--nw-muted);\n}\n\n.nsr-stepper {\n display: grid;\n grid-template-columns: 34px minmax(42px, 1fr) 34px;\n width: 100%;\n min-height: 34px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n overflow: hidden;\n background: var(--nw-bg);\n}\n\n.nsr-stepper button {\n border: 0;\n background: var(--nw-surface);\n color: var(--nw-text);\n cursor: pointer;\n}\n\n.nsr-stepper button:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.nsr-stepper-value {\n display: grid;\n place-items: center;\n color: var(--nw-text);\n font-size: 13px;\n font-weight: 800;\n}\n\n.nw-input,\n.nw-select {\n min-width: 0;\n width: 100%;\n min-height: 34px;\n padding: 7px 9px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n background: var(--nw-bg);\n color: var(--nw-text);\n outline: none;\n}\n\n.nw-input:focus,\n.nw-select:focus,\n.nw-button:focus-visible,\n.nw-menu-item:focus-visible {\n border-color: var(--nw-info);\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--nw-info) 24%, transparent);\n}\n\n.nw-modifier-grid {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 8px;\n}\n\n.nw-option {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 9px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n background: var(--nw-bg);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 650;\n}\n\n.nsr-choice {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 9px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n background: var(--nw-bg);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 650;\n}\n\n.nw-summary {\n display: grid;\n gap: 8px;\n padding: 10px;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-surface);\n}\n\n.nw-summary-compact {\n align-self: end;\n}\n\n.nw-summary-row {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n padding-bottom: 8px;\n border-bottom: 1px solid color-mix(in srgb, var(--nw-border) 70%, transparent);\n}\n\n.nw-summary-row:last-child {\n padding-bottom: 0;\n border-bottom: 0;\n}\n\n.nw-summary-row dt {\n color: var(--nw-muted);\n font-size: 12px;\n}\n\n.nw-line-note {\n display: block;\n margin-top: 2px;\n font-size: 11px;\n font-weight: 500;\n}\n\n.nw-summary-row dd {\n margin: 0;\n text-align: right;\n font-size: 13px;\n font-weight: 700;\n}\n\n.nw-total dd {\n font-size: 18px;\n}\n\n.nw-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n.nsr-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n.nw-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n min-height: 36px;\n padding: 8px 12px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n background: var(--nw-bg);\n color: var(--nw-text);\n font-size: 13px;\n font-weight: 700;\n cursor: pointer;\n}\n\n.nw-button-primary {\n border-color: var(--nw-accent-strong);\n background: linear-gradient(145deg, var(--nw-accent), var(--nw-accent-strong));\n color: white;\n}\n\n.nw-button-info {\n border-color: var(--nw-info);\n background: var(--nw-info);\n color: white;\n}\n\n.nw-button:disabled {\n cursor: wait;\n opacity: 0.65;\n}\n\n.nw-note {\n margin: 0;\n padding: 10px;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-surface);\n color: var(--nw-muted);\n font-size: 12px;\n line-height: 1.45;\n}\n\n.nw-footer {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 10px 14px;\n border-top: 1px solid var(--nw-border);\n color: var(--nw-muted);\n font-size: 12px;\n}\n\n.nsr-footer {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 10px 14px;\n border-top: 1px solid var(--nw-border);\n color: var(--nw-muted);\n font-size: 12px;\n}\n\n.nw-meta {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.nw-feature {\n display: grid;\n grid-template-columns: 24px minmax(0, 1fr);\n gap: 10px;\n padding: 10px;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-bg);\n}\n\n.nw-check {\n display: inline-grid;\n width: 22px;\n height: 22px;\n place-items: center;\n border-radius: 6px;\n background: var(--nw-accent);\n color: white;\n font-size: 13px;\n font-weight: 800;\n}\n\n.nw-check svg {\n width: 14px;\n height: 14px;\n stroke: currentColor;\n stroke-width: 3;\n fill: none;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n@media (max-width: 560px) {\n .nw-shell {\n padding: 10px;\n }\n\n .nw-grid,\n .nw-field-grid,\n .nw-modifier-grid {\n grid-template-columns: 1fr;\n }\n\n .nw-tabs {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n }\n\n .nsr-tabs {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n }\n\n .nw-header {\n align-items: flex-start;\n }\n\n .nsr-header {\n align-items: flex-start;\n }\n\n .nw-chip {\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .nsr-chip {\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .nw-button {\n flex: 1 1 140px;\n }\n}\n" },
|
|
70
70
|
{ relPath: "examples/food-ordering/test/ordering-flow.test.ts", content: "// @vitest-environment happy-dom\n/// <reference lib=\"dom\" />\nimport { act, createElement as h } from 'react';\nimport { createRoot, type Root } from 'react-dom/client';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport OrderingFlow from '../src/views/ordering-flow.js';\n\nvi.mock('../src/helpers.js', async () => {\n const { createElement } = await import('react');\n const container = ({ children }: { readonly children?: unknown }) =>\n createElement('div', null, children);\n const button = ({ children }: { readonly children?: unknown }) =>\n createElement('button', { type: 'button' }, children);\n return {\n ActionBar: container,\n AppShell: ({\n children,\n footer,\n subtitle,\n title,\n }: {\n readonly children?: unknown;\n readonly footer?: unknown;\n readonly subtitle?: string;\n readonly title?: string;\n }) => createElement('main', null, title, subtitle, children, footer),\n AsyncBoundary: container,\n ChoiceGroup: container,\n createViewStore:\n <T>(_key: string, initial: T) =>\n (selector?: (state: T) => unknown) => ({\n state: initial,\n selected: selector?.(initial),\n setState: () => undefined,\n }),\n DataCard: button,\n DataList: container,\n Feedback: ({ children, status }: { readonly children?: unknown; readonly status?: string }) =>\n createElement('div', { 'data-status': status }, children),\n Field: container,\n Form: ({ children }: { readonly children?: unknown }) => createElement('form', null, children),\n HandoffButton: button,\n QuantityStepper: container,\n ShellNav: container,\n StatusBadge: container,\n SubmitButton: button,\n useAppFlow: () => ({\n activeView: 'stores',\n navigate: () => undefined,\n back: () => undefined,\n canBack: false,\n params: {},\n }),\n useCallTool: () => ({\n status: 'idle',\n isIdle: true,\n isPending: false,\n isSuccess: false,\n isError: false,\n callTool: () => Promise.resolve({ structuredContent: {} }),\n callToolAsync: () => Promise.resolve({ structuredContent: {} }),\n reset: () => undefined,\n }),\n useHandoff: () => ({ status: 'idle', open: () => Promise.resolve() }),\n useLayout: () => ({\n theme: 'light',\n displayMode: 'inline',\n supports: { modelContext: false, followUpMessage: false },\n }),\n useSendFollowUpMessage: () => () => Promise.resolve(),\n useToolInfo: () => toolResult,\n useUpdateModelContext: () => () => Promise.resolve(),\n useViewState: <T>(_key: string, initial: T) => [initial, () => undefined] as const,\n useWidgetLifecycle: () => () => Promise.resolve(),\n useWidgetReady: () => true,\n View: container,\n ViewStack: container,\n };\n});\n\ntype ToolResult = {\n readonly content?: unknown;\n readonly structuredContent?: unknown;\n readonly _meta?: unknown;\n readonly isError?: boolean;\n};\n\nlet toolResult: ToolResult;\nlet root: Root | undefined;\n\nbeforeEach(() => {\n (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;\n toolResult = {};\n document.body.innerHTML = '<div id=\"root\"></div>';\n (globalThis as { __noodleReactVersion?: number }).__noodleReactVersion = 0;\n (globalThis as { __noodleReactBridge?: unknown }).__noodleReactBridge = {\n getToolResult: () => toolResult,\n getViewState: () => ({}),\n setWidgetState: () => undefined,\n getLayout: () => ({\n theme: 'light',\n displayMode: 'inline',\n supports: { modelContext: false, followUpMessage: false },\n }),\n callServerTool: () => Promise.resolve({ structuredContent: {} }),\n openExternal: () => Promise.resolve(),\n sendFollowUpMessage: () => Promise.resolve(),\n updateModelContext: () => Promise.resolve(),\n };\n});\n\nafterEach(() => {\n if (root) act(() => root?.unmount());\n root = undefined;\n delete (globalThis as { __noodleReactBridge?: unknown }).__noodleReactBridge;\n});\n\nfunction render(result: ToolResult): string {\n toolResult = result;\n root = createRoot(document.querySelector('#root') as HTMLElement);\n act(() => root?.render(h(OrderingFlow)));\n return document.body.textContent ?? '';\n}\n\ndescribe('food-ordering invoking result states', () => {\n it('renders pending without identifier-dependent actions before hydration', () => {\n const text = render({});\n expect(text).toContain('Loading food ordering');\n expect(text).not.toContain('Search stores');\n expect(document.querySelector('button')).toBeNull();\n });\n\n it('renders an explicit tool error without ordering actions', () => {\n const text = render({\n content: [{ type: 'text', text: 'Ordering is unavailable' }],\n isError: true,\n });\n expect(text).toContain('Could not load food ordering');\n expect(text).not.toContain('Search stores');\n expect(document.querySelector('button')).toBeNull();\n });\n\n it('rejects malformed success data before rendering identifier-dependent actions', () => {\n const text = render({\n structuredContent: {\n status: 'Ready',\n customer: 'Asha',\n stores: [{ name: 'Missing identifier' }],\n featuredItems: [],\n },\n });\n expect(text).toContain('Food ordering result was incomplete');\n expect(text).not.toContain('Search stores');\n expect(document.querySelector('button')).toBeNull();\n });\n\n it('preserves the ordering flow after valid hydration', () => {\n const text = render({\n structuredContent: {\n status: 'Ready to build a food order.',\n customer: 'Asha',\n stores: [\n {\n id: 'harbor-noodles',\n name: 'Harbor Noodles',\n cuisine: 'Noodles',\n address: '18 Pier Lane',\n open: true,\n etaMinutes: 24,\n rating: 4.8,\n },\n ],\n featuredItems: [\n {\n id: 'spicy_miso',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Spicy Miso Bowl',\n price: 16,\n description: 'Miso broth and noodles.',\n modifiers: ['extra_noodles'],\n },\n ],\n },\n });\n expect(text).toContain('Search stores');\n expect(text).toContain('Harbor Noodles');\n expect(document.querySelector('form')).not.toBeNull();\n });\n});\n" },
|
|
71
|
-
{ relPath: "examples/food-ordering/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('food-ordering example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('emits a complete ordering app manifest with cart state and app-only helpers', async () => {\n const manifest = (await app.toManifest()) as {\n server: {\n name: string;\n agentGuide?: unknown;\n context?: {\n defaults?: { locale?: string; timeZone?: string };\n ambient?: {\n outputSchema?: unknown;\n fulfilment?: { output?: unknown };\n };\n };\n };\n handoff?: { allowedDomains?: string[] };\n state?: { handles?: Record<string, { kind: string; scope: string }> };\n connectors?: Record<string, { id: string; version: string }>;\n tools: Array<{\n name: string;\n title?: string;\n visibility?: string[];\n annotations?: Record<string, unknown>;\n output?: unknown;\n fulfilment?: { steps?: unknown[]; output?: unknown };\n }>;\n widgets?: Array<{\n name: string;\n tool: string;\n view?: { component?: string; entry?: string };\n }>;\n };\n\n expect(manifest.server.name).toBe('food_ordering');\n expect(manifest.server.agentGuide).toBeDefined();\n expect(manifest.server).not.toHaveProperty('distribution');\n expect(manifest.server.context).toMatchObject({\n defaults: { locale: 'en-US', timeZone: 'America/New_York' },\n ambient: {\n fulfilment: {\n output: {\n serviceArea: 'Harbor District',\n orderingDate: '${context.temporal.localDate}',\n },\n },\n },\n });\n expect(manifest.state?.handles?.cart).toMatchObject({\n kind: 'cart',\n scope: 'caller',\n });\n expect(manifest.handoff?.allowedDomains).toEqual(['https://orders.example.com']);\n expect(manifest.connectors?.state).toEqual({ id: 'noodle_state', version: '1.0.0' });\n\n const tools = new Map(manifest.tools.map((tool) => [tool.name, tool]));\n expect(manifest.widgets?.find((widget) => widget.tool === 'open_ordering')?.view).toMatchObject(\n {\n component: 'ordering-flow',\n entry: './views/ordering-flow.tsx',\n },\n );\n for (const helper of [\n 'search_stores',\n 'load_menu',\n 'load_item',\n 'read_cart',\n 'sync_cart',\n 'prepare_checkout',\n ]) {\n expect(tools.get(helper)?.visibility).toEqual(['app']);\n }\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('featuredItems');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.temporal.localDate}');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.ambient.serviceArea}');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.location.latitude}');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.location.longitude}');\n expect(tools.get('open_ordering')?.annotations).toMatchObject({\n 'x-noodleseed-model-latest-message-includes-any': expect.arrayContaining([\n 'order',\n 'menu',\n 'checkout',\n ]),\n });\n expect(JSON.stringify(tools.get('sync_cart'))).toContain('revision');\n expect(tools.get('sync_cart')?.annotations?.confirm).toBe(false);\n expect(tools.get('prepare_checkout')?.annotations?.confirm).toBe(false);\n expect(tools.get('plan_order')?.fulfilment).toMatchObject({\n steps: [\n {\n id: 'choose_fulfilment',\n elicit: {\n message: 'How should we fulfil this order?',\n requestedSchema: {\n type: 'object',\n properties: {\n method: { type: 'string', enum: ['pickup', 'delivery'] },\n requestedDate: { type: 'string', format: 'date' },\n },\n required: ['method', 'requestedDate'],\n },\n },\n },\n ],\n output: {\n method: '${steps.choose_fulfilment.method}',\n requestedDate: '${steps.choose_fulfilment.requestedDate}',\n },\n });\n expect(manifest.widgets?.map((widget) => widget.name)).toContain('capabilities_card');\n expect(manifest.tools.every((tool) => typeof tool.title === 'string')).toBe(true);\n });\n\n it('projects host distribution metadata separately from the runtime manifest', () => {\n const distribution = app.toDistributionMetadata();\n expect(distribution).toMatchObject({\n schemaVersion: 1,\n listing: { summary: 'Build a pickup noodle order.' },\n assets: {\n icon: { alt: 'Food Ordering noodle bowl' },\n screenshots: [\n expect.objectContaining({\n alt: 'Food Ordering MCP App showing nearby stores',\n prompt: 'Help me build a noodle order for pickup.',\n }),\n expect.objectContaining({\n alt: 'Food Ordering MCP App showing the Harbor Noodles menu',\n prompt: 'Show me the Harbor Noodles menu.',\n }),\n expect.objectContaining({\n alt: 'Food Ordering MCP App reviewing a checkout handoff',\n prompt: 'Review my spicy miso bowl order before checkout.',\n }),\n ],\n },\n });\n const scenarios = distribution?.review.scenarios ?? [];\n expect(scenarios.filter(({ shouldInvoke }) => shouldInvoke).map(({ id }) => id)).toEqual([\n 'build_order',\n 'browse_menu',\n 'compare_options',\n 'plan_pickup',\n 'review_checkout',\n ]);\n expect(scenarios.filter(({ shouldInvoke }) => !shouldInvoke).map(({ id }) => id)).toEqual([\n 'unrelated_weather',\n 'unrelated_email',\n 'unrelated_travel',\n ]);\n expect(\n scenarios\n .filter(({ shouldInvoke }) => !shouldInvoke)\n .every((scenario) => !('tools' in scenario)),\n ).toBe(true);\n });\n});\n" },
|
|
71
|
+
{ relPath: "examples/food-ordering/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('food-ordering example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('emits a complete ordering app manifest with cart state and app-only helpers', async () => {\n const manifest = (await app.toManifest()) as {\n server: {\n name: string;\n agentGuide?: unknown;\n context?: {\n defaults?: { locale?: string; timeZone?: string };\n ambient?: {\n outputSchema?: unknown;\n fulfilment?: { output?: unknown };\n };\n };\n };\n handoff?: { allowedDomains?: string[] };\n state?: { handles?: Record<string, { kind: string; scope: string }> };\n connectors?: Record<string, { id: string; version: string }>;\n tools: Array<{\n name: string;\n title?: string;\n visibility?: string[];\n annotations?: Record<string, unknown>;\n output?: unknown;\n fulfilment?: { steps?: unknown[]; output?: unknown };\n }>;\n widgets?: Array<{\n name: string;\n tool: string;\n view?: { component?: string; entry?: string };\n }>;\n };\n\n expect(manifest.server.name).toBe('food_ordering');\n expect(manifest.server.agentGuide).toBeDefined();\n expect(manifest.server).not.toHaveProperty('distribution');\n expect(manifest.server.context).toMatchObject({\n defaults: { locale: 'en-US', timeZone: 'America/New_York' },\n ambient: {\n fulfilment: {\n output: {\n serviceArea: 'Harbor District',\n orderingDate: '${context.temporal.localDate}',\n },\n },\n },\n });\n expect(manifest.state?.handles?.cart).toMatchObject({\n kind: 'cart',\n scope: 'caller',\n });\n expect(manifest.handoff?.allowedDomains).toEqual(['https://orders.example.com']);\n expect(manifest.connectors?.state).toEqual({ id: 'noodle_state', version: '1.0.0' });\n\n const tools = new Map(manifest.tools.map((tool) => [tool.name, tool]));\n expect(manifest.widgets?.find((widget) => widget.tool === 'open_ordering')?.view).toMatchObject(\n {\n component: 'ordering-flow',\n entry: './views/ordering-flow.tsx',\n },\n );\n for (const helper of [\n 'search_stores',\n 'load_menu',\n 'load_item',\n 'read_cart',\n 'sync_cart',\n 'prepare_checkout',\n ]) {\n expect(tools.get(helper)?.visibility).toEqual(['app']);\n }\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('featuredItems');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.temporal.localDate}');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.ambient.serviceArea}');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.location.latitude}');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.location.longitude}');\n expect(tools.get('open_ordering')?.annotations).toMatchObject({\n 'x-noodleseed-model-latest-message-includes-any': expect.arrayContaining([\n 'order',\n 'menu',\n 'checkout',\n ]),\n 'x-noodleseed-model-once-per-session': true,\n });\n expect(JSON.stringify(tools.get('sync_cart'))).toContain('revision');\n expect(tools.get('sync_cart')?.annotations?.confirm).toBe(false);\n expect(tools.get('prepare_checkout')?.annotations?.confirm).toBe(false);\n expect(tools.get('plan_order')?.fulfilment).toMatchObject({\n steps: [\n {\n id: 'choose_fulfilment',\n elicit: {\n message: 'How should we fulfil this order?',\n requestedSchema: {\n type: 'object',\n properties: {\n method: { type: 'string', enum: ['pickup', 'delivery'] },\n requestedDate: { type: 'string', format: 'date' },\n },\n required: ['method', 'requestedDate'],\n },\n },\n },\n ],\n output: {\n method: '${steps.choose_fulfilment.method}',\n requestedDate: '${steps.choose_fulfilment.requestedDate}',\n },\n });\n expect(manifest.widgets?.map((widget) => widget.name)).toContain('capabilities_card');\n expect(manifest.tools.every((tool) => typeof tool.title === 'string')).toBe(true);\n });\n\n it('projects host distribution metadata separately from the runtime manifest', () => {\n const distribution = app.toDistributionMetadata();\n expect(distribution).toMatchObject({\n schemaVersion: 1,\n listing: { summary: 'Build a pickup noodle order.' },\n assets: {\n icon: { alt: 'Food Ordering noodle bowl' },\n screenshots: [\n expect.objectContaining({\n alt: 'Food Ordering MCP App showing nearby stores',\n prompt: 'Help me build a noodle order for pickup.',\n }),\n expect.objectContaining({\n alt: 'Food Ordering MCP App showing the Harbor Noodles menu',\n prompt: 'Show me the Harbor Noodles menu.',\n }),\n expect.objectContaining({\n alt: 'Food Ordering MCP App reviewing a checkout handoff',\n prompt: 'Review my spicy miso bowl order before checkout.',\n }),\n ],\n },\n });\n const scenarios = distribution?.review.scenarios ?? [];\n expect(scenarios.filter(({ shouldInvoke }) => shouldInvoke).map(({ id }) => id)).toEqual([\n 'build_order',\n 'browse_menu',\n 'compare_options',\n 'plan_pickup',\n 'review_checkout',\n ]);\n expect(scenarios.filter(({ shouldInvoke }) => !shouldInvoke).map(({ id }) => id)).toEqual([\n 'unrelated_weather',\n 'unrelated_email',\n 'unrelated_travel',\n ]);\n expect(\n scenarios\n .filter(({ shouldInvoke }) => !shouldInvoke)\n .every((scenario) => !('tools' in scenario)),\n ).toBe(true);\n });\n});\n" },
|
|
72
72
|
{ relPath: "examples/food-ordering/vitest.config.ts", content: "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n resolve: {\n alias: {\n '@noodleseed/one': new URL('../../packages/authoring/src/index.ts', import.meta.url).pathname,\n '@noodle-borg/capabilities': new URL(\n '../../packages/capabilities/src/index.ts',\n import.meta.url,\n ).pathname,\n '@noodle-borg/compiler': new URL('../../packages/compiler/src/index.ts', import.meta.url)\n .pathname,\n '@noodle-borg/compute': new URL('../../packages/compute/src/index.ts', import.meta.url)\n .pathname,\n '@noodle-borg/connector-defs': new URL(\n '../../packages/connector-defs/src/index.ts',\n import.meta.url,\n ).pathname,\n '@noodle-borg/connector-http': new URL(\n '../../packages/connector-http/src/index.ts',\n import.meta.url,\n ).pathname,\n '@noodle-borg/runtime': new URL('../../packages/runtime/src/index.ts', import.meta.url)\n .pathname,\n },\n },\n test: {\n include: ['test/**/*.test.ts'],\n },\n});\n" },
|
|
73
73
|
{ relPath: "examples/gmail-multi-account/README.md", content: "# Gmail multi-account automation\n\n**Owns:** The flagship proof that one reusable connector can be bound to multiple independently\nauthenticated accounts inside one MCP server.\n\nThis fictional example binds `gmailConnector()` twice through separate `externalExchange()` logical\nconnections. Public tools always accept `accounts: [...]`; reads accept either account or the canonical\npersonal-then-work pair, while mutations accept exactly one account and require runtime confirmation.\n\nCapability slot: **reusable connector + independently authenticated multi-account bindings**. It is distinct\nfrom `customer-auth`, which owns authentication of the MCP caller rather than downstream connector accounts.\n\nThe labels `personal@example.com` and `work@example.com` are static display labels, not provider identities.\nThe deployment-owned credential provider maps each logical connection to its real Google authorization.\nNo Google client, provider account id, token, or real email address belongs in this project.\n\n## Safety and API boundary\n\n- Search, message/thread reads, draft reads, and vacation-setting reads may target one or both accounts.\n- Draft creation/update/send, label changes, archive, raw send, trash, and vacation updates target one account.\n- Every mutation is prepared against the exact selected binding and must be confirmed before dispatch.\n- `send_message.raw` and draft `raw` are RFC 2822 MIME bytes encoded with base64url. This example does not\n pretend that `to`/`subject`/`body` strings are sufficient to encode Unicode MIME correctly.\n- Vacation `startTime`/`endTime` schemas enforce only digit-shaped 1–19 character epoch-millisecond strings.\n When both are supplied, Gmail's backend remains authoritative for the required `startTime < endTime`\n relationship; this example does not claim cross-field JSON Schema validation.\n- Trash is reversible. Permanent message/thread/draft deletion, delegation, forwarding/sharing settings,\n and unrestricted raw HTTP requests are intentionally absent.\n\n## Local checks\n\n```sh\nnoodle validate\nnoodle test\n```\n\nThe committed tests compile hermetic fake connector responses. They never contact Gmail or load OAuth\ncredentials. A real deployment additionally needs an operator-provided external credential exchange\nendpoint for each logical connection.\n\n## Personal automation skill\n\nThe source skill is [`skills/personal-email-automation/SKILL.md`](skills/personal-email-automation/SKILL.md).\nValidate it with the standard skill validator before distribution. The source skill is shipped as part of\nthis example; canonical export of an app and its skill as an installable Codex plugin remains roadmap work\nand is not currently provided by Noodle Seed.\n" },
|
|
74
74
|
{ relPath: "examples/gmail-multi-account/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"gmail-multi-account\"\n}\n" },
|
|
@@ -280,7 +280,7 @@ export function renderWidgetsAndAppsReference() {
|
|
|
280
280
|
'',
|
|
281
281
|
'## Tools and views',
|
|
282
282
|
'',
|
|
283
|
-
'Use `tool(name, { description, input, output, fulfil, view })` for a model-visible tool that renders a widget, and the same `tool(name, { ..., visibility: ["app"] })` for an app-only helper hidden from the model. When a view is valid only for narrow explicit intent, add `modelVisibility: { latestMessageIncludesAny: ["open the form", ...] }`; Noodle normalizes and matches those literal phrases against the latest user message before model discovery and fails closed on malformed data.
|
|
283
|
+
'Use `tool(name, { description, input, output, fulfil, view })` for a model-visible tool that renders a widget, and the same `tool(name, { ..., visibility: ["app"] })` for an app-only helper hidden from the model. When a view is valid only for narrow explicit intent, add `modelVisibility: { latestMessageIncludesAny: ["open the form", ...] }`; Noodle normalizes and matches those literal phrases against the latest user message before model discovery and fails closed on malformed data. Add `oncePerSession: true` when a successful model-selected view must not repeat in that conversation, and `requiredWhenVisible: true` only when a matching turn must render that sole required tool before normal model discovery resumes. These options control presentation and relevance, never idempotency or authorization. A `view` is `{ component: "name", entry: "./views/name.tsx" }` — a React component the compiler bundles at validate/deploy time.',
|
|
284
284
|
'',
|
|
285
285
|
'## Noodle Design default',
|
|
286
286
|
'',
|
|
@@ -129,7 +129,7 @@ const SDK_RECIPES = [
|
|
|
129
129
|
'',
|
|
130
130
|
'### Non-trivial tool: ctx connectors, annotations, visibility, async',
|
|
131
131
|
'',
|
|
132
|
-
"`ctx` is `{ input, user, connectors }`. Bind connectors with `use` on the server, then call one inside `fulfil` to record a step. `annotations.readOnly()` declares a closed-world safe read. TypeScript action helpers enforce confirmation only with `{ confirm: true }`; omitted or `false` executes directly, and action/destructive/open-world hints alone never enable the gate. For stateless hosts that cannot present Noodle confirmation, set `interactions: { confirmationFallback: 'host' }` in the `server` options to explicitly trust native host write approval; omission remains fail-closed and the fallback never supplies missing `ctx.elicit` input. `visibility` defaults to `['model', 'app']` — set `['app']` to hide a helper from the model. For a narrow explicit-intent tool, `modelVisibility: { latestMessageIncludesAny: [...] }` deterministically limits model discovery to a latest user message containing one
|
|
132
|
+
"`ctx` is `{ input, user, connectors }`. Bind connectors with `use` on the server, then call one inside `fulfil` to record a step. `annotations.readOnly()` declares a closed-world safe read. TypeScript action helpers enforce confirmation only with `{ confirm: true }`; omitted or `false` executes directly, and action/destructive/open-world hints alone never enable the gate. For stateless hosts that cannot present Noodle confirmation, set `interactions: { confirmationFallback: 'host' }` in the `server` options to explicitly trust native host write approval; omission remains fail-closed and the fallback never supplies missing `ctx.elicit` input. `visibility` defaults to `['model', 'app']` — set `['app']` to hide a helper from the model. For a narrow explicit-intent tool, `modelVisibility: { latestMessageIncludesAny: [...] }` deterministically limits model discovery to a latest user message containing one normalized literal phrase. Add `oncePerSession: true` to prevent another successful model-selected use in that conversation, and `requiredWhenVisible: true` only when the matching tool must be called before normal discovery resumes. These are presentation controls, not authorization or idempotency. `fulfil` may be `async` (the compiler awaits it while recording).",
|
|
133
133
|
'',
|
|
134
134
|
'```ts',
|
|
135
135
|
"import { annotations, connector, server, tool, z } from '@noodleseed/one';",
|
|
@@ -244,7 +244,7 @@ const SDK_AUTHORING_SIGNATURES = [
|
|
|
244
244
|
'## Authoring signatures',
|
|
245
245
|
'',
|
|
246
246
|
'- `server(name, options, definitions)` — `options` commonly includes `title`, `version`, `instructions`, `agentGuide`, `distribution`, `branding`, `auth`, `use`, `provides`, `state`, and `handoff`; `definitions` is the array of tools/resources/prompts.',
|
|
247
|
-
'- `tool(name, { description, input, output, annotations?, visibility?, modelVisibility?, view?, fulfil })` — `input`/`output` are Zod schemas; `fulfil({ input, connectors, user })` returns data matching `output`. Add `view: { component, entry }` for a React widget; use `visibility: ["app"]` for an app-only helper. Use `modelVisibility.latestMessageIncludesAny` only for normalized literal explicit-intent discovery;
|
|
247
|
+
'- `tool(name, { description, input, output, annotations?, visibility?, modelVisibility?, view?, fulfil })` — `input`/`output` are Zod schemas; `fulfil({ input, connectors, user })` returns data matching `output`. Add `view: { component, entry }` for a React widget; use `visibility: ["app"]` for an app-only helper. Use `modelVisibility.latestMessageIncludesAny` only for normalized literal explicit-intent discovery; `oncePerSession` and `requiredWhenVisible` add deterministic presentation controls, never authorization or idempotency.',
|
|
248
248
|
'- Keep tool input names application-owned and meaningful; `__noodleIntent` is reserved for an optional serve-time operator analytics adapter and never reaches `fulfil`.',
|
|
249
249
|
'- `resource(name, { uri, description?, mimeType?, fulfil })` and `prompt(name, { description?, arguments?, fulfil })` expose MCP resources/prompts.',
|
|
250
250
|
'- View metadata (`viewTitle`, `viewDescription`, `csp`, `domain`, `permissions`) belongs on the tool that renders it; `asset("./path")` packages local files.',
|
|
@@ -28,7 +28,12 @@ export declare function selectAssistantModelTools(artifact: RuntimeArtifact, cal
|
|
|
28
28
|
}) | undefined, options?: {
|
|
29
29
|
readonly anonymousSignInOfferSurface?: readonly SurfaceCapabilityRef[];
|
|
30
30
|
readonly latestMessage?: string;
|
|
31
|
+
readonly usedToolNames?: readonly string[];
|
|
31
32
|
}): readonly ArtifactTool[];
|
|
33
|
+
/** Whether an already-selected tool requires the first model step to call it. */
|
|
34
|
+
export declare function assistantModelToolRequiredWhenVisible(tool: ArtifactTool): boolean;
|
|
35
|
+
/** Whether a successful model-selected call consumes this tool for the assistant session. */
|
|
36
|
+
export declare function assistantModelToolOncePerSession(tool: ArtifactTool): boolean;
|
|
32
37
|
/**
|
|
33
38
|
* Build bounded product guidance from structured App Package data, never from rendered host files.
|
|
34
39
|
* Workflows are atomic: if one step is unavailable to this model, the entire workflow is omitted.
|
|
@@ -4,6 +4,8 @@ import { projectArtifactForSurface } from './artifact-projection.js';
|
|
|
4
4
|
/** Model-context budget for the embedded assistant's compact product workflow projection. */
|
|
5
5
|
export const ASSISTANT_GUIDE_MAX_BYTES = 16 * 1024;
|
|
6
6
|
const LATEST_MESSAGE_INCLUDES_ANY = 'x-noodleseed-model-latest-message-includes-any';
|
|
7
|
+
const ONCE_PER_SESSION = 'x-noodleseed-model-once-per-session';
|
|
8
|
+
const REQUIRED_WHEN_VISIBLE = 'x-noodleseed-model-required-when-visible';
|
|
7
9
|
const MAX_VISIBILITY_PHRASES = 32;
|
|
8
10
|
const MAX_VISIBILITY_PHRASE_CHARS = 128;
|
|
9
11
|
/**
|
|
@@ -20,8 +22,26 @@ export function selectAssistantModelTools(artifact, caller, options = {}) {
|
|
|
20
22
|
: undefined;
|
|
21
23
|
const authorized = signInOfferArtifact?.tools ?? filterAuthorizedTools(artifact.tools, caller);
|
|
22
24
|
return authorized.filter((tool) => tool._meta?.ui?.visibility?.includes('model') !== false &&
|
|
25
|
+
hasValidSessionVisibility(tool, options.usedToolNames) &&
|
|
23
26
|
matchesLatestMessageConstraint(tool, options.latestMessage));
|
|
24
27
|
}
|
|
28
|
+
/** Whether an already-selected tool requires the first model step to call it. */
|
|
29
|
+
export function assistantModelToolRequiredWhenVisible(tool) {
|
|
30
|
+
return tool.annotations?.[REQUIRED_WHEN_VISIBLE] === true;
|
|
31
|
+
}
|
|
32
|
+
/** Whether a successful model-selected call consumes this tool for the assistant session. */
|
|
33
|
+
export function assistantModelToolOncePerSession(tool) {
|
|
34
|
+
return tool.annotations?.[ONCE_PER_SESSION] === true;
|
|
35
|
+
}
|
|
36
|
+
function hasValidSessionVisibility(tool, usedToolNames) {
|
|
37
|
+
const once = tool.annotations?.[ONCE_PER_SESSION];
|
|
38
|
+
const required = tool.annotations?.[REQUIRED_WHEN_VISIBLE];
|
|
39
|
+
if (Object.hasOwn(tool.annotations ?? {}, ONCE_PER_SESSION) && once !== true)
|
|
40
|
+
return false;
|
|
41
|
+
if (Object.hasOwn(tool.annotations ?? {}, REQUIRED_WHEN_VISIBLE) && required !== true)
|
|
42
|
+
return false;
|
|
43
|
+
return once !== true || !usedToolNames?.includes(tool.name);
|
|
44
|
+
}
|
|
25
45
|
function matchesLatestMessageConstraint(tool, latestMessage) {
|
|
26
46
|
if (!Object.hasOwn(tool.annotations ?? {}, LATEST_MESSAGE_INCLUDES_ANY)) {
|
|
27
47
|
return true;
|
|
@@ -3,6 +3,7 @@ import { requiresToolConfirmation } from '@noodle-borg/compiler';
|
|
|
3
3
|
import { evaluateToolAuthorization } from '@noodle-borg/protocol';
|
|
4
4
|
import { executeToolInteractive, prepareToolForConfirmation } from '@noodle-borg/runtime';
|
|
5
5
|
import { withAssistantSessionExecutionAuthority } from './assistant-customer-routing.js';
|
|
6
|
+
import { assistantModelToolOncePerSession } from './assistant-guide.js';
|
|
6
7
|
import { AssistantInteractionCapacityError, } from './assistant-interaction-state.js';
|
|
7
8
|
import { assistantConfirmationProposal, assistantConfirmationReview, assistantPreparedArgumentReview, assistantSafeOutput, } from './assistant-presentation.js';
|
|
8
9
|
/**
|
|
@@ -20,6 +21,28 @@ export async function dispatchAssistantTool(input) {
|
|
|
20
21
|
data: { code: 'tool_forbidden', retryable: false },
|
|
21
22
|
};
|
|
22
23
|
}
|
|
24
|
+
const oncePerSession = assistantModelToolOncePerSession(input.tool);
|
|
25
|
+
if (oncePerSession && !(await input.store.claimModelToolUse(input.session.id, input.tool.name))) {
|
|
26
|
+
return {
|
|
27
|
+
kind: 'event',
|
|
28
|
+
event: 'error',
|
|
29
|
+
data: { code: 'invalid_model_tool_call', retryable: false },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const dispatch = await dispatchClaimedAssistantTool(input);
|
|
34
|
+
if (oncePerSession && dispatch.kind === 'event' && dispatch.event === 'error') {
|
|
35
|
+
await input.store.releaseModelToolUse(input.session.id, input.tool.name);
|
|
36
|
+
}
|
|
37
|
+
return dispatch;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (oncePerSession)
|
|
41
|
+
await input.store.releaseModelToolUse(input.session.id, input.tool.name);
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function dispatchClaimedAssistantTool(input) {
|
|
23
46
|
const executeDeps = {
|
|
24
47
|
...withAssistantSessionExecutionAuthority(input.executeDeps, input.artifact, input.session),
|
|
25
48
|
caller: input.caller,
|
|
@@ -60,6 +60,8 @@ export interface AssistantSessionRecord {
|
|
|
60
60
|
readonly expiresAt: string;
|
|
61
61
|
readonly absoluteExpiresAt: string;
|
|
62
62
|
readonly history: AssistantHistoryMessage[];
|
|
63
|
+
/** Successful model-selected tools carrying the typed once-per-session visibility contract. */
|
|
64
|
+
readonly modelToolUses: readonly string[];
|
|
63
65
|
/**
|
|
64
66
|
* Model turns already spent. Durable and separate from `history`, which keeps a bounded recent
|
|
65
67
|
* prompt window and is never the admission bound.
|
|
@@ -109,7 +111,7 @@ export interface AssistantStore {
|
|
|
109
111
|
} | undefined>;
|
|
110
112
|
revokeClient(id: string, now: Date): Promise<boolean>;
|
|
111
113
|
authenticateClient(id: string, secret: string): Promise<AssistantClientRecord | undefined>;
|
|
112
|
-
createSession(input: Omit<AssistantSessionRecord, 'id' | 'tokenHash' | 'history' | 'turnCount'>): Promise<{
|
|
114
|
+
createSession(input: Omit<AssistantSessionRecord, 'id' | 'tokenHash' | 'history' | 'modelToolUses' | 'turnCount'>): Promise<{
|
|
113
115
|
readonly session: AssistantSessionRecord;
|
|
114
116
|
readonly token: string;
|
|
115
117
|
}>;
|
|
@@ -119,6 +121,10 @@ export interface AssistantStore {
|
|
|
119
121
|
* race a read-then-write loses. A refused turn does not advance the count.
|
|
120
122
|
*/
|
|
121
123
|
consumeTurn(id: string, limit: number): Promise<AssistantTurnConsumption>;
|
|
124
|
+
/** Atomically reserve one once-per-session model tool use. */
|
|
125
|
+
claimModelToolUse(id: string, tool: string): Promise<boolean>;
|
|
126
|
+
/** Release a reservation only when execution failed before a usable result or interaction existed. */
|
|
127
|
+
releaseModelToolUse(id: string, tool: string): Promise<boolean>;
|
|
122
128
|
/**
|
|
123
129
|
* Bind an already-open conversation to a signed-in caller (ADR 0201, 5.6b).
|
|
124
130
|
*
|
|
@@ -193,7 +199,7 @@ export declare class InMemoryAssistantStore implements AssistantStore {
|
|
|
193
199
|
} | undefined>;
|
|
194
200
|
revokeClient(id: string, now: Date): Promise<boolean>;
|
|
195
201
|
authenticateClient(id: string, secret: string): Promise<AssistantClientRecord | undefined>;
|
|
196
|
-
createSession(input: Omit<AssistantSessionRecord, 'id' | 'tokenHash' | 'history' | 'turnCount'>): Promise<{
|
|
202
|
+
createSession(input: Omit<AssistantSessionRecord, 'id' | 'tokenHash' | 'history' | 'modelToolUses' | 'turnCount'>): Promise<{
|
|
197
203
|
readonly session: AssistantSessionRecord;
|
|
198
204
|
readonly token: string;
|
|
199
205
|
}>;
|
|
@@ -208,6 +214,8 @@ export declare class InMemoryAssistantStore implements AssistantStore {
|
|
|
208
214
|
}): Promise<AssistantSessionElevation>;
|
|
209
215
|
consumePendingResume(sessionId: string): Promise<AssistantPendingResume | undefined>;
|
|
210
216
|
consumeTurn(id: string, limit: number): Promise<AssistantTurnConsumption>;
|
|
217
|
+
claimModelToolUse(id: string, tool: string): Promise<boolean>;
|
|
218
|
+
releaseModelToolUse(id: string, tool: string): Promise<boolean>;
|
|
211
219
|
getSession(token: string, now: Date): Promise<AssistantSessionRecord | undefined>;
|
|
212
220
|
appendHistory(id: string, messages: readonly AssistantHistoryMessage[]): Promise<void>;
|
|
213
221
|
createInteraction(input: Extract<AssistantInteractionCreateInput, {
|
|
@@ -88,6 +88,7 @@ export class InMemoryAssistantStore {
|
|
|
88
88
|
id,
|
|
89
89
|
tokenHash: digest(token),
|
|
90
90
|
history: [],
|
|
91
|
+
modelToolUses: [],
|
|
91
92
|
turnCount: 0,
|
|
92
93
|
};
|
|
93
94
|
this.#sessions.set(id, session);
|
|
@@ -142,6 +143,23 @@ export class InMemoryAssistantStore {
|
|
|
142
143
|
this.#sessions.set(id, { ...session, turnCount });
|
|
143
144
|
return { allowed: true, turnCount };
|
|
144
145
|
}
|
|
146
|
+
async claimModelToolUse(id, tool) {
|
|
147
|
+
const session = this.#sessions.get(id);
|
|
148
|
+
if (!session || session.modelToolUses.includes(tool))
|
|
149
|
+
return false;
|
|
150
|
+
this.#sessions.set(id, { ...session, modelToolUses: [...session.modelToolUses, tool] });
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
async releaseModelToolUse(id, tool) {
|
|
154
|
+
const session = this.#sessions.get(id);
|
|
155
|
+
if (!session?.modelToolUses.includes(tool))
|
|
156
|
+
return false;
|
|
157
|
+
this.#sessions.set(id, {
|
|
158
|
+
...session,
|
|
159
|
+
modelToolUses: session.modelToolUses.filter((candidate) => candidate !== tool),
|
|
160
|
+
});
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
145
163
|
async getSession(token, now) {
|
|
146
164
|
this.#pruneInteractions(now);
|
|
147
165
|
const tokenHash = digest(token);
|
|
@@ -12,6 +12,7 @@ export async function requestModelCompletion(input) {
|
|
|
12
12
|
stream: true,
|
|
13
13
|
messages: input.messages,
|
|
14
14
|
tools: input.tools,
|
|
15
|
+
...(input.toolChoice === undefined ? {} : { tool_choice: input.toolChoice }),
|
|
15
16
|
...(completionLimit === undefined ? {} : { max_completion_tokens: completionLimit }),
|
|
16
17
|
});
|
|
17
18
|
const requestBytes = new TextEncoder().encode(body).byteLength;
|
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
const
|
|
1
|
+
const LATEST_MESSAGE_ANNOTATION = 'x-noodleseed-model-latest-message-includes-any';
|
|
2
|
+
const ONCE_PER_SESSION_ANNOTATION = 'x-noodleseed-model-once-per-session';
|
|
3
|
+
const REQUIRED_WHEN_VISIBLE_ANNOTATION = 'x-noodleseed-model-required-when-visible';
|
|
4
|
+
const RESERVED_ANNOTATIONS = [
|
|
5
|
+
LATEST_MESSAGE_ANNOTATION,
|
|
6
|
+
ONCE_PER_SESSION_ANNOTATION,
|
|
7
|
+
REQUIRED_WHEN_VISIBLE_ANNOTATION,
|
|
8
|
+
];
|
|
2
9
|
const MAX_PHRASES = 32;
|
|
3
10
|
const MAX_PHRASE_CHARS = 128;
|
|
4
11
|
export function manifestToolAnnotations(options) {
|
|
5
|
-
|
|
6
|
-
|
|
12
|
+
const reserved = RESERVED_ANNOTATIONS.find((annotation) => Object.hasOwn(options.annotations ?? {}, annotation));
|
|
13
|
+
if (reserved !== undefined) {
|
|
14
|
+
if (reserved === LATEST_MESSAGE_ANNOTATION) {
|
|
15
|
+
throw new Error(`Use modelVisibility.latestMessageIncludesAny instead of the reserved ${reserved} annotation.`);
|
|
16
|
+
}
|
|
17
|
+
throw new Error(`Use modelVisibility instead of the reserved model visibility annotation ${reserved}.`);
|
|
7
18
|
}
|
|
8
19
|
if (options.modelVisibility === undefined) {
|
|
9
20
|
return options.annotations ? { annotations: { ...options.annotations } } : {};
|
|
@@ -20,7 +31,16 @@ export function manifestToolAnnotations(options) {
|
|
|
20
31
|
if (new Set(normalized).size !== normalized.length) {
|
|
21
32
|
throw new Error('modelVisibility.latestMessageIncludesAny phrases must be unique.');
|
|
22
33
|
}
|
|
23
|
-
return {
|
|
34
|
+
return {
|
|
35
|
+
annotations: {
|
|
36
|
+
...options.annotations,
|
|
37
|
+
[LATEST_MESSAGE_ANNOTATION]: trimmed,
|
|
38
|
+
...(options.modelVisibility.oncePerSession ? { [ONCE_PER_SESSION_ANNOTATION]: true } : {}),
|
|
39
|
+
...(options.modelVisibility.requiredWhenVisible
|
|
40
|
+
? { [REQUIRED_WHEN_VISIBLE_ANNOTATION]: true }
|
|
41
|
+
: {}),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
24
44
|
}
|
|
25
45
|
function normalize(value) {
|
|
26
46
|
return value
|
|
@@ -136,6 +136,10 @@ export interface ToolOptions {
|
|
|
136
136
|
readonly modelVisibility?: {
|
|
137
137
|
/** Show this tool to the model only when the latest user message contains one of these literals. */
|
|
138
138
|
readonly latestMessageIncludesAny: readonly string[];
|
|
139
|
+
/** Hide this tool after its first successful model-selected use in one assistant session. */
|
|
140
|
+
readonly oncePerSession?: true;
|
|
141
|
+
/** Require a model call when this is the eligible required tool for the turn's first model step. */
|
|
142
|
+
readonly requiredWhenVisible?: true;
|
|
139
143
|
};
|
|
140
144
|
/**
|
|
141
145
|
* Tool-surface visibility (SEP-1865 `_meta.ui.visibility`). Default `['model', 'app']`. Set `['app']`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ADMISSION_DEFAULTS } from '@noodle-borg/admission-limits/portable';
|
|
2
2
|
import { requestModelCompletion, } from '@noodle-borg/assistant-gateway/model-runtime';
|
|
3
|
-
import { dispatchAssistantTool, projectAssistantGuide, publicSurfaceOf, selectAssistantModelTools, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
|
|
3
|
+
import { assistantModelToolOncePerSession, assistantModelToolRequiredWhenVisible, dispatchAssistantTool, projectAssistantGuide, publicSurfaceOf, selectAssistantModelTools, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
|
|
4
4
|
import { validateJsonSchemaWithDefaults } from '@noodle-borg/compiler';
|
|
5
5
|
import { guardedFetch } from '@noodle-borg/connector-http';
|
|
6
6
|
import { evaluateToolAuthorization } from '@noodle-borg/protocol';
|
|
@@ -50,7 +50,12 @@ export async function runAgentTurn(target, session, message, context, deps, emit
|
|
|
50
50
|
}
|
|
51
51
|
const identityLine = signedInIdentityLine(session.caller, assistant.sessionClaims);
|
|
52
52
|
const knowledge = await resolveAssistantKnowledge(target.served);
|
|
53
|
-
|
|
53
|
+
let modelTools = selectTurnModelTools(target, session.caller, message, session.modelToolUses);
|
|
54
|
+
const requiredTools = modelTools.filter(assistantModelToolRequiredWhenVisible);
|
|
55
|
+
if (requiredTools.length > 1) {
|
|
56
|
+
return emit({ event: 'error', data: { code: 'multiple_required_model_tools' } });
|
|
57
|
+
}
|
|
58
|
+
let requiredTool = requiredTools[0];
|
|
54
59
|
const guideProjection = projectAssistantGuide({
|
|
55
60
|
appPackage: target.served.appPackageSnapshot?.artifact,
|
|
56
61
|
modelTools,
|
|
@@ -145,7 +150,11 @@ export async function runAgentTurn(target, session, message, context, deps, emit
|
|
|
145
150
|
return emit({ event: 'error', data: { code: 'model_token_budget_exhausted' } });
|
|
146
151
|
}
|
|
147
152
|
const requestTokenLimit = Math.min(binding.requestPolicy?.maxCompletionTokens ?? Number.MAX_SAFE_INTEGER, remainingTokens ?? Number.MAX_SAFE_INTEGER);
|
|
148
|
-
const
|
|
153
|
+
const requiredToolForStep = requiredTool;
|
|
154
|
+
const stepModelTools = requiredToolForStep === undefined ? modelTools : [requiredToolForStep];
|
|
155
|
+
const completion = await requestCompletion(binding, messages, target, session.caller, deps.modelFetch, requiredToolForStep === undefined
|
|
156
|
+
? (delta) => emit({ event: 'content', data: { delta } })
|
|
157
|
+
: () => undefined, requiredToolForStep === undefined ? assistantKnowledgeModelTools(knowledge) : [], stepModelTools, requestTokenLimit === Number.MAX_SAFE_INTEGER ? undefined : requestTokenLimit, turnSignal, requiredToolForStep === undefined ? undefined : 'required');
|
|
149
158
|
stats.modelRequests += 1;
|
|
150
159
|
stats.promptTokens += completion.usage?.promptTokens ?? 0;
|
|
151
160
|
stats.completionTokens += completion.usage?.completionTokens ?? 0;
|
|
@@ -157,6 +166,11 @@ export async function runAgentTurn(target, session, message, context, deps, emit
|
|
|
157
166
|
const response = completion.choices[0]?.message;
|
|
158
167
|
if (!response)
|
|
159
168
|
return emit({ event: 'error', data: { code: 'invalid_model_response' } });
|
|
169
|
+
if (requiredToolForStep !== undefined &&
|
|
170
|
+
(response.tool_calls?.length !== 1 ||
|
|
171
|
+
response.tool_calls[0]?.function.name !== requiredToolForStep.name)) {
|
|
172
|
+
return emit({ event: 'error', data: { code: 'required_model_tool_missing' } });
|
|
173
|
+
}
|
|
160
174
|
if (!response.tool_calls?.length)
|
|
161
175
|
return;
|
|
162
176
|
messages.push({
|
|
@@ -187,7 +201,7 @@ export async function runAgentTurn(target, session, message, context, deps, emit
|
|
|
187
201
|
});
|
|
188
202
|
continue;
|
|
189
203
|
}
|
|
190
|
-
const tool =
|
|
204
|
+
const tool = stepModelTools.find((candidate) => candidate.name === call.function.name);
|
|
191
205
|
if (!tool) {
|
|
192
206
|
return emit({ event: 'error', data: { code: 'invalid_model_tool_call' } });
|
|
193
207
|
}
|
|
@@ -251,6 +265,12 @@ export async function runAgentTurn(target, session, message, context, deps, emit
|
|
|
251
265
|
now: () => assistantNow(deps),
|
|
252
266
|
onToolStarted: () => emit({ event: 'tool_started', data: { id: call.id, tool: tool.name } }),
|
|
253
267
|
});
|
|
268
|
+
if (assistantModelToolOncePerSession(tool) &&
|
|
269
|
+
(dispatch.kind !== 'event' || dispatch.event !== 'error')) {
|
|
270
|
+
modelTools = modelTools.filter((candidate) => candidate.name !== tool.name);
|
|
271
|
+
}
|
|
272
|
+
if (requiredToolForStep?.name === tool.name)
|
|
273
|
+
requiredTool = undefined;
|
|
254
274
|
if (dispatch.kind === 'event') {
|
|
255
275
|
stats.interactionCount += 1;
|
|
256
276
|
return emit({ event: dispatch.event, data: dispatch.data });
|
|
@@ -319,7 +339,7 @@ export async function narrateInteractionResolution(target, session, tool, action
|
|
|
319
339
|
}
|
|
320
340
|
return narrated;
|
|
321
341
|
}
|
|
322
|
-
async function requestCompletion(binding, messages, target, caller, injected, onContent = () => undefined, extraTools = [], selectedModelTools, maxCompletionTokens, signal) {
|
|
342
|
+
async function requestCompletion(binding, messages, target, caller, injected, onContent = () => undefined, extraTools = [], selectedModelTools, maxCompletionTokens, signal, toolChoice) {
|
|
323
343
|
return requestModelCompletion({
|
|
324
344
|
binding,
|
|
325
345
|
messages,
|
|
@@ -343,9 +363,10 @@ async function requestCompletion(binding, messages, target, caller, injected, on
|
|
|
343
363
|
maxResponseBytes: MAX_MODEL_RESPONSE,
|
|
344
364
|
...(maxCompletionTokens === undefined ? {} : { maxCompletionTokens }),
|
|
345
365
|
...(signal === undefined ? {} : { signal }),
|
|
366
|
+
...(toolChoice === undefined ? {} : { toolChoice }),
|
|
346
367
|
});
|
|
347
368
|
}
|
|
348
|
-
function selectTurnModelTools(target, caller, latestMessage) {
|
|
369
|
+
function selectTurnModelTools(target, caller, latestMessage, usedToolNames) {
|
|
349
370
|
const assistant = target.served.artifact.server.assistant;
|
|
350
371
|
if (caller.identityKind === 'anonymous' && offersSignIn(assistant)) {
|
|
351
372
|
const surface = publicSurfaceOf(assistant);
|
|
@@ -353,11 +374,13 @@ function selectTurnModelTools(target, caller, latestMessage) {
|
|
|
353
374
|
return selectAssistantModelTools(target.served.artifact, caller, {
|
|
354
375
|
anonymousSignInOfferSurface: surface.capabilities,
|
|
355
376
|
...(latestMessage === undefined ? {} : { latestMessage }),
|
|
377
|
+
...(usedToolNames === undefined ? {} : { usedToolNames }),
|
|
356
378
|
});
|
|
357
379
|
}
|
|
358
380
|
}
|
|
359
381
|
return selectAssistantModelTools(target.served.artifact, caller, {
|
|
360
382
|
...(latestMessage === undefined ? {} : { latestMessage }),
|
|
383
|
+
...(usedToolNames === undefined ? {} : { usedToolNames }),
|
|
361
384
|
});
|
|
362
385
|
}
|
|
363
386
|
/** Surface behavior is injected only when the session is pinned to the public projection that minted it. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noodleseed/one",
|
|
3
|
-
"version": "0.139.
|
|
3
|
+
"version": "0.139.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Noodle CLI by Noodle Seed — author, run, and deploy declarative MCP servers. Embedding the assistant in your own web app is @noodleseed/assistant.",
|
|
6
6
|
"license": "Apache-2.0",
|