Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions backend/agent/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,87 @@ def parse_tracking_result(text: str) -> dict:
return result


def parse_checkout_result(text: str) -> dict:
raw = text or ""
result = {
"payment_url": "",
"order_number": "",
"total": None,
"currency": "LKR",
"expires_at": "",
"raw": raw,
}

if not raw or raw.startswith("MISSING_INFO:"):
return result

data: Any = raw
if isinstance(raw, str):
stripped = raw.strip()
if stripped.startswith("{"):
try:
data = json.loads(stripped)
except json.JSONDecodeError:
data = raw

if isinstance(data, dict):
order = data.get("order") if isinstance(data.get("order"), dict) else data
total_raw = (
order.get("total")
or order.get("amount")
or order.get("grand_total")
or order.get("total_amount")
)
result["payment_url"] = str(
order.get("payment_url")
or order.get("paymentUrl")
or order.get("pay_url")
or order.get("payUrl")
or order.get("click_to_pay_url")
or order.get("checkout_url")
or order.get("pay_link")
or order.get("url")
or ""
).strip()
result["order_number"] = str(
order.get("order_number")
or order.get("orderNumber")
or order.get("order_id")
or order.get("orderId")
or order.get("id")
or ""
).strip()
result["expires_at"] = str(
order.get("expires_at")
or order.get("expiresAt")
or order.get("expiry")
or order.get("price_lock_expires_at")
or ""
).strip()

if isinstance(total_raw, dict):
amount = total_raw.get("amount") or total_raw.get("value") or total_raw.get("price")
if amount not in (None, ""):
try:
result["total"] = _coerce_int(amount)
except (TypeError, ValueError):
result["total"] = None
result["currency"] = str(total_raw.get("currency") or order.get("currency") or "LKR").strip() or "LKR"
elif total_raw not in (None, ""):
try:
result["total"] = _coerce_int(total_raw)
except (TypeError, ValueError):
result["total"] = None
result["currency"] = str(order.get("currency") or "LKR").strip() or "LKR"
else:
result["payment_url"] = _first_url(raw)
order_match = re.search(r"(?:order(?:\s+number)?|order_id|order id)\D{0,8}([A-Z0-9-]{4,})", raw, re.IGNORECASE)
if order_match:
result["order_number"] = order_match.group(1).strip()

return result


def build_tool_result_event(tool_name: str, result: Any) -> dict[str, Any]:
event: dict[str, Any] = {"result": result}
if tool_name == "search_products" and isinstance(result, str):
Expand All @@ -369,6 +450,11 @@ def build_tool_result_event(tool_name: str, result: Any) -> dict[str, Any]:
if tracking.get("order_number") or tracking.get("status") or tracking.get("events"):
event["tracking"] = tracking
event["raw"] = result
elif tool_name == "checkout" and isinstance(result, str):
order = parse_checkout_result(result)
if order.get("payment_url") or order.get("order_number"):
event["order"] = order
event["raw"] = result
return event


Expand Down
24 changes: 23 additions & 1 deletion backend/tests/test_tool_arg_sanitizer.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import unittest
from unittest.mock import AsyncMock, patch

from agent.tools import _search_products, build_tool_result_event, coerce_tool_args, format_tool_result_for_model, parse_failed_tool_generation, parse_product_markdown, parse_search_products_markdown, parse_tracking_result
from agent.tools import _search_products, build_tool_result_event, coerce_tool_args, format_tool_result_for_model, parse_checkout_result, parse_failed_tool_generation, parse_product_markdown, parse_search_products_markdown, parse_tracking_result


class ToolArgSanitizerTest(unittest.TestCase):
Expand Down Expand Up @@ -140,6 +140,28 @@ def test_parses_tracking_result_and_emits_tracking_event(self):
self.assertIn("tracking", event)
self.assertEqual(event["tracking"]["location"], "Colombo")

def test_parses_checkout_result_and_emits_order_event(self):
checkout_json = """{
"order_number": "ORD-12345",
"payment_url": "https://pay.kapruka.com/checkout/ORD-12345",
"total": {"amount": 12500, "currency": "LKR"},
"expires_at": "2026-06-21T20:00:00+05:30"
}"""

