Skip to content

Commit 4833fbe

Browse files
committed
host callable test coverage opt args
1 parent d028fbd commit 4833fbe

3 files changed

Lines changed: 167 additions & 0 deletions

File tree

baml_language/sdk_tests/crates/python_pydantic2/function_calls/customizable/test_host_callables.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@
1919

2020
import baml_sdk # noqa: F401 — initializes the BAML runtime
2121
from baml_sdk.baml import BamlError
22+
from baml_sdk.baml.errors import AccessError
2223
from baml_sdk.host_callable_tests import (
2324
Person,
2425
ValidationError,
2526
call_int_callback,
27+
call_optional_callback_omitted,
28+
call_optional_callback_supplied,
29+
call_optional_int_callback_supplied,
2630
call_repeatedly,
2731
call_with_callback,
2832
call_with_class_callback,
@@ -339,3 +343,61 @@ def cb(_x: int) -> str:
339343

340344
result = call_with_throwing(callback=cb, x=1)
341345
assert result == "caught:RuntimeError"
346+
347+
348+
# ---------------------------------------------------------------------------
349+
# Optional args × host callables (the combination).
350+
#
351+
# Optional args (test_optional_args.py) and host callables (the cases above)
352+
# were each covered in isolation but never together: a host callable whose
353+
# *own* type carries an optional parameter (`(x: int, y?: int) -> ...`).
354+
# Defaults aren't allowed inside a callable type — only the `?` optional
355+
# marker — so the host's own language-level default is the only possible
356+
# source of a value when BAML omits the arg.
357+
# ---------------------------------------------------------------------------
358+
359+
360+
def test_optional_arg_callable_supplied_passes_value_positionally():
361+
"""When BAML supplies the callable's optional arg (`callback(x, y = 9)`),
362+
the engine resolves the named arg into the positional arg list, so the
363+
Python callback receives both values positionally — overriding its own
364+
default. Exercises the optional-args surface across the host boundary."""
365+
seen: list[tuple[int, int]] = []
366+
367+
def cb(x: int, y: int = -1) -> str:
368+
seen.append((x, y))
369+
return f"{x}:{y}"
370+
371+
result = call_optional_callback_supplied(callback=cb, x=5)
372+
assert result == "5:9"
373+
assert seen == [(5, 9)]
374+
375+
376+
def test_optional_arg_callable_supplied_int_return():
377+
"""Same supplied-optional path with a non-string (`int`) return value —
378+
covers the result-encode branch for an optional-arg host callable."""
379+
380+
def cb(x: int, y: int = 7) -> int:
381+
return x * 10 + y
382+
383+
assert call_optional_int_callback_supplied(callback=cb, x=5) == 150
384+
385+
386+
def test_optional_arg_callable_omitted_raises_access_error():
387+
"""Omitting the callable's optional arg at the BAML call site
388+
(`callback(x)`) currently fails. Unlike a BAML→BAML call — where the
389+
engine resolves an omitted optional natively — the omitted-arg sentinel
390+
cannot be converted to an owned value for the host wire, so the engine
391+
raises `baml.errors.AccessError` *before* the callback ever runs. Pins
392+
the current limitation: a host callable's optional arg cannot be omitted,
393+
so the host's own default never gets a chance to apply."""
394+
invoked: list[tuple[int, int]] = []
395+
396+
def cb(x: int, y: int = 0) -> str:
397+
invoked.append((x, y))
398+
return f"{x}:{y}"
399+
400+
with pytest.raises(BamlError) as exc_info:
401+
call_optional_callback_omitted(callback=cb, x=5)
402+
assert isinstance(exc_info.value.value, AccessError)
403+
assert invoked == [] # the engine fails before dispatching to the host

baml_language/sdk_tests/crates/typescript_node/function_calls/customizable/host_callables.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import "./baml_sdk/index.js";
2+
import { BamlError } from "@boundaryml/baml-core-node";
23
import { describe, expect, it } from "vitest";
4+
import { AccessError } from "./baml_sdk/baml/errors/index.js";
35
import {
46
Person,
57
call_int_callback_async,
8+
call_optional_callback_omitted_async,
9+
call_optional_callback_supplied_async,
10+
call_optional_int_callback_supplied_async,
611
call_repeatedly_async,
712
call_with_callback,
813
call_with_callback_async,
@@ -153,3 +158,67 @@ describe("function_calls — generated SDK sync guard for host callables", () =>
153158
);
154159
});
155160
});
161+
162+
// Optional args × host callables (the combination): a host callable whose
163+
// own type carries an optional parameter (`(x: int, y?: int) -> ...`).
164+
// Optional args (optional_args.test.ts) and host callables (above) were each
165+
// covered alone but never together. Defaults aren't allowed inside a callable
166+
// type — only the `?` optional marker — so the host's own default is the only
167+
// possible source of a value when BAML omits the arg.
168+
describe("function_calls — optional-arg host callables (the combination)", () => {
169+
// NOTE: a named/optional callable type (`(x: int, y?: int) -> T`) codegens
170+
// to the loose `(...args: unknown[]) => T` signature in TS — unlike an
171+
// unnamed positional type (`(int) -> string`), which yields a precise
172+
// `(arg0: number) => string`. The callbacks below take `...args: unknown[]`
173+
// to match that generated contract.
174+
it("passes a supplied optional arg through positionally", async () => {
175+
// BAML resolves `callback(x, y = 9)` into the callable's positional arg
176+
// list, so the host receives both values positionally — overriding its
177+
// own default.
178+
const seen: unknown[][] = [];
179+
const cb = (...args: unknown[]) => {
180+
seen.push(args);
181+
const [x, y] = args;
182+
return `${x}:${y}`;
183+
};
184+
185+
await expect(call_optional_callback_supplied_async(cb, 5)).resolves.toBe(
186+
"5:9",
187+
);
188+
expect(seen).toEqual([[5, 9]]);
189+
});
190+
191+
it("round-trips a supplied optional arg with an int return", async () => {
192+
const cb = (...args: unknown[]) =>
193+
(args[0] as number) * 10 + (args[1] as number);
194+
195+
await expect(
196+
call_optional_int_callback_supplied_async(cb, 5),
197+
).resolves.toBe(150);
198+
});
199+
200+
it("rejects an omitted optional arg with an AccessError before dispatch", async () => {
201+
// Unlike a BAML→BAML call — where the engine resolves an omitted optional
202+
// natively — the omitted-arg sentinel cannot cross the host serialization
203+
// boundary, so the engine raises `baml.errors.AccessError` *before* the
204+
// callback ever runs. Pins the current limitation: a host callable's
205+
// optional arg cannot be omitted at the call site.
206+
const invoked: unknown[][] = [];
207+
const cb = (...args: unknown[]) => {
208+
invoked.push(args);
209+
const [x, y] = args;
210+
return `${x}:${y}`;
211+
};
212+
213+
let caught: unknown;
214+
try {
215+
await call_optional_callback_omitted_async(cb, 5);
216+
expect.unreachable("expected an AccessError to be thrown");
217+
} catch (e) {
218+
caught = e;
219+
}
220+
expect(caught).toBeInstanceOf(BamlError);
221+
expect((caught as BamlError).value).toBeInstanceOf(AccessError);
222+
expect(invoked).toEqual([]); // the engine fails before dispatching
223+
});
224+
});

