Skip to content

feat(ai-chat): C++ AIChatClient + wire-behavioral gate - #52

Merged
mjerris merged 7 commits into
mainfrom
ai-chat-client
Jul 24, 2026
Merged

feat(ai-chat): C++ AIChatClient + wire-behavioral gate#52
mjerris merged 7 commits into
mainfrom
ai-chat-client

Conversation

@mjerris

@mjerris mjerris commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

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 the
TypeScript 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) with
    AuthenticationError (-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 200 before the turn's outcome is known).
    summarize surfaces its one_of {error} branch as a thrown SummaryError
    never a silent empty string.
  • 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 an overall transfer cap a heartbeat couldn't reset — mirroring python's
    sock_read=60, with a bounded connect (python connect=10). Documented in the
    header.
  • Exported via the umbrella header (signalwire.hpp).
  • 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) including
    summarize {error}SummaryError (never empty string).
  • scripts/run-ci.sh — AI-CHAT gate wired (run_gate + BUILD_MODE routing,
    skip-passes until diff_port_ai_chat.py is on porting-sdk main); ai_chat_dump
    added to the TEST build target lists.

Verification (all RUN on this branch)

  • Dump alone: JSON-only stdout, exit 0, against a live mock.
  • Gate GREEN:
    ✓ AI-CHAT — cpp: client speaks the AI Chat protocol per the vendored spec.
  • RED-proof: swallowing the summarize {error} branch reds the gate on
    summarize_failed (one_of 'error' branch was swallowed …); reverted clean
    (source byte-identical).
  • Full test suite: 1977/1977 pass (19 AI-Chat). FMT + LINT clean.

Coordinated pass — the gate/mock live on porting-sdk@ai-chat-client; CI pins it
via 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

mjerris and others added 7 commits July 23, 2026 17:13
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
mjerris merged commit 820d500 into main Jul 24, 2026
3 of 6 checks passed
@mjerris
mjerris deleted the ai-chat-client branch July 24, 2026 13:53
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant