diff --git a/packages/pysequence-bot/src/pysequence_bot/ai/tools.py b/packages/pysequence-bot/src/pysequence_bot/ai/tools.py index de548b7..bed4cdf 100644 --- a/packages/pysequence-bot/src/pysequence_bot/ai/tools.py +++ b/packages/pysequence-bot/src/pysequence_bot/ai/tools.py @@ -81,7 +81,7 @@ { "name": "request_transfer", "description": ( - "Stage a transfer between two pods. This does NOT execute the transfer — " + "Stage a transfer between two pods or ports. This does NOT execute the transfer — " "it validates the request and returns a confirmation payload. " "The user must explicitly confirm before the transfer executes. " "You MUST always include a note. Infer a short, clear description from " @@ -94,11 +94,11 @@ "properties": { "source_name": { "type": "string", - "description": "Name of the source pod (case-insensitive).", + "description": "Name of the source pod or port (case-insensitive).", }, "destination_name": { "type": "string", - "description": "Name of the destination pod (case-insensitive).", + "description": "Name of the destination pod or port (case-insensitive).", }, "amount_dollars": { "type": "number", @@ -287,6 +287,75 @@ def _suggest_pods(name: str, pods: list[dict[str, Any]]) -> str: return " Try using get_all_pods to see available pods." +def _find_port(name: str, ports: list[dict[str, Any]]) -> dict[str, Any] | None: + """Find a port by exact match, then single-substring match.""" + name_lower = name.lower() + for port in ports: + if port["name"].lower() == name_lower: + return port + substring_matches = [p for p in ports if name_lower in p["name"].lower()] + if len(substring_matches) == 1: + return substring_matches[0] + return None + + +def _suggest_ports(name: str, ports: list[dict[str, Any]]) -> str: + """Return a suggestion suffix for unmatched port names.""" + name_lower = name.lower() + substring_matches = [p for p in ports if name_lower in p["name"].lower()] + if substring_matches: + names = ", ".join(p["name"] for p in substring_matches) + return f" Did you mean: {names}?" + return " Try using get_all_accounts to see available ports." + + +def _find_account_by_name( + name: str, pods: list[dict[str, Any]], ports: list[dict[str, Any]] +) -> dict[str, Any] | None: + """Search across pods and ports by name. + + Return dict with id, name, type, balance_cents, balance. + Exact match first, then single-substring. Returns None if ambiguous or not found. + """ + name_lower = name.lower() + + # Exact match across both + for pod in pods: + if pod["name"].lower() == name_lower: + return { + "id": pod["id"], + "name": pod["name"], + "type": "POD", + "balance_cents": pod["balance_cents"], + "balance": pod["balance"], + } + for port in ports: + if port["name"].lower() == name_lower: + return { + "id": port["id"], + "name": port["name"], + "type": "PORT", + "balance_cents": port["balance_cents"], + "balance": port["balance"], + } + + # Substring match across both + all_items = [(p, "POD") for p in pods] + [(p, "PORT") for p in ports] + substring_matches = [ + (item, typ) for item, typ in all_items if name_lower in item["name"].lower() + ] + if len(substring_matches) == 1: + item, typ = substring_matches[0] + return { + "id": item["id"], + "name": item["name"], + "type": typ, + "balance_cents": item["balance_cents"], + "balance": item["balance"], + } + return None + + def execute_tool( tool_name: str, tool_input: dict[str, Any], @@ -341,6 +410,7 @@ def execute_tool( client, agent_config, pending_transfers, + sdk_config=sdk_config, user_id=user_id, staged_this_turn=staged_this_turn, daily_limits=daily_limits, @@ -383,6 +453,7 @@ def _handle_request_transfer( agent_config: AgentConfig, pending_transfers: dict[str, dict], *, + sdk_config: SdkConfig | None = None, user_id: int | None = None, staged_this_turn: list[str] | None = None, daily_limits: DailyLimitTracker | None = None, @@ -433,21 +504,22 @@ def _handle_request_transfer( } ) - # Resolve both pods with a single API call - pods = client.get_pods() + # Resolve source and destination across pods and ports + org_id = sdk_config.org_id if sdk_config else None + all_accounts = client.get_all_accounts(org_id) + pods = all_accounts["pods"] + ports = all_accounts["ports"] - source = _find_pod(source_name, pods) + source = _find_account_by_name(source_name, pods, ports) if source is None: suggestion = _suggest_pods(source_name, pods) - return json.dumps( - {"error": f"Source pod '{source_name}' not found.{suggestion}"} - ) + return json.dumps({"error": f"Source '{source_name}' not found.{suggestion}"}) - destination = _find_pod(destination_name, pods) + destination = _find_account_by_name(destination_name, pods, ports) if destination is None: suggestion = _suggest_pods(destination_name, pods) return json.dumps( - {"error": f"Destination pod '{destination_name}' not found.{suggestion}"} + {"error": f"Destination '{destination_name}' not found.{suggestion}"} ) # Check sufficient balance @@ -466,8 +538,10 @@ def _handle_request_transfer( pending = { "source_id": source["id"], "source_name": source["name"], + "source_type": source["type"], "destination_id": destination["id"], "destination_name": destination["name"], + "destination_type": destination["type"], "amount_cents": amount_cents, "amount_display": f"${amount_dollars:.2f}", "created_at": time.time(), @@ -545,6 +619,8 @@ def _handle_confirm_transfer( source_id=transfer["source_id"], destination_id=transfer["destination_id"], amount_cents=transfer["amount_cents"], + source_type=transfer.get("source_type", "POD"), + destination_type=transfer.get("destination_type", "POD"), description=transfer.get("note", ""), ) except RuntimeError as e: diff --git a/packages/pysequence-bot/tests/test_ai.py b/packages/pysequence-bot/tests/test_ai.py index 4800c5b..be47896 100644 --- a/packages/pysequence-bot/tests/test_ai.py +++ b/packages/pysequence-bot/tests/test_ai.py @@ -13,10 +13,13 @@ from pysequence_bot.ai.memory import MemoryStore from pysequence_bot.ai.tools import ( TOOLS, - execute_tool, + _find_account_by_name, _find_pod, + _find_port, _handle_confirm_transfer, _suggest_pods, + _suggest_ports, + execute_tool, ) from pysequence_bot.config import AgentConfig, SdkConfig from pysequence_bot.telegram.bot import ( @@ -48,6 +51,23 @@ }, ] +FAKE_PORTS = [ + { + "id": "port-1", + "name": "Payroll", + "organization_id": "org-1", + "balance_cents": 300000, + "balance": "$3,000.00", + }, + { + "id": "port-2", + "name": "Side Hustle", + "organization_id": "org-1", + "balance_cents": 50000, + "balance": "$500.00", + }, +] + @pytest.fixture def mock_client() -> MagicMock: @@ -61,6 +81,11 @@ def mock_client() -> MagicMock: "total_balance": "$1,250.00", "pod_count": 2, } + client.get_all_accounts.return_value = { + "pods": FAKE_PODS, + "ports": FAKE_PORTS, + "accounts": [], + } return client @@ -358,6 +383,8 @@ def test_executes(self, mock_client, agent_config, pending, sdk_config): source_id="pod-2", destination_id="pod-1", amount_cents=5000, + source_type="POD", + destination_type="POD", description="", ) # Pending transfer should be cleaned up @@ -394,6 +421,8 @@ def test_executes_with_note(self, mock_client, agent_config, pending, sdk_config source_id="pod-2", destination_id="pod-1", amount_cents=5000, + source_type="POD", + destination_type="POD", description="Monthly grocery restock", ) @@ -1158,9 +1187,13 @@ def test_zero_match_suggests_get_all_pods(self, agent_config, pending): class TestTransferSingleApiCall: - def test_calls_get_pods_once(self, agent_config, pending): + def test_calls_get_all_accounts_once(self, agent_config, pending): client = MagicMock() - client.get_pods.return_value = FAKE_PODS + client.get_all_accounts.return_value = { + "pods": FAKE_PODS, + "ports": [], + "accounts": [], + } execute_tool( "request_transfer", { @@ -1172,12 +1205,16 @@ def test_calls_get_pods_once(self, agent_config, pending): agent_config, pending, ) - client.get_pods.assert_called_once() - client.get_pod_balance.assert_not_called() + client.get_all_accounts.assert_called_once() + client.get_pods.assert_not_called() def test_transfer_fuzzy_resolves(self, agent_config, pending): client = MagicMock() - client.get_pods.return_value = POSSESSIVE_PODS + client.get_all_accounts.return_value = { + "pods": POSSESSIVE_PODS, + "ports": [], + "accounts": [], + } result = json.loads( execute_tool( "request_transfer", @@ -1195,6 +1232,150 @@ def test_transfer_fuzzy_resolves(self, agent_config, pending): assert result["destination"] == "Bob's Savings" +# -- Port transfer tests ---------------------------------------------------- + + +class TestPortTransfers: + def test_port_to_pod_transfer(self, mock_client, agent_config, pending, sdk_config): + result = json.loads( + execute_tool( + "request_transfer", + { + "source_name": "Payroll", + "destination_name": "Groceries", + "amount_dollars": 100.00, + }, + mock_client, + agent_config, + pending, + sdk_config=sdk_config, + ) + ) + assert "pending_transfer_id" in result + assert result["source"] == "Payroll" + assert result["destination"] == "Groceries" + stored = pending[result["pending_transfer_id"]] + assert stored["source_type"] == "PORT" + assert stored["destination_type"] == "POD" + + def test_pod_to_port_transfer(self, mock_client, agent_config, pending, sdk_config): + result = json.loads( + execute_tool( + "request_transfer", + { + "source_name": "Rent", + "destination_name": "Side Hustle", + "amount_dollars": 50.00, + }, + mock_client, + agent_config, + pending, + sdk_config=sdk_config, + ) + ) + assert "pending_transfer_id" in result + assert result["source"] == "Rent" + assert result["destination"] == "Side Hustle" + stored = pending[result["pending_transfer_id"]] + assert stored["source_type"] == "POD" + assert stored["destination_type"] == "PORT" + + def test_unknown_port_name_error( + self, mock_client, agent_config, pending, sdk_config + ): + result = json.loads( + execute_tool( + "request_transfer", + { + "source_name": "Nonexistent Port", + "destination_name": "Groceries", + "amount_dollars": 10.00, + }, + mock_client, + agent_config, + pending, + sdk_config=sdk_config, + ) + ) + assert "error" in result + assert "not found" in result["error"] + + def test_confirm_transfer_passes_correct_types( + self, mock_client, agent_config, pending, sdk_config + ): + pending["txn-port"] = { + "source_id": "port-1", + "source_name": "Payroll", + "source_type": "PORT", + "destination_id": "pod-1", + "destination_name": "Groceries", + "destination_type": "POD", + "amount_cents": 10000, + "amount_display": "$100.00", + "created_at": time.time(), + } + mock_client.transfer.return_value = { + "organization": {"id": "org-1", "pods": []} + } + + result = json.loads( + _handle_confirm_transfer( + {"pending_transfer_id": "txn-port"}, + mock_client, + agent_config, + pending, + sdk_config=sdk_config, + ) + ) + + assert result["success"] is True + mock_client.transfer.assert_called_once_with( + "kyc-1", + source_id="port-1", + destination_id="pod-1", + amount_cents=10000, + source_type="PORT", + destination_type="POD", + description="", + ) + + +class TestFindAccountByName: + def test_exact_pod_match(self): + result = _find_account_by_name("Groceries", FAKE_PODS, FAKE_PORTS) + assert result is not None + assert result["name"] == "Groceries" + assert result["type"] == "POD" + + def test_exact_port_match(self): + result = _find_account_by_name("Payroll", FAKE_PODS, FAKE_PORTS) + assert result is not None + assert result["name"] == "Payroll" + assert result["type"] == "PORT" + + def test_substring_pod_match(self): + result = _find_account_by_name("grocer", FAKE_PODS, FAKE_PORTS) + assert result is not None + assert result["name"] == "Groceries" + assert result["type"] == "POD" + + def test_substring_port_match(self): + result = _find_account_by_name("payro", FAKE_PODS, FAKE_PORTS) + assert result is not None + assert result["name"] == "Payroll" + assert result["type"] == "PORT" + + def test_not_found(self): + result = _find_account_by_name("Nonexistent", FAKE_PODS, FAKE_PORTS) + assert result is None + + def test_case_insensitive(self): + result = _find_account_by_name("payroll", FAKE_PODS, FAKE_PORTS) + assert result is not None + assert result["name"] == "Payroll" + assert result["type"] == "PORT" + + # -- Typing indicator tests -------------------------------------------------