fix: make checkout-complete callable and keep discount responses schema-valid - #155
Open
Björn Meyer (BrocksiNet) wants to merge 3 commits into
Conversation
The tool sent an empty payload to checkout.complete, but UCP marks payment as
required for that operation: checkout.json annotates it
ucp_request: {create: "optional", update: "optional", complete: "required"}
and the generated checkout.complete.request.json derives `required: ["payment"]`
from it. Every commit therefore failed schema validation with
`$.payment is required`, and the tool declared only `id` and `dryRun`, so an
agent had nowhere to put one. No UCP order could be placed over MCP at all.
Add a payload parameter, matching the other mutating tools, and default payment
to an empty instrument list when the agent does not mention it. Nothing reads
the instrument yet -- CheckoutAdapterInterface::completeCheckout() takes only an
id and a context, so completion charges the sales channel default
(invoice/offline) method -- so requiring the agent to invent one would be a gate
with no payoff. An explicit payment is passed through untouched, so it starts
meaning something as soon as the SDK threads it into the adapter.
The defaulting lives in its own class because the tool depends on the final
ShoppingOperationExecutor and cannot be constructed with a mock, the same reason
UcpCheckoutCompletionPreview was split out.
Applying a discount code adds a Shopware promotion line item with a negative
unit price. The mapper forwarded every line item regardless of type, and the
SDK's LineItem::toArray() emits a per-line `{"type": "subtotal"}` entry, which
types/total.json constrains to `minimum: 0`. The response then failed its own
schema inside ShoppingOperationExecutor::response(), so the agent saw a server
error even though the request was valid and the discount had been applied.
discount.apply is where an agent notices, but any response carrying a promotion
was affected: cart.get, cart.update, checkout.get and checkout.update all run
the same mapping.
Report these lines the way the spec models them instead: exclude them from
line_items and emit a negative `items_discount` total, which is one of the two
types total.json permits a negative amount. Shopware's positionPrice is already
net of the promotion, so the discount is added back out of the subtotal and
reported separately -- subtotal + items_discount returns to positionPrice, and
subtotal stays non-negative as the schema requires. The entry is omitted when
nothing was discounted, since zero would violate `exclusiveMaximum: 0`.
The predicate keys off a negative unit price rather than the promotion type, so
credits and custom negative lines are handled too. The embedded page needs no
change: its totals block already renders any type generically.
UcpResponseSchemaTest runs the real GeneratedSchemaValidator against the real
schemas, so the mapping and the spec cannot drift apart again unnoticed.
Three request shapes are impossible to infer from the tool schemas, and each one fails in a way that reads like the agent's mistake: - cart.update requires the cart id inside the payload as well as on the tool argument. The SDK validates the raw payload instead of merging the resource id first as it does for cart.get and cart.cancel, so omitting it returns `$.id is required` even though the id was supplied. - checkout.create accepts cart_id for cart-to-checkout conversion, plus discounts.codes, fulfillment and buyer_consent. The SDK maps all four, but the generated request schema lists none of them. line_items stays required even when converting, so cart_id alone returns `$.line_items is required`. - line_items is a full replacement on both update tools. An agent told to add a shipping address that sends only fulfillment empties the cart. Record the remaining gaps in the parity plan too, so the entry that claims the MCP matrix is fully routed no longer reads as complete when payment is validated but discarded and discounts.applied[] is not emitted at all.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #154. That PR made tool failures visible in band; these are the first two things the visibility exposed, and both are real defects rather than agent mistakes.
Why is this change necessary?
checkout-completecould never commit. Traced end to end:checkout-updatewith a buyerready_for_completecheckout-completewithdryRun=false$.payment is requiredid,dryRunonly — nowhere to putpaymentThe requirement is correct and comes from the spec, not from a bug.
checkout.jsonannotates the field:so the generated
checkout.complete.request.jsonderivesrequired: ["payment"]faithfully. The defect is on our side:UcpCheckoutCompleteTool::__invoke()declared onlyidanddryRunand sent[]as the payload, where every other mutating tool forwards the agent's. The money tool could not place an order, and no agent input could fix it.discount-applyfailed its own response schema, server-side, after succeeding. Applying a code adds a Shopware promotion line item with a negative unit price.ShopwareDataMapper::mapShopwareLineItems()forwarded every line regardless of type, and the SDK'sLineItem::toArray()emits a per-line{"type": "subtotal"}entry — whichtypes/total.jsonconstrains tominimum: 0:{ "if": { "properties": { "type": { "enum": ["subtotal", "fulfillment", "tax", "fee"] } }, "required": ["type"] }, "then": { "properties": { "amount": { "minimum": 0 } } } }ShoppingOperationExecutor::response()validates before returning, so the agent got a server error on a valid request whose write had already landed.discount-applyis only where it is noticed first:cart.get,cart.update,checkout.getandcheckout.updaterun the same mapping and broke identically whenever a promotion sat in the cart.Three payload shapes were impossible to infer, each failing in a way that reads like the agent's fault.
cart.updaterequires the cart id inside the payload as well as on the tool argument, because the SDK validates the raw payload instead of merging the resource id first as it does forcart.get/cart.cancel— andHttpPayloadMapper::toCartUpdateRequest()then discards the payload copy, so it is pure redundancy.checkout.createacceptscart_idfor cart-to-checkout conversion plusdiscounts.codes,fulfillmentandbuyer_consent, none of which appear in its generated request schema. Andline_itemsis a full replacement on both update tools, so an agent told to add a shipping address that sends onlyfulfillmentempties the cart.What does this change do, exactly?
checkout-completetakes apayloadlike the other mutating tools, and defaultspaymentto an empty instrument list when the agent does not mention it. Nothing reads the instrument yet —CheckoutAdapterInterface::completeCheckout()takes only an id and a context, andCheckoutCompleternever references payment, so completion charges the sales channel default (invoice/offline) method. Making the agent invent an instrument would be a gate with no payoff. An explicitpaymentis passed through untouched, so it starts meaning something the moment the SDK threads it into the adapter.The default is
{"instruments": []}rather than{}deliberately: an empty PHP array passes only becauseGeneratedSchemaValidator::matchesExpectedType()treats[]as an object, and the payload should not lean on that leniency.Defaulting lives in
UcpCheckoutCompletionPaymentfor the same reasonUcpCheckoutCompletionPreviewwas split out in #154 — the tool depends on the finalShoppingOperationExecutorand cannot be constructed with a mock, so inline logic would leave the money path untestable.Promotions are reported the way the spec models them. They are excluded from
line_itemsand emitted as a negativeitems_discounttotal, one of the two typestotal.jsonpermits a negative amount. Shopware'spositionPriceis already net of the promotion, so the discount is added back out of the subtotal and reported separately:subtotal + items_discountreturns topositionPrice, andsubtotalstays non-negative as the schema requires. The entry is omitted entirely when nothing was discounted, since zero would violateexclusiveMaximum: 0.The predicate keys off a negative unit price rather than the promotion type, so credits and custom negative lines are covered too. The embedded page needed no change — its
ucp_embedded_totalsblock already renders any type generically, so the discount moves from an item row to an "Items discount" total row.The three payload shapes are now in the tool descriptions, including that
line_itemsstays required when converting a cart, socart_idalone does not silently fail.Verification
Lane 66. 530 unit tests pass (was 508), 1656 assertions, 8 pre-existing skips.
php-cs-fixerreports 0 of 269 files fixable. PHPStan reports 21 errors, all pre-existing and in files this PR does not touch — zero overlap with the seven changed files.The payment gate, against the real
GeneratedSchemaValidatorand the real schemas:[]— what the tool sent before["$.payment is required"]{"payment":{"instruments":[]}}— the new defaultcom.shopware.invoiceinstrumentThat first row is the reported failure reproduced exactly.
UcpResponseSchemaTestruns the production path — the payload's own fields plus theucpenvelope, exactly asShoppingOperationExecutor::response()assembles it — through the real validator, acrossdiscount.apply,cart.get,cart.update,cart.create,checkout.get,checkout.updateandcheckout.create, with a promotion in the cart. It resolves the schema directory by reflection the same wayUcpSdkExtensiondoes, so it holds whether the SDK is installed from a path repository or fromvendor/.I checked the new tests actually catch the old bug rather than merely passing: reverting only
ShopwareDataMappermakesUcpResponseSchemaTestthrowValidationExceptionondiscount.apply.responseand the mapper tests fail on the promotion still being inline_items.Both unguessable-payload claims were confirmed against the validator rather than assumed —
cart.updatewithout a payloadidreturns["$.id is required"], andcheckout.createwithcart_idbut noline_itemsreturns["$.line_items is required"].Not verified end to end: a live
checkout-complete --dryRun=falseplacing a real order on a lane, which is blocked by the same profile-fetch handshake documented in #154.Known limits and upstream follow-ups
Four gaps have their root cause in the SDK and are recorded in
docs/full-ucp-parity-plan.mdrather than worked around here:completeCheckout()cannot receive the payment the spec requires.CheckoutAdapterInterface::completeCheckout(string $id, RequestContext $context)— the executor validatespayment, then drops it. Fixing this changes a public interface every merchant adapter implements, so it needs a deprecation path rather than a drive-by patch.Checkouthas nopaymentfield, thoughcheckout.jsondefines one as a response property. This is whycheckout-updateacceptspaymentandcheckout-getshows no trace of it.Carthas nodiscountsfield and noextraescape hatch (Checkouthasextra). So the spec's richerdiscounts.applied[]breakdown —discount.json→$defs.applied_discount, with per-targetallocations— cannot be expressed oncart.get,cart.updateordiscount.applyat all. This PR reports the amount; the itemisation waits on that field.cart.update's duplicate id andcheckout.create's missing schema fields are both upstream: merging the resource id before validating incartUpdate()would remove the duplication and match the SDK's ownShoppingOperationToolSchemas::CART_UPDATE_PAYLOAD, which requires onlyline_itemsand currently contradicts its runtime validator.Also worth noting:
payment_handlersis always empty in discovery.CapabilityFilteringProfileContributorgates it onDESCRIPTOR_PAYMENT_TOKENIZATION, whichUcpExtensionAvailabilitystrips unless a handler reportssupportsTokenization() === true, andShopwareInvoicePaymentHandlerreturnsfalse. That is correct today, and this PR does not change it — worth stating so the empty list is not read as a regression from the payment work.