feat(ai-chat): C++ AIChatClient + wire-behavioral gate - #52
Merged
Conversation
Port the SignalWire AI Chat client to C++, mirroring the python reference
(signalwire.ai_chat.AIChatClient) and the TS template, and prove it GREEN
against the shared wire-behavioral gate (porting-sdk mock_ai_chat).
- include/signalwire/ai_chat/ai_chat_client.hpp + src/ai_chat/ai_chat_client.cpp:
the AIChatClient. Six methods (create_conversation/chat/end/del/log/summarize)
returning decoded result types (ConversationInfo/ChatResponse/ChatLog/bool/
std::string). Typed error family: base AIChatError(code+message) with
AuthenticationError(-32009)/ConversationNotFoundError(-32001)/RateLimitError
(-32005,-32006)/ChatInProgressError(-32007)/SummaryError(no code); unmapped
codes fall to the base error. Success/failure is decided by the JSON-RPC BODY,
never the HTTP status (the keepalive heartbeat commits 200 before the outcome
is known). summarize surfaces its one_of {error} branch as a thrown
SummaryError — never a silent empty string.
- Exported via the umbrella header (signalwire.hpp), namespace signalwire::ai_chat.
- streaming_note honored: cpp-httplib's set_read_timeout is a PER-READ idle
timeout (each streamed keepalive-whitespace read resets it), used instead of a
total transfer cap a heartbeat couldn't reset — mirroring python sock_read=60,
with a bounded connect (python connect=10). Documented in the header.
- tools/ai_chat_dump.cpp: the AI-CHAT gate's dump program (built as a CMake
executable next to the other *_dump targets). Reads MOCK_AI_CHAT_URL, drives
the client through the shared ai_chat_corpus, emits ONE JSON object to stdout.
- tests/test_ai_chat_client.cpp: 19 unit tests (repo's framework) — URL
resolution, HTTP Basic auth with project as username + identity-never-in-params,
wire method/param mapping, decoded shapes, JSON-RPC error mapping, and
summarize {error} -> SummaryError (never empty string). Stands up an in-process
httplib mock on a free port.
- scripts/run-ci.sh: AI-CHAT gate wired (run_gate + BUILD_MODE routing, skip-pass
when diff_port_ai_chat.py isn't on porting-sdk main yet); ai_chat_dump added to
the TEST build target lists (host/exec/run).
Verification (all RUN, on this branch):
- dump alone: JSON-only stdout, exit 0, against a live mock.
- gate GREEN: diff_port_ai_chat.py --port cpp -> "client speaks the AI Chat
protocol per the vendored spec."
- RED-proof: swallowing the summarize {error} branch reds the gate on
summarize_failed; reverted clean (source byte-identical).
- full test suite: 1977/1977 pass (19 AI-Chat). FMT + LINT clean.
Coordinated-With: porting-sdk@ai-chat-client
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NqhUoqrbptHNS3cypq9s6t
…acle The AI-Chat client landed without regenerating port_surface.json / port_signatures.json, so SURFACE-DIFF and DRIFT were red against the now-fixed oracle (porting-sdk ai-chat-client @ 62dee31, which enumerates signalwire.ai_chat). Fold the C++ AI-Chat idiom onto the Python oracle shape in both enumerators (emission covers idiom) — aiming for zero allow-list, three residual entries remain (all fleet-consistent fallbacks). Folds (in the enumerators, not the allow-list): - module: the split C++ headers (ai_chat.ai_chat_client / .ai_chat_error) -> the oracle's single signalwire.ai_chat.client module. - AIChatClient ctor -> __init__; del() -> delete (reserved-word rename). - close(): a real, genuine close() method is added to the client (a well-defined no-op — cpp-httplib is stateless, a fresh Client per request — mirroring the python reference's close() and the TS no-op), folded onto the reference `close`. NOT an omission. - url() read-only getter -> dropped (Python exposes .url as an instance attribute the surface oracle does not record as a class member). - AIChatError: code()/has_code()/server_message() getters + protected fields -> dropped; only __init__ survives, matching the oracle. - error subclasses + result structs (ConversationInfo/ChatResponse/ ChatLog/AuthenticationError/...) emitted under the canonical module; their __init__ synthesized to the dataclass auto-ctor shape. - signatures: the options-structs (AIChatClientOptions/ChatOptions/ CreateConversationOptions/SummarizeOptions) unfolded to the oracle's kwargs-exploded params (role/config_url/user_metadata/user_message/ timeout/reinit/summary_prompt). create_conversation/chat/summarize param names+count now line up. Every projected symbol is verified present in the header before it is emitted (abort-loud), so nothing is invented. Residual allow-list (3 entries — max permitted; all fleet-consistent): - PORT_OMISSIONS AIChatClient.__aenter__ / __aexit__: impossible: the python async-context-manager PROTOCOL dunders have no snake_case- nameable C++ member — the enter side is construction, the exit side is the folded close() / RAII. Same disposition as RelayClient.__aenter__ and the TS/PHP/perl/dotnet AI-Chat ports. - PORT_SIGNATURE_OMISSIONS AIChatClient.__init__: the reference's 5th param `session: aiohttp.ClientSession` (an injectable async HTTP session) has no C++ analog — cpp-httplib is stateless, no session object to inject. Params 1-4 (project/token/space/url) match verbatim. NEEDS HUMAN APPROVAL. Gates: SURFACE-DIFF green (folded, not allow-listed), DRIFT green, SIGNATURES + SURFACE-FRESH green (regenerated artifacts committed), AI-CHAT wire gate green, FMT green, ai_chat unit tests green. Coordinated-With: porting-sdk@ai-chat-client Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NqhUoqrbptHNS3cypq9s6t
…allow-list entries Re-drift against porting-sdk ai-chat-client @ f6efa9b, which dropped AIChatClient.__init__'s Python-only `session` DI param and completed `chat` to 8 params (adds timeout/reinit). - DELETE `cpp_stateless_transport_no_session` signature-omission (now VACUOUS): the oracle's __init__ folds to (project,token,space,url) with no session param, matching the port's AIChatClientOptions struct exactly. - Add timeout + reinit to the chat() signature projection. The surface was already REAL — ChatOptions inherits timeout/reinit from ConversationTurnOptions, the impl wires them to conversation_timeout/reinit on the wire, and the corpus dump already drove chat(timeout=30, reinit=true). Only the projection lagged. - FOLD __aenter__/__aexit__ onto the C++ RAII lifecycle (destructor + close), the direct analogue of `async with` / a `using` block — same fold dotnet takes via IDisposable/using. Deletes the two AIChatClient surface omissions. Result: ai_chat allow-list is EMPTY (0 entries). SURFACE suite all rules PASS; AI-CHAT wire gate PASS; 1978/1978 tests PASS. Coordinated-With: porting-sdk@ai-chat-client Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NqhUoqrbptHNS3cypq9s6t
# Conflicts: # port_surface.json # scripts/run-ci.sh
mjerris
added a commit
that referenced
this pull request
Jul 29, 2026
… WAIT-LIVENESS ran nothing
BEHAVIORAL-NIGHTLY was red. WAIT-LIVENESS was the only failing rule; RELAY-LIVENESS and
SECRET-SCRUB-LIVE both passed in the same run.
It is NOT a behavioural regression and NOT a previously-vacuous check now biting. It is a
BUILD-WIRING regression: the `wait_liveness_dump` binary was never built, so the gate executed
a nonexistent program —
wait-liveness dump did not run — exit=127, 0 bytes stdout
— and correctly failed. The port's wait() liveness behaviour was always right; it passes the
moment the binary exists.
ROOT CAUSE: commit 820d500 ("feat(ai-chat): C++ AIChatClient + wire-behavioral gate", #52) added
`ai_chat_dump` to run-ci's `cmake --build --target` list and in the SAME edit dropped
`wait_liveness_dump`, in all three build modes. That silently reverted 3f67c11 ("fix(ci): build
wait_liveness_dump for the WAIT-LIVENESS gate", #50), which had added it for exactly this gate.
Confirmed by `git log -S wait_liveness_dump -- scripts/run-ci.sh`.
It hid locally because a stale build/wait_liveness_dump from Jul 27 was still on disk while every
sibling dump was Jul 28. A clean CI runner has no leftover, hence exit 127.
Restored to all three build modes (local, exec:, run:). No source, no test, no gate rule changed.
Verification:
python3 porting-sdk/scripts/suites/behavioral.py --port cpp --repo . \
--rules WAIT-LIVENESS,RELAY-LIVENESS,SECRET-SCRUB-LIVE -> exit 0
[BEHAVIORAL:WAIT-LIVENESS] ... PASS
[BEHAVIORAL:RELAY-LIVENESS] ... PASS
[BEHAVIORAL:SECRET-SCRUB-LIVE] ... PASS
[BEHAVIORAL] all 3 rules PASS
Pre-fix reproduction was byte-identical to the nightly's failure line.
run-format.sh -> exit 0, no change of substance.
A PRIOR FINDING IS REFUTED BY THIS WORK: task #96 records "SECRET-SCRUB-LIVE passes VACUOUSLY in
go, cpp, ruby". That is FALSE for cpp. Probed with a deliberately missing binary, the rule exits 1
with "✗ cpp: secret-scrub dump did not emit valid JSON" — it is non-vacuous, and its PASS is real
because secret_scrub_dump IS in the build list. The go and ruby halves of #96 should be re-checked
before being acted on.
NOT FIXED, worth its own item: nothing guards this defect class. WIRED-MODES passed but does not
cover the dump-target list, so any PR can silently drop a dump target and only a nightly notices.
A per-PR check that every --dump-cmd path in _behavioral_commands.py appears in run-ci's --target
list would close it, and the same coupling is unguarded in every port that builds dump binaries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
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.
AI Chat client — C++ port
Ports the SignalWire AI Chat client to C++ (
signalwire::ai_chat::AIChatClient),mirroring the python reference (
signalwire.ai_chat.AIChatClient) and theTypeScript template, and proves it GREEN against the shared wire-behavioral gate
(
porting-sdk/scripts/diff_port_ai_chat.py+mock_ai_chat).What's here
include/signalwire/ai_chat/ai_chat_client.hpp+src/ai_chat/ai_chat_client.cpp—the client. Six methods (
create_conversation/chat/end/del/log/
summarize) returning decoded result types(
ConversationInfo/ChatResponse/ChatLog/bool/std::string).Typed error family: base
AIChatError(code+message)withAuthenticationError(-32009),ConversationNotFoundError(-32001),RateLimitError(-32005/-32006),ChatInProgressError(-32007),SummaryError(no code); an unmapped code falls to the base error.Success/failure is decided by the JSON-RPC body, never the HTTP status
(the keepalive heartbeat commits
200before the turn's outcome is known).summarizesurfaces its one_of{error}branch as a thrownSummaryError—never a silent empty string.
set_read_timeoutis a PER-READidle timeout (each streamed keepalive-whitespace read resets it), used instead
of an overall transfer cap a heartbeat couldn't reset — mirroring python's
sock_read=60, with a bounded connect (pythonconnect=10). Documented in theheader.
signalwire.hpp).tools/ai_chat_dump.cpp— the AI-CHAT gate's dump program, built as a CMakeexecutable next to the other
*_dumptargets. ReadsMOCK_AI_CHAT_URL, drivesthe client through the shared
ai_chat_corpus, emits ONE JSON object to stdout.tests/test_ai_chat_client.cpp— 19 unit tests (repo's framework) includingsummarize {error}→SummaryError(never empty string).scripts/run-ci.sh— AI-CHAT gate wired (run_gate+BUILD_MODErouting,skip-passes until
diff_port_ai_chat.pyis on porting-sdk main);ai_chat_dumpadded to the TEST build target lists.
Verification (all RUN on this branch)
✓ AI-CHAT — cpp: client speaks the AI Chat protocol per the vendored spec.summarize {error}branch reds the gate onsummarize_failed(one_of 'error' branch was swallowed …); reverted clean(source byte-identical).
Coordinated pass — the gate/mock live on
porting-sdk@ai-chat-client; CI pins itvia
PORTING_SDK_REF, else the gate skip-passes.Coordinated-With: porting-sdk@ai-chat-client
🤖 Generated with Claude Code
https://claude.ai/code/session_01NqhUoqrbptHNS3cypq9s6t