parsed = parse_checkout_result(checkout_json)
self.assertEqual(parsed["order_number"], "ORD-12345")
self.assertEqual(parsed["payment_url"], "https://pay.kapruka.com/checkout/ORD-12345")
self.assertEqual(parsed["total"], 12500)
self.assertEqual(parsed["currency"], "LKR")

event = build_tool_result_event("checkout", checkout_json)
self.assertIn("order", event)
self.assertEqual(event["order"]["expires_at"], "2026-06-21T20:00:00+05:30")

def test_checkout_missing_info_stays_plain_text(self):
event = build_tool_result_event("checkout", "MISSING_INFO: recipient phone, delivery date")
self.assertNotIn("order", event)


if __name__ == "__main__":
unittest.main()
25 changes: 14 additions & 11 deletions docs/feature-checklists/03-pay-link-card.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,23 @@ Upgrade the checkout completion experience so successful order placement produce

## Checklist

- [ ] Review candidate pay-link and order-summary implementations
- [ ] Confirm current `checkout` tool result shape from the MCP
- [ ] Add structured order payload support to backend tool-result events
- [ ] Add a dedicated pay-link or order-summary card component
- [ ] Surface price lock / payment guidance clearly in the UI
- [ ] Preserve the existing cart and checkout drawer flow
- [ ] Verify behavior for success and incomplete-checkout cases
- [ ] Run backend tests relevant to order payload parsing
- [ ] Run `npm run lint`
- [ ] Run `npm run build`
- [ ] Verify the flow in a live browser session
- [x] Review candidate pay-link and order-summary implementations
- [x] Confirm current `checkout` tool result shape from the MCP
- [x] Add structured order payload support to backend tool-result events
- [x] Add a dedicated pay-link or order-summary card component
- [x] Surface price lock / payment guidance clearly in the UI
- [x] Preserve the existing cart and checkout drawer flow
- [x] Verify behavior for success and incomplete-checkout cases
- [x] Run backend tests relevant to order payload parsing
- [x] Run `npm run lint`
- [x] Run `npm run build`
- [x] Verify the flow in a live browser session
- [ ] Commit, push branch, and open draft PR

## Notes

- Candidate reference: `tmp_compare/kavi-kapruka/components/checkout/PayLinkCard.tsx`
- Candidate reference: `tmp_compare/Kapruka-Ai-Shopping-Agent/components/OrderSummary.js`
- Success card parsing is verified with a representative `checkout` tool-result test instead of placing a real Kapruka order.
- Live browser verification used `http://localhost:3000` with a Playwright fallback because the in-app Browser tool was not callable in this thread.
- Incomplete checkout browser check: "Place my order now" stayed text-only and did not render a payment card.
11 changes: 10 additions & 1 deletion frontend/src/components/ChatMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"use client";

import PayLinkCard from "@/components/PayLinkCard";
import ProductCard from "@/components/ProductCard";
import TrackingCard from "@/components/TrackingCard";
import { Message } from "@/hooks/useChat";
import type { ProductSummary, TrackingSummary } from "@/lib/api";
import type { OrderSummary, ProductSummary, TrackingSummary } from "@/lib/api";