baml_language/sdk_tests/fixtures/function_calls/baml_src/ns_host_callable_tests/main.baml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,42 @@ function call_with_class_callback(callback: (Person) -> string, p: Person) -> st
3030
callback(p)
3131
}
3232

33+
// ─── Optional args × host callables (the combination) ───────────────────────
34+
//
35+
// These exercise the intersection of two surfaces that were each covered
36+
// in isolation but never together: a host callable whose *own* type
37+
// carries an optional parameter (`y?: int`). Defaults are not permitted
38+
// inside a callable type — only the `?` optional marker is — so the host's
39+
// own language-level default is the only source of a value when BAML omits
40+
// the arg.
41+
42+
// Optional arg SUPPLIED by name across the host boundary. BAML resolves
43+
// `y = 9` into the callable's positional arg list, so the host receives
44+
// both args positionally (`callable.call1` in bridge_python's
45+
// host_value.rs). The happy path for an optional-arg host callable.
46+
function call_optional_callback_supplied(callback: (x: int, y?: int) -> string, x: int) -> string {
47+
callback(x, y = 9)
48+
}
49+
50+
// Same optional-arg callable shape with a non-string (`int`) return —
51+
// covers the result-encode path for an optional-arg host callable.
52+
function call_optional_int_callback_supplied(callback: (x: int, y?: int) -> int, x: int) -> int {
53+
callback(x, y = 100)
54+
}
55+
56+
// Optional arg OMITTED across the host boundary. Unlike a BAML→BAML call —
57+
// where the engine resolves an omitted optional natively (see the
58+
// `optional_dropping_adapter` case in ns_optional_function_parameters) —
59+
// the omitted-arg sentinel (`ValueKind::OmittedArg`) cannot be converted
60+
// to an owned `BexExternalValue` for the wire, so building the host-call
61+
// arg list raises `baml.errors.AccessError("omitted argument")` *before*
62+
// the host callable ever runs. This pins the current limitation: a host
63+
// callable cannot have an optional arg omitted at the call site, so the
64+
// host's own default never gets a chance to apply.
65+
function call_optional_callback_omitted(callback: (x: int, y?: int) -> string, x: int) -> string {
66+
callback(x)
67+
}
68+
3369
// Loop that invokes the callback N times — exercises N round-trips
3470
// through SysOp::BamlHostCallHostValue.
3571
function call_repeatedly(callback: (int) -> string, n: int) -> string[] {

0 commit comments

Comments
 (0)