function ToolBadge({ tool, args }: { tool: string; args: Record<string, unknown> }) {
const labels: Record<string, string> = {
Expand Down Expand Up @@ -63,6 +64,11 @@ function ToolTracking({ tracking }: { tracking: TrackingSummary | null }) {
return <TrackingCard tracking={tracking} />;
}

function ToolOrder({ order }: { order: OrderSummary | null }) {
if (!order) return null;
return <PayLinkCard order={order} />;
}

function normalizeLine(line: string, hasProducts: boolean): string | null {
const trimmed = line.trim();
if (!trimmed) return null;
Expand Down Expand Up @@ -155,6 +161,8 @@ export default function ChatMessage({
const hasProducts = !isUser && productResults.length > 0;
const trackingResult =
message.toolResults?.map((entry) => entry.tracking).find((entry): entry is TrackingSummary => Boolean(entry)) || null;
const orderResult =
message.toolResults?.map((entry) => entry.order).find((entry): entry is OrderSummary => Boolean(entry)) || null;

return (
<div className={`animate-fade-in flex ${isUser ? "justify-end" : "justify-start"} ${hasProducts ? "w-full" : ""}`}>
Expand Down Expand Up @@ -184,6 +192,7 @@ export default function ChatMessage({
)}

{!hasProducts && trackingResult ? <ToolTracking tracking={trackingResult} /> : null}
{!hasProducts && !trackingResult && orderResult ? <ToolOrder order={orderResult} /> : null}

{!message.content && message.toolCalls && message.toolCalls.length > 0 && !productResults.length ? (
<div className="flex items-center gap-1.5 py-1">
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/components/PayLinkCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client";

import type { OrderSummary } from "@/lib/api";

function formatTotal(total?: number | null, currency = "LKR") {
if (total == null) return null;
return `${currency} ${total.toLocaleString("en-LK")}`;
}

export default function PayLinkCard({ order }: { order: OrderSummary }) {
if (!order.payment_url && !order.order_number) return null;

const total = formatTotal(order.total, order.currency);

return (
<section className="mt-4 overflow-hidden rounded-[1.5rem] border border-accent/20 bg-surface shadow-[0_16px_48px_rgba(37,36,31,0.06)]">
<div className="border-b border-accent/15 bg-accent/8 px-4 py-4">
<p className="text-[11px] uppercase tracking-[0.16em] text-muted">Order ready</p>
<h3 className="mt-1 text-lg font-semibold tracking-tight text-ink">
{order.order_number ? `#${order.order_number}` : "Payment link created"}
</h3>
<p className="mt-1 text-sm text-ink-soft">Prices are typically held for 60 minutes. Complete payment before the link expires.</p>
</div>

<div className="space-y-4 px-4 py-4">
{total ? (
<div className="flex items-center justify-between rounded-2xl border border-border bg-bg px-4 py-3">
<span className="text-sm text-ink-soft">Total</span>
<span className="text-sm font-semibold text-ink">{total}</span>
</div>
) : null}

{order.payment_url ? (
<a
href={order.payment_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex w-full items-center justify-center rounded-2xl bg-accent px-4 py-3 text-sm font-semibold text-white transition hover:bg-accent-hover"
>
Complete Payment
</a>
) : (
<div className="rounded-2xl border border-dashed border-border px-4 py-3 text-sm text-ink-soft">
Payment link not included in the response. Please check the latest assistant message for next steps.
</div>
)}
</div>
</section>
);
}
7 changes: 4 additions & 3 deletions frontend/src/hooks/useChat.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"use client";

import { useState, useCallback, useRef } from "react";
import { streamChat, type ProductSummary, type TrackingSummary } from "@/lib/api";
import { streamChat, type OrderSummary, type ProductSummary, type TrackingSummary } from "@/lib/api";

export interface Message {
id: string;
role: "user" | "assistant" | "system";
content: string;
toolCalls?: { tool: string; args: Record<string, unknown> }[];
toolResults?: { tool: string; result?: string; raw?: string; product?: ProductSummary; products?: ProductSummary[]; tracking?: TrackingSummary }[];
toolResults?: { tool: string; result?: string; raw?: string; product?: ProductSummary; products?: ProductSummary[]; tracking?: TrackingSummary; order?: OrderSummary }[];
timestamp: number;
}

Expand All @@ -34,7 +34,7 @@ export function useChat(sessionId: string) {

let assistantContent = "";
const toolCalls: { tool: string; args: Record<string, unknown> }[] = [];
const toolResults: { tool: string; result?: string; raw?: string; product?: ProductSummary; products?: ProductSummary[]; tracking?: TrackingSummary }[] = [];
const toolResults: { tool: string; result?: string; raw?: string; product?: ProductSummary; products?: ProductSummary[]; tracking?: TrackingSummary; order?: OrderSummary }[] = [];
const assistantId = `assistant-${++idCounter.current}`;

try {
Expand Down Expand Up @@ -70,6 +70,7 @@ export function useChat(sessionId: string) {
product: event.product,
products: event.products,
tracking: event.tracking,
order: event.order,
});
setMessages((prev) => {
const existing = prev.find((m) => m.id === assistantId);
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ export interface TrackingSummary {
raw?: string;
}

export interface OrderSummary {
payment_url: string;
order_number: string;
total?: number | null;
currency: string;
expires_at: string;
raw?: string;
}

export interface CheckoutInfoPayload {
recipient: {
name: string;
Expand Down Expand Up @@ -97,6 +106,7 @@ export interface ChatEvent {
product?: ProductSummary;
products?: ProductSummary[];
tracking?: TrackingSummary;
order?: OrderSummary;
}

export async function* streamChat(
Expand Down