Skip to content

feat(ai): migrate support responses to Luna and OpenAI Agents SDK - #283

Merged
NathanTarbert merged 117 commits into
mainfrom
jerel/luna-support-responses
Oct 22, 2026
Merged

NathanTarbert merged 117 commits into
mainfrom
jerel/luna-support-responses

Conversation

@jerelvelarde

@jerelvelarde jerelvelarde commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Review status

Marked ready for maintainer review at the user’s request. That is a request for review; it is not a claim that this is ready to merge or to enable automatic posting.

Fifteen broad review rounds preceded this branch. The thirteen commits after the previously reviewed head c602d6c answer a later, separate round of review updates and are not the output of those earlier rounds. Six of them carry the earlier concerns from that round:

  • Expected tool failures — directories, symlinks and submodules, invalid paths, and GitHub transport and API errors — return bounded tool results to the investigator instead of ending the run, so it can correct the lookup or fall back to other evidence. Cancellation and the investigation budget still terminate the run.
  • GitHub evidence reads authenticate with the existing App installation at read-only contents permission, bounded by the investigation deadline and a ten-second installation request deadline. One investigation can stop waiting without canceling another caller’s shared token request, and incomplete configuration cannot silently fall back to anonymous access.
  • An interrupted delivery keeps the owed handoff reason, waits for a possibly in-flight post, and warns the human when delivery is unknown. A successful post records confirmation even when the row stays pending for escalation.
  • The whole web response serializes with its footer last, keeping technical details and sources ahead of it in saved suggestions, shadow records and streaming output, while the native separate details pane is preserved.

The current amendment round adds five further concerns, each with its own red/green record:

  • The Discord footer is counted inside the message budget, with UTF-16 boundary and indentation handling corrected.
  • An exhausted GitHub rate limit is classified from its response headers rather than inferred.
  • Evidence-auth failure is reported as a safe operator diagnostic — an allowlisted category name, never a credential value — leaving the recoverable auth_unavailable result the model sees unchanged.
  • Validated code stays literal across both web serializations and in both validated fields; only the unvalidated disclaimer is sanitized, and the legacy formatter path is preserved.
  • Deterministic handoff reasons the pipeline already knows are preserved and carried across into a durable consumer, so a known reason reaches the human-facing ticket note, the system message and the recorded-delivery retry instead of stopping at pipeline metadata.

Two documentation-only follow-ups clarify that disclaimer-only sanitization applies to the web branch and that deployment configuration needs review before rollout.

These have accepted source-and-test review, not completed review confirmation: a scoped twenty-cluster confirmation at runtime head b09050e is still running after six interrupted jobs were restarted, and the broader original-PR confirmation and the original 130-case / 90-disposition promotion audit are unfinished. The two original-PR reason-propagation defects found in this review are resolved by the producer and durable-consumer changes in these commits; the broader audit remains open. No convergence claim is made.

Current PR head: cb9f8dc. CI run 37772441929 and security run 37772442032 are green at this exact head. CI run 37701387192 and security run 37701387244 passed at runtime head b09050e. Local checks are recorded below. Review confirmation and the broader audit remain unfinished.

Summary

Support replies could repeat the report, guess API compatibility, or bury the useful answer in a long response. This change introduces a bounded support investigator using GPT-5.6 Luna and the OpenAI Agents SDK. It validates structured evidence, runs a separate verifier, and renders a concise human paragraph with expandable technical details and sources on GitHub and in web QA.

Luna also handles confidence verification, ticket classification, and sentiment. The default path requires only OPENAI_API_KEY. The existing queue, one-primary-reply-per-ticket behavior, delivery adapters, feedback calibration, and durable escalation workflow remain in place. Delivery fixes preserve expanded details in saved suggestions and shadow records, and report unresolved handoff state instead of silently accepting it.

Runtime requirement: Node 24+ for local and container execution, aligned with CI and the OpenAI dependency stack.

Old vs. new responses

The table compares historical failure examples with actual saved output from the new pipeline, evaluated September 21, 2026, on build d8c932f. It shows the public body; AI disclosure and attribution footers are omitted here for readability.

Evidence scope: D is a verified excerpt from an original Outpost reply. A, B, C, and E describe reconstructed regression fixtures, not original transcripts. All five new outputs are real live runs. No replies were posted.

Case Previous response New pipeline's actual public body Assessment
A · Deep Agents subagents Reconstructed fixture: treated support as uncertain, suggested a speculative workaround, and sent the developer to a GitHub discussion. Yes. Deep Agents supports subagents: the top-level agent can spawn specialized agents for delegated tasks, and CopilotKit’s integration can stream those subagent interactions to the frontend. Direct, 26-word answer. Version scope and evidence are collapsed below it.
B · Rendering delegation Reconstructed fixture: mixed API generations and hedged an unverified render-hook name. This needs a maintainer review to give you a reliable next step. The draft failed the exact evidence-quote check. The handoff avoids publishing it, but gives no useful clarification.
C · Maintainer follow-up Reconstructed fixture: praised a maintainer and claimed it could not read other replies. Yes—the reporter confirmed that aligning the package versions restored the chat, so the rendering issue appears resolved. The exact framework, API generation, and package versions were not provided, so the underlying compatibility mismatch cannot be identified from the available thread. Reads the supplied resolution correctly, but adds little and discusses missing context. A no-reply policy is still needed.
D · Dependency audit, #6423 Verified original excerpt: “I haven't read the source files or run resolution myself, so I won't confirm causation or prescribe the specific fix” This needs a maintainer review to give you a reliable next step. Less noise, but no technical resolution. The investigator could not verify historical manifests and lockfile within its tool budget.
E · MCP SSE headers, #6927 Reconstructed fixture: praised the reproduction, explained its own limitations, and deferred to engineering. The report matches the current v2 source: the SSE config exposes a raw headers map, while the transport requires those headers under requestInit. The current main branch already contains the proposed mapping, so this appears fixed in source, but the published version containing it cannot be determined without the exact installed @copilotkit/runtime version. A concrete finding in 53 words, with installed-version uncertainty visible. Code and sources are collapsed.

The earlier September 17–18 pipeline run remains archived separately; it is not relabeled as this build.

The original D response had 281 whitespace-delimited body words/tokens, including Markdown heading markers and excluding its opening disclosure and footers; it had no dropdown. The new handoff body has 12 words. That is a readability improvement, not a technical resolution.

See the complete new response format: Deep Agents subagents

The following is the saved formatted output. The inner disclosure is the actual technical-details dropdown.

Yes. Deep Agents supports subagents: the top-level agent can spawn specialized agents for delegated tasks, and CopilotKit’s integration can stream those subagent interactions to the frontend.

Technical details and sources

The Deep Agents documentation explicitly lists “spawn subagents for specialized tasks” as a capability. CopilotKit’s integration documentation also states that users can interact with subagents as they spawn. This support belongs to the Deep Agents backend; CopilotKit provides the frontend surface and AG-UI connection rather than defining the subagent execution model.

Applies to: Deep Agents integration; no specific package version or CopilotKit API generation was provided.

API version: unknown

Sources


🤖 Generated by CopilotKit AI Support · Documentation

See the complete new response format: MCP SSE authentication headers

The following is the saved formatted output. It cites the source retrieved during evaluation; it does not establish a minimum fixed release or an executed reproduction.

The report matches the current v2 source: the SSE config exposes a raw headers map, while the transport requires those headers under requestInit. The current main branch already contains the proposed mapping, so this appears fixed in source, but the published version containing it cannot be determined without the exact installed @copilotkit/runtime version.

Technical details and sources

The verified evidence supports the reported defect mechanism and correction:

  • MCPClientConfigSSE documents headers?: Record<string, string>.
  • The current v2 runtime constructs SSE transport options as { requestInit: { headers: serverConfig.headers } }, rather than passing the raw map as the second argument.
  • The same source catches MCP connection failures, logs that the server is being skipped, and continues, which explains why the agent can appear without those tools.

For a checkout containing the current source, the relevant implementation is:

transport = new SSEClientTransport(
  new URL(serverConfig.url),
  serverConfig.headers
    ? { requestInit: { headers: serverConfig.headers } }
    : undefined,
);

This finding is scoped to CopilotKit v2. The retrieved implementation is from main; that proves the fix exists in source but does not prove which published package release includes it. Provide the exact @copilotkit/runtime version, including any prerelease tag, to verify whether upgrading resolves the issue for the affected installation.

Applies to: CopilotKit v2 BuiltInAgent MCP SSE configuration; verified in current main source. Published-version applicability remains unresolved until the exact @copilotkit/runtime version is known.

API version: v2

Sources


🤖 Generated by CopilotKit AI Support · Documentation

Architecture and behavior

Area Old baseline New implementation
Generation Fixed docs/code retrieval followed by one Claude Sonnet 4.6 call Luna investigation through the OpenAI Agents SDK and Responses API
Investigation No tools on the generator Read-only, adaptive CopilotKit/AG-UI docs/code search, supplied-thread reading, source-at-ref lookup, and tagged-release lookup
Bounds Single generation call Six tool calls, eight turns, 60-second investigation deadline; tools are disabled after the sixth call to leave room for synthesis
Response contract Free-form Markdown, mainly prompt-controlled answer / partial / route, one summary paragraph up to 80 words, details, API generation, applicability, evidence quotes, and internal handoff reason
Rendering Collapsed long code blocks on GitHub One GitHub technical-details dropdown; native disclosure in web QA; Discord splits to ordinary messages, and Slack and Teams still fall through to the legacy format
Verification Haiku judgment using truncated draft/source context Separate Luna judgment over the complete bounded draft/evidence and supplied conversation, followed by grounding checks
Publication controls Grounding suppression; low confidence did not generally withhold the draft Invalid output, unusable verification, intentional routing, or a verifier score below 0.40 withholds the technical draft and produces a concise handoff
Auxiliary models Haiku defaults for confidence, classification and sentiment Luna defaults throughout; strict structured results and preserved degraded states

Ticket classification preserves recognized critical incidents when the model fails or underestimates urgency. Regression tests distinguish incident reports from negation, prevention, and diagnostic questions.

The investigator and verifier receive available author/timestamp metadata. The thread tool reads supplied history; it does not fetch missing remote comments or claim the remote thread is complete. Version-aware search and evidence checks reduce definite v1/v2 mismatches. Source reads can pin commits, but search-result citations are not universally pinned. Main-branch code is not release evidence.

The style linter is connected in report mode by default; AI_DRAFT_LINT_MODE=enforce enables its blocking rules. Exact quote matching checks provenance, not whether every conclusion follows. The verifier is a separate call using the same model family, so it is not independent ground truth.

Validation and live evaluation

Runtime validation recorded at b09050e (tree 7baae56), eleven commits after the previously reviewed head c602d6c. Current PR head cb9f8dc adds two documentation-only commits: the formatter comment now explicitly scopes the web behavior, and rollout instructions accurately describe configuration review. The formatter’s executable output is byte-identical to b09050e, all other runtime source is unchanged, and both changed files pass a fresh formatting check. GitHub CI run 37772441929 and security run 37772442032 passed at cb9f8dc, including build, schema checks, formatting, lint, typechecks and tests.

  • 5,901 tests passed at runtime head b09050e: 4,599 core tests across 78 files, and 1,302 downstream tests across 94 files — 856 web, 84 worker, 73 Teams, 66 Discord, 64 root scripts, 60 GitHub App, 52 Slack, 47 Linear sync. The downstream suites were run fresh against the current build output. The core suite was measured twice at this same head and tree and reused rather than repeated; the web suite aliases core to src, so it exercises source rather than the compiled artifact.
  • Eleven cold typechecks passed: four core projects with their build info deleted first, plus each of the seven workspaces that depend on the core package. discord-mcp and docs were deliberately skipped — neither depends on it.
  • Lint exits clean with no errors and the same 40 pre-existing warnings across 27 files. The five warnings inside a changed test file sit at identical lines on both sides of the diff; nothing was repaired or suppressed.
  • Formatting passed --check on all 16 changed Prettier-supported files. The seventeenth, schema.prisma, is outside the Prettier glob and was checked with prisma validate instead, which passed against a placeholder connection string — static schema parsing, no database contacted. Its only change is a comment block, and the original migration is byte-unchanged, so the existing column is untouched.
  • The full production build passed: 10 tasks, 10 successful. All 8 packages and apps within the change's dependency reach were cache misses and executed fresh; the 2 cached tasks (docs, discord-mcp) are outside it.
  • Compiled modules were loaded and inspected after that build: 54 AI runtime exports and 21 queue exports. The public surface moves by exactly one addition versus c602d6c, with none removed. The new GitHub evidence-auth module is module-private — reached from the support agent, absent from the package index — and its only module specifiers are two Octokit packages already declared at c602d6c, so no dependency entered the runtime graph.

The reasoning-continuity proof — the Agents SDK 0.18.0 converter reading and the two-response interaction showing encrypted reasoning carried across a stateless request — is a dated historical record from an earlier session, not a new live call on this head. The new App-installation authentication has been exercised only against synthetic credentials and offline SDK requests; live installation authentication is still unverified.

This amendment changes no Dockerfile and no runtime dependency, so no container build was run and no container runtime claim is made for it; the earlier c602d6c image proof is archived and does not carry forward. No provider, GitHub, or platform call was made during these checks, no service was deployed, and no support reply was posted. The local credentials and /qa check remains tied to preview a4bf059, which is still the old build and does not contain any of these commits.

The five-case output comparison remains the September 21 live run at d8c932f. It is not a replay of this head, and tests and verifier scores do not establish real-world support accuracy.

Regression coverage includes the SDK tool loop, malformed/segmented structured output, evidence validation, investigation budgets, provider failures, rendering, stream boundaries, and delivery/escalation invariants.

The five-case evaluation was rerun September 21, 2026, against a freshly emitted AI build of d8c932f. The raw artifact records the commit and compiled-module hashes:

  • Five live Luna pipeline replays produced one answer, two partial answers, and two handoffs. Classification and sentiment returned valid, non-degraded results in all five; this establishes call/schema health, not semantic accuracy.
  • In an earlier, separate September 17–18 live test, the Luna verifier withheld 5/5 reconstructed historical bad replies, scoring them 0.05–0.18 against the 0.40 threshold.
  • Local replay times were 21.4–35.6 seconds. Total input ranged from 11,844 to 74,892 tokens, including cached tokens and discarded drafts. There is no matched old-system latency or cost measurement.

This is not a controlled Sonnet-versus-Luna comparison. Historical failures predate prompt improvements already present in the old code baseline. A/B use reconstructed questions, C uses a synthetic resolved conversation, and D/E use real issue openers without later comments. Retrieval uses current evidence, not snapshots from the original reply dates. Model, prompts, tools, validation, and rendering changed together; these results do not isolate model quality or estimate production accuracy.

Rollout and remaining work

Keep posting disabled during further evaluation. This change does not deploy a service or enable external replies. Before enabling automatic posting:

  • Give Discord a compact summary with an on-demand details interaction; details currently remain ordinary visible messages.
  • Carry validated-code rendering into Slack and Teams, which still fall through to the legacy format. The collapsed GitHub section and the web disclosure are the only two surfaces this change actually gives a distinct presentation.
  • Preserve useful clarification questions instead of replacing invalid or routed drafts with generic handoffs (B).
  • Add a no-reply decision for resolved conversations and maintainer-owned follow-ups (C).
  • Improve historical repository-layout and published-package evidence lookup (D).
  • Deduplicate retrieved context, improve citation pinning, and measure the token/coverage tradeoff.
  • Run matched old/new cases with frozen evidence and blind human review, grading added value, factual/version correctness, unnecessary handoffs, and appropriate silence.

Luna is the default here because that was an explicit user direction to route all AI calls through it. That direction is still being reconciled against the proposal to stay on Anthropic until a matched evaluation exists, so the default is a rollout decision rather than a settled benchmark result. For explicit rollback, set AI_RESPONSE_PROVIDER=anthropic, configure the Anthropic key, and clear or replace OpenAI-specific model overrides. Provider failures never silently switch providers.

The paragraph-plus-dropdown presentation follows the Orca response on #3434; that comment is a layout reference, not a correctness benchmark.

R1-DELIVERY-A1: join only the formatted summary and optional details when
saving suggestedResponse, so no-adapter web sources retain technical
details and source links. Keep private response/handoff metadata outside
this publishable field. Responses without details keep their existing text.

Affected symbol and callsites:
- handleAiResponse: registered by apps/worker/src/index.ts:85, imported at
  line 27; re-exported by packages/outpost/queue/src/index.ts:4.
- Test-only calls are in packages/outpost/queue/src/__tests__/ai-response.test.ts,
  imported at line 95; the new source-matrix call is at line 737.
- The single suggestedResponse assignment is in handleAiResponse; audit of
  the full handler and queue module found no other durable suggestion writer.
  The separate shadow SYSTEM diagnostic uses the formatted summary, and
  adapter delivery already receives the full formatted response object.

Validation:
- Baseline: 98 handler tests pass.
- Red: all five new no-adapter source cases fail (actual Summary only).
- Green: 103 handler tests; 74 package files / 1457 tests pass.
- Targeted Prettier and ESLint pass; queue tsc --noEmit and emitted build pass.
- git diff --check passes. Review convergence remains owned by the parent task.
Reject non-positive, non-finite, fractional, malformed, and unsafe integer
PATHFINDER_MAX_QUERY_CHARS values before direct Pathfinder clients can run.
Keep the default at 1000 and preserve positive safe-integer overrides.

AI-A01 regression: 10 new rejection cases failed before the fix because
config import succeeded (including maxQueryChars: NaN); all now pass.
Validation: 642 AI tests passed, including 70 config/Pathfinder tests;
targeted Prettier, ESLint, and AI TypeScript noEmit passed.

Affected-symbol audit: config.pathfinder.maxQueryChars is consumed only by
PathfinderClient.searchEvidence, private search, and searchDocs via
capQuery. All three now receive eagerly validated config. capQuery itself
and its explicit non-positive helper semantics are unchanged. Full config.ts
scan found no other numeric environment parsers with the same failure.
Require Node 24+ and use node:24-alpine in all seven app Dockerfiles,
matching the Node 24 CI runtime. The locked OpenAI SDK requires Node 22+
and all 585 locked Node engine declarations accept Node 24.0.0.

Runtime-reference audit covers the root manifest and README, all 21
Docker stages, deployment Markdown (including its base-image example),
and the deployment/getting-started HTML pages. No Node 20 support claims
remain outside locked third-party dependency metadata; the sample ticket
Node Version remains historical mock data. pnpm, Turbo, and healthchecks
are unchanged.

Verified JSON and all Docker FROM declarations, scoped Prettier checks,
HTML parse/format round-trip checks, dependency-engine compatibility,
and git diff --check. Container verification uses this committed tree.
Recognize GPT and known o1/o3/o4 model families in the shared provider
validation, including aliases and dated snapshots. Keep unknown custom
provider deployment names allowed, and preserve Claude rejection under
OpenAI.

Callsite audit:
- validateConfig routes response, confidence, classifier, and sentiment
  model settings through validateModelProvider before pipeline startup.
- AuxiliaryModel uses the same guard for explicit per-call overrides;
  ConfidenceScorer, TicketClassifier, and analyzeSentiment use this class.
- Provider-specific SupportAgent and ResponseGenerator remain unchanged;
  pipeline configuration validation guards their selected response model.
- No other duplicated provider-family checks exist in the AI package.

Regressions: all four config stages and direct auxiliary construction.
31 pre-fix failures become 91 passing focused cases; 169 related tests pass.
Targeted Prettier, ESLint, and AI no-emit typecheck pass.

Addresses AI-A02.
Addresses AI-A03. Distinguish explicit security vulnerabilities, data loss, and production outages from ordinary HIGH incidents. Preserve heuristic CRITICAL as an urgency floor without lowering model CRITICAL.

Affected callsites: TicketClassifier.heuristicClassify, TicketClassifier.classify (model merge and degraded fallback), and AIPipeline.classifyTicket through the shared classifier. The pipeline inherits the corrected fallback without a separate change.

Validation: regression cases failed in 14 assertions before the fix; all 61 classifier and auxiliary OpenAI tests now pass. Targeted Prettier, ESLint, and AI TypeScript noEmit checks pass.
Fix R1-DELIVERY-A2: read escalationRequiredReason alongside responseState
after a no-op escalation claim. DELIVERED with a retained marker now fails
for manual attention without claiming a handoff, clearing its reason, or
regenerating/reposting. Explicit null markers preserve clean success.

Apply the same invariant to the initial prior-response gate so retries
cannot erase the failure, and to pending-response sweep no-op outcomes.
The sweep still isolates failures per response and retains its scan policy.

Symbol callsites:
- reportSkippedRecoveryEscalation: recoverRequiredEscalation and
  recoverPendingResponse, ai-response.ts:312,360.
- handleAiResponse: queue/src/index.ts:4 exports it; worker/src/index.ts:27
  imports it and :85 registers AI_RESPONSE. Its handler regression suite
  exercises the initial gate and both no-op recovery entry points.
- settleStrandedResponse: handlePendingResponseSweep at
  pending-response-sweep.ts:231; the public handler is exported by
  queue/src/index.ts:15 and registered by worker/src/index.ts:94.
- stageRow test fixture: recovery no-op describe block in
  queue/src/__tests__/ai-response.test.ts, including the initial-gate tests.
- settleAfterRead test fixture: pending-response-sweep.test.ts:367,379,398
  covers concurrent ESCALATED, conflicting DELIVERED, and clean DELIVERED.

Validation: red/green demonstrated for both no-op recovery routes, the
initial-gate retry, and sweep no-op; 130 tests pass across both affected
suites. Prettier and queue tsc pass. ESLint has zero errors and five
pre-existing warnings in untouched sweep fake methods (baseline six).
Forward nonempty query.version from the shared search builder and the separate searchDocs builder, matching searchEvidence. Keep unversioned arguments and existing error handling unchanged.

Callsite audit: searchCode, searchAgUiCode, and searchAgUiDocs delegate to the shared builder; searchDocs builds its own arguments; searchEvidence already forwards version. Pipeline searchDocs/searchCode callers omit version and retain their behavior. Support-agent searchEvidence callers are unchanged. exploreDocs, queryKnowledgeBase, and fallbackSearch do not accept PathfinderQuery.

Regression evidence: serialized JSON-RPC assertions fail for v1/v2 across all four wrappers before the fix (8 failures; omitted-version cases pass). After the fix, 55 Pathfinder tests pass. Prettier, targeted ESLint, AI typecheck, and AI build pass.
Derive labels from the final rounded score using the documented thresholds.
Use the existing 25/NEUTRAL empty-input contract for degraded fallbacks and
empty trend periods, while retaining degraded flags and billed token usage.

Callsite audit: account-scoring consumes the label for its Prisma mapping
and still skips sentiment writes whenever degraded is true; no handler
changes were needed. getSentimentTrend forwards analyzed scores and labels
and now emits a consistent neutral pair for empty periods, which remain
excluded from trend deltas. Both APIs keep their existing exports and shape.

Validation: genuine red-green regression (19 expected failures before the
fix, 59 passing tests after) across sentiment, OpenAI auxiliary transport,
sentiment trend, and account-scoring. Changed-file Prettier, ESLint with
zero warnings, AI tsc --noEmit --incremental false, and git diff --check pass.
Follow up AI-A03: avoid CRITICAL escalation for prevention questions and negated incident mentions. Inspect nearby phrase context within individual sentences so a separate affirmative incident still escalates. Preserve the original data-loss HIGH baseline and all model priority floors.

Affected callsites: TicketClassifier.heuristicClassify and TicketClassifier.classify; AIPipeline.classifyTicket inherits the guarded heuristic without its own change. Guards intentionally cover common phrasing rather than full natural-language inference.

Validation: 11 new assertions fail before the follow-up; all 77 classifier and auxiliary transport tests pass afterward. Targeted formatting, lint, AI noEmit typecheck, and diff checks pass.
Apply the requested API generation filter before deciding whether search_evidence needs its unfiltered fallback. Use the same case-insensitive v1-deprecated marker as final reply validation, and filter fallback hits before remembering them. Mixed usable results retain requested_version scope.

Callsite audit: both support-agent searchEvidence calls share this filter across CopilotKit/AG-UI docs/code. Pathfinder strict parsing/version forwarding and final reply validation remain intact. The single spend(), shared abort signal, request/source limits, v1/unknown semantics, scope labels, and thrown retrieval failures are preserved; no other production searchEvidence callers exist.

Validation: genuine SDK/aimock red-green roundtrips cover deprecated-only and uppercase-title first hits, useful fallback evidence, mixed results without fallback, v1/unknown retention, and fallback error propagation. Full AI suite: 27 files, 744 tests passed. Prettier, ESLint, AI typecheck, and isolated AI emit build passed. Temporary dependency/declaration links removed before commit.
Flatten every publishable Discord part in the support-agent string stream, without duplicating the first part. Append separate details only for web output and retain the publication gate.

Add an aimock regression through the real SupportAgent, reply validation, and formatter. The pre-fix stream loses steps 12-24, citations, and the footer; short replies on all platforms and routed-draft confidentiality are also covered.

Callsite audit: generateStreamingResponse has only test callers in this repo. The Discord adapter already consumes every formatted part; the web QA route consumes separate details metadata. The queue saved-suggestion continuation issue is separate from this stream API. Legacy streamed chunks remain unchanged.

Validation: 770 AI tests pass, including 125 targeted pipeline/formatter tests; Prettier and ESLint pass for both changed files; AI tsc noEmit and isolated production build pass on Node 24.
Return a failed AI_RESPONSE result when both the DELIVERED write and
its deliveryConfirmed fallback fail after successful delivery. Include
both errors for manual attention and stop before progress 100 while
preserving pipeline teardown and the one-post response claim.

R1-DELIVERY-A4: the regression failed against a9db1d8 with success true,
then passed with the fix. The fallback-marker-success control remains
green. A retry schedules recovery without regenerating or reposting.

Callsite audit: read the complete ai-response handler, its worker
registration and JobResult failure consumer, and the pending-response
sweep delivery marker consumer. Existing primary-response recovery
prevents repeat posts; successful fallback markers still repair state.
No owed-escalation reason persistence changes (R1-DELIVERY-A3).

Validation: 108 handler tests and 256 queue tests pass; scoped Prettier
and ESLint, queue tsc --noEmit, queue emitted build, and diff check pass.
R1-DELIVERY-A3: insert escalationRequiredReason with the primary response,
so failed later writes or an interrupted enqueue cannot replace the exact
low-confidence/suppression reason with generic delivery recovery. An insert
failure prevents publication. Keep a failed platform post's reason ahead
of suppression by replacing its marker and responseError together; report
both write and queue failures when that replacement is not durable.

Symbols and callsites:
- handleAiResponse (ai-response.ts:480) is exported by queue/src/index.ts:4
  and registered by apps/worker/src/index.ts:85. It computes the owed reason
  before its primary message insert, then publishes at most once.
- recoverRequiredEscalation (ai-response.ts:294), called by the prior-response
  gate at :569, preserves deliveryFailed metadata for replaced reasons.
- enqueueEscalationAtomically is unchanged; its callers at ai-response.ts:306,
  :354, :1049 and pending-response-sweep.ts:142 consume the durable reason.
- trackResponsePersistence (ai-response.test.ts:264) provides real write-derived
  retry/sweep snapshots with rollback for the new failure-path regressions.

Validation: genuine red/green retry and sweep regressions; 137 tests pass;
changed-file Prettier and ESLint pass; queue no-emit typecheck and isolated
queue emit build pass on Node 24. A4 delivery-confirmation failure handling
is unchanged. Parent owns the CR loop; no push is part of this fix.
R1-DELIVERY-A3 follow-up: a concurrent retry can enqueue the persisted
handoff while the original postResponse is pending. Guard the later
delivery-reason replacement by PRIMARY_AI_RESPONSE and PENDING so it
cannot recreate an owed marker on ESCALATED or DELIVERED. When the guard
matches no row, retain responseError without changing lifecycle metadata.

Symbols and callsites:
- handleAiResponse (ai-response.ts:480), exported at queue/src/index.ts:4
  and registered at apps/worker/src/index.ts:85, now conditions the marker
  replacement on the still-pending primary response.
- recoverRequiredEscalation (ai-response.ts:294), called by the gate at
  :569, and enqueueEscalationAtomically (:120; calls :306, :354, :1064 and
  pending-response-sweep.ts:142) retain their existing transition behavior.
- holdPlatformPost (ai-response.test.ts:341) and trackResponsePersistence
  (:264) drive the real overlapping retry regression. The analogous
  DELIVERED/interrupted-owner case proves no transient marker is stranded.

Validation: both race cases fail before the guard and pass after it;
139 handler/sweep tests pass. Changed-file Prettier and ESLint, queue
typecheck, and isolated queue emit build pass. A4 remains unchanged.
Root owns CR and integration; no push or main-checkout mutation.
Follow up R1-DELIVERY-A1 by preferring nonempty formatted.parts over the
aliased first-part text when saving the complete publishable suggestion.
Join parts with paragraph separators, append optional details, and fall
back to text when parts is absent or empty. Never append private draft or
handoff metadata.

Affected symbol and callsites:
- handleAiResponse: imported by apps/worker/src/index.ts:27 and registered
  as the AI_RESPONSE handler at line 85; re-exported by
  packages/outpost/queue/src/index.ts:4.
- Test calls remain in packages/outpost/queue/src/__tests__/ai-response.test.ts
  (import at line 95; new multipart/fallback call at line 741).
- Full handler and queue-module audit found one suggestedResponse writer.
  Adapters already receive the full formatted object; shadow diagnostics
  remain separate. The analogous streaming pipeline remains unchanged.

Validation: the multipart regression genuinely failed with Summary only
before the fix; afterward all 105 handler tests pass, including existing
web/no-details coverage and the empty-parts fallback. Targeted Prettier,
ESLint, queue typecheck, and queue emitted build pass. Private fields and
duplicate first parts are excluded by the exact expected suggestion.
Force OpenAI and Luna in the shared auxiliary call options. Override the
pipeline test config's provider and all four model fields, then validate
the same config object consumed by the default pipeline clients.

Test callsite audit: every ConfidenceScorer, TicketClassifier, and
analyzeSentiment call in auxiliary-openai.test.ts uses the pinned options.
All three AIPipeline construction sites use the file-local config mock;
the injected SupportAgent already defaults to Luna on OpenAI Responses.
No production behavior or process-wide provider/model env mutation changed.

AI-A10 red/green: Anthropic provider plus Claude model overrides produced
23 failures before the fix and 46 passes afterward. Both files also pass
with default env and OpenAI provider plus Claude model overrides. Full AI
suite: 776 tests pass. Targeted Prettier/ESLint and AI typecheck pass.
R2-AI-A02 keeps legacy Pathfinder parsing tolerant while making the strict searchEvidence boundary fail when a code result cannot be cited.

Call sites audited: PathfinderClient.searchEvidence production use is via SupportAgent in packages/outpost/ai/src/support-agent.ts for the initial docs lookup and dynamic follow-up tool calls; direct strict-boundary tests live in packages/outpost/ai/src/pathfinder-evidence.test.ts. SearchResult kind/sourceUrl consumers in support-agent.ts and support-reply.ts were checked; no exported shape changed.

Checks: red pathfinder-evidence regression failed before implementation; green pathfinder-evidence and pathfinder parser tests passed; scoped Prettier, scoped ESLint, AI tsc --noEmit, AI tsc build with /tmp output, and git diff --check passed.
R2-DELIVERY-A1 keeps the SHADOW_MODE SYSTEM row aligned with the complete publishable response stored as suggestedResponse.

Changed-symbol callsites: added private completePublishableResponse in packages/outpost/queue/src/handlers/ai-response.ts; it is called once in handleAiResponse to derive publishableResponse, which is then used by the suggestedResponse ticket update and the outpost-shadow SYSTEM message create. Added findShadowMessageCreateCall test helper used by the existing shadow assertion plus the new web-details and multipart Discord shadow regressions.
R2-AI-A05 prevents a caller-cancelled MCP setup from leaving the initialized session id cached for the next strict evidence request. The cancellation cleanup is limited to the caller AbortSignal path during notifications/initialized; non-cancellation notification failures remain best-effort.

Call sites audited: connect is used directly by pathfinder tests and internally by callTool; production strict evidence flows through SupportAgent's Pick<PathfinderClient, 'searchEvidence'> dependency in support-agent.ts. reset remains private except disconnect, and no exported type or method shape changed.

Checks: red pathfinder-evidence sequential cancellation regression failed before implementation; green pathfinder-evidence and pathfinder parser tests passed; scoped Prettier, scoped ESLint, AI tsc --noEmit, AI tsc build with /tmp output, and git diff --check passed.
R2-AI-A04: recognize did/has/was/were/have/had/will question starters in
the existing sentence-local CRITICAL guard. Keep the old HIGH baseline,
healthy model CRITICAL, affirmative incidents and mixed sentences intact.

Add 21 contrastive heuristic rows and real Responses LOW/MEDIUM and
CRITICAL coverage. Genuine red: 20 cases initially, plus 15 additional
same-pattern cases. Green: 120 classifier/auxiliary tests; scoped Prettier
and ESLint, inclusive AI typecheck and isolated AI build all pass.

Changed-symbol call-site audit: TicketClassifier.heuristicClassify is called
by TicketClassifier.classify (classifier.ts:54), AIPipeline.classifyTicket
fallback (pipeline.ts:432), and classifier.test.ts. The model-backed caller
is pipeline.ts:426, constructed at pipeline.ts:110. Tests exercising the
class also include auxiliary-openai.test.ts; pipeline.test.ts and
pipeline-groundedness.test.ts retain their injected classifier mocks.
No signature, exports, tag/category heuristics or priority merge changed.
R2-AI-A01 separates internal handoff diagnostics from public support prose validation while preserving strict public summary/details/appliesTo checks and the nonempty route reason rule.

Changed-symbol callsites: validateSupportReply still parses supportReplySchema, checks route handoffReason nonempty, validates evidence, and now calls validateProse only for public summary/details/appliesTo. supportReplyText and supportReplyDetails were not changed; tests assert private handoffReason does not appear in either renderer. No queue, provider, or formatter callsites changed.
R2-AI-A06 fixes code citation URL construction for GitHub repository URLs that end in .git/. The normalization now removes a trailing slash before stripping a trailing .git suffix, preserving the existing provider allowlist, branch, ref, and source path behavior.

Call sites audited: blobUrl is private and called only by parseSnippets for code snippets. Its sourceUrl output flows through tolerant searchCode/searchAgUiCode, strict searchEvidence, SupportAgent source filtering/deduplication, and support-reply citation validation. No exported interface shape changed.

Checks: red pathfinder parser and strict evidence suffix regressions failed before implementation; green pathfinder and pathfinder-evidence tests passed; scoped Prettier, scoped ESLint, AI tsc --noEmit, AI tsc build with /tmp output, and git diff --check passed.
R2-AI-A03: keep the message from known validation and investigation-budget
errors for PipelineResult.handoffReason. Retain the existing 2000-character
return bound, generic public handoff and empty-diagnosis fallback. Other
provider/transport failures still propagate without replacement.

Tests first: eight genuine failures for diagnosis loss, then 88 passing
pipeline tests covering both classes, GitHub/Discord/web public and stream
privacy, cause exclusion, low-confidence suppression and verifier skipping.
Scoped Prettier/ESLint, inclusive AI typecheck and isolated AI build pass.

Changed-symbol call-site audit: AIPipeline.generateSupportResponse is called
by queue/handlers/ai-response.ts:735, apps/web/src/app/api/qa/route.ts:65,
and generateStreamingResponse at ai/src/pipeline.ts:466, plus owning tests.
Private handoffReason is consumed by queue escalation/shadow diagnostics
at ai-response.ts:786,875; web/streaming select formatted output. No signature,
export, queue state machine or provider error conversion was changed.
R3-AI-A02 requires strict searchEvidence calls for search-code and search-ag-ui-code to reject legacy JSON-array results that cannot be cited. The legacy parser can still return URL-less entries, but the strict boundary now also uses the requested tool name when enforcing code citation provenance.

Changed symbol call sites audited: PathfinderClient.searchEvidence is called by SupportAgent primary retrieval in packages/outpost/ai/src/support-agent.ts, by SupportAgent follow-up tool retrieval in packages/outpost/ai/src/support-agent.ts, and by strict evidence tests in packages/outpost/ai/src/pathfinder-evidence.test.ts. Public tolerant wrappers searchCode/searchDocs/searchAgUiCode/searchAgUiDocs still route through search/parseSearchResults unchanged, with a regression control for URL-less legacy searchCode JSON.

Validation: red pathfinder-evidence regression failed at 233baee with both strict code tools resolving URL-less legacy JSON; green pathfinder-evidence passed 28 tests; related pathfinder parser/wrapper test passed 41 tests; scoped Prettier, scoped ESLint, explicit AI typecheck, explicit AI build, and git diff --check passed.
R3-AI-A03 encodes read_source blob citation paths with the same per-segment encoding used for GitHub contents requests.

Changed symbol/callsites:

- Added encodeSourcePath(path) in packages/outpost/ai/src/support-agent.ts.

- Updated read_source githubJson contents callsite to use encodedPath.

- Updated read_source remembered sourceUrl callsite to use encodedPath in the GitHub blob URL.

- Added support-agent regression rows for docs/My Guide.md and docs/100% ready (setup).md.

- Added support-reply validation regression proving encoded blob citations validate while raw whitespace URLs remain rejected.
R3-AI-A05 extends the sentence-local critical incident suffix guard so passive absence reports such as security vulnerabilities were not found and production outage was not reported do not create a CRITICAL floor.

Call-site enumeration for the changed classifier surface: TicketClassifier.heuristicClassify is called by TicketClassifier.classify in packages/outpost/ai/src/classifier.ts; AIPipeline.classifyTicket uses the classifier fallback in packages/outpost/ai/src/pipeline.ts; AIPipeline constructs TicketClassifier in packages/outpost/ai/src/pipeline.ts; queue AI response handling reaches it through pipeline.classifyTicket in packages/outpost/queue/src/handlers/ai-response.ts; classifier, auxiliary-openai, pipeline, and queue tests cover the concrete and mocked callers.

Tests: node ../../node_modules/vitest/vitest.mjs run ai/src/classifier.test.ts --config vitest.config.ts --no-cache --reporter=dot; node node_modules/prettier/bin/prettier.cjs --check packages/outpost/ai/src/classifier.ts packages/outpost/ai/src/classifier.test.ts; node node_modules/eslint/bin/eslint.js packages/outpost/db/src packages/outpost/ai/src packages/outpost/queue/src packages/outpost/shared/src; node node_modules/typescript/bin/tsc --project packages/outpost/ai/tsconfig.json --noEmit --incremental false; node node_modules/typescript/bin/tsc --project packages/outpost/ai/tsconfig.build.json --outDir /tmp/outpost-r3-passive-ai-build-1789930006 --incremental false
Follow-up for R3-AI-A05: evaluate every critical incident mention in a sentence so a first passive absence mention cannot hide a later affirmative mention of the same phrase.

Regression rows cover: Security vulnerabilities were not found in staging, but security vulnerabilities were found in production; Data loss was not found in staging, but data loss was found in production. Red log showed both returned HIGH before the fix; green log shows 84/84 classifier tests passing after the scanner evaluates all mentions.

Call-site context remains the same as the prior A05 commit: TicketClassifier.heuristicClassify is called by TicketClassifier.classify; AIPipeline.classifyTicket uses classifier fallback logic; queue AI response handling reaches it through pipeline.classifyTicket. No public symbols or signatures changed.

Tests: node ../../node_modules/vitest/vitest.mjs run ai/src/classifier.test.ts --config vitest.config.ts --no-cache --reporter=dot; node node_modules/prettier/bin/prettier.cjs --check packages/outpost/ai/src/classifier.ts packages/outpost/ai/src/classifier.test.ts; node node_modules/eslint/bin/eslint.js packages/outpost/db/src packages/outpost/ai/src packages/outpost/queue/src packages/outpost/shared/src; node node_modules/typescript/bin/tsc --project packages/outpost/ai/tsconfig.json --noEmit --incremental false; node node_modules/typescript/bin/tsc --project packages/outpost/ai/tsconfig.build.json --outDir /tmp/outpost-r3-passive-followup-ai-build-final-1789930263 --incremental false
R3-AI-A06: suppressed legacy responses now put deterministic groundedness/enforced-lint reasons before generic legacy generator reasoning, while explicit OpenAI support-agent route/error diagnostics still take precedence.

Changed-symbol call sites: AIPipeline.generateSupportResponse is called by the queue AI response handler, web QA route, and generateStreamingResponse; PipelineResult.handoffReason is consumed by queue escalation persistence and by pipeline tests; public web/streaming paths publish formatted output. lintDraft, assessGroundedness, SUPPRESSED_RESPONSE_TEXT, and exported type shapes keep their existing call sites and signatures.

Tests: red pipeline/pipeline-openai suite failed 2/91 as expected before the fix; final related Vitest suite passed 110/110; scoped Prettier, ESLint, AI typecheck, AI build, and git diff --check passed. Logs and round-3-fix-R3-AI-A06.md saved in the CR ledger.
jerelvelarde and others added 26 commits October 18, 2026 20:36
A new AIPipeline, and with it a new SupportAgent, is built for every job
and every web QA request, and each agent called githubEvidenceAuthFromEnv
for its own resolver. The createAppAuth strategy behind it holds the SDK's
token cache, so every ticket that read source minted its own installation
token, and the comments about tokens being reused across investigations
only held within one.

A usable configuration now resolves to one resolver per process, keyed on
the credentials and the factory, so the token one investigation mints is
the one the next reuses. Anonymous and misconfigured resolvers are
stateless and are unchanged.
The evidence reads ask for an installation token narrowed to
contents:read, but the GitHub App setup steps only listed Issues and
Discussions. Without Contents, GitHub refuses the narrowed token and
every read_source and read_release comes back as auth_unavailable.
List Contents: Read-only, say how to add it to an existing App, and
point to it from the support agent docs.

Also add a deployment note: OPENAI_API_KEY must be on outpost-worker
and outpost-web in both Railway environments, and claude-* model
overrides must be cleared, before the OpenAI default deploys. Neither
fails at boot, so a missed step dead-letters every AI_RESPONSE job
with no reply and no handoff.
…ipeline tests

The same regex was written out three times: once in the Discord budget
tests and twice more by the handoff-reason bound tests. It now lives in
test-utils/surrogates.ts, which the build already excludes, and all three
call sites import it.
The sharedInstallationAuth docblock said the SDK's token cache and its
in-flight sharing both live inside one strategy instance. In the installed
@octokit/auth-app@7.2.2 (dist-node/index.js) only the first is true:

- The token cache is per instance. createAppAuth puts `cache: getCache()`
  on its state (line 452), and getCache builds a fresh toad-cache Lru each
  call (lines 43-50). Reads and writes go through state.cache (183, 259).
- In-flight sharing is module level. `pendingPromises` is a single Map
  declared at module scope (line 167). getInstallationAuthenticationConcurrently
  (168-180) keys it with optionsToCacheKey (99-114): installation id,
  repository ids, repository names and permissions, with no App identity.
  Concurrent exchanges are therefore de-duplicated across strategy
  instances, and across Apps with the same installation id, whether or not
  the resolver is shared. The exchange writes its token only to the opening
  strategy's cache (line 259).

So sharing the resolver is what makes the cache reusable across
investigations; it is not what de-duplicates concurrent exchanges. The
docblock now says that.

The untilAborted comment said a detached exchange fills the SDK cache "for
whoever is still waiting". Waiting callers get the token from the shared
promise itself; the cache write (into the opener's cache) serves whoever
asks next. Reworded to match.

The TOKEN_EXCHANGE_TIMEOUT_MS comment was checked and is accurate as
written. Comment-only change.
- Cutover checklist now includes granting Contents: Read-only to an
  existing App. read_source and read_release run only on the OpenAI
  path (AIPipeline builds a SupportAgent only for responseProvider
  openai, packages/outpost/ai/src/pipeline.ts) and request a token
  narrowed to contents: read (EVIDENCE_PERMISSIONS in
  packages/outpost/ai/src/github-evidence-auth.ts).
- A missing Contents permission is no longer described as only
  auth_unavailable. That is the model's tool result (githubJson returns
  reason auth_unavailable, packages/outpost/ai/src/support-agent.ts).
  The worker log line carries token_exchange_failed: mintWithoutLeaking
  wraps any failed exchange in that code, and githubJson logs it through
  githubEvidenceAuthDiagnostic (github-evidence-auth.ts).
- Sentiment no longer degrades "without anyone being told".
  analyzeSentiment logs an error line on its fallback
  (packages/outpost/ai/src/sentiment.ts), and account scoring skips the
  sentiment write when the result is degraded, so accounts keep their
  last sentiment
  (packages/outpost/queue/src/handlers/account-scoring.ts).
- "Missing either step" now reads "step 1 or 2", since the new step 3
  does not fail the job. The validateConfig, pending-row and
  retry/dead-letter claims were checked and kept: AIPipeline's
  constructor is the only validateConfig caller (pipeline.ts), it runs
  before prisma.message.create in handlers/ai-response.ts, and a
  success: false result retries until MAX_JOB_ATTEMPTS, then
  dead-letters (handleFailure in packages/outpost/queue/src/worker.ts).
- Org-owned App permissions can be changed by an org owner or a GitHub
  App manager, not only an org owner.
…'t accept

Since 29ad1ab one installation resolver, and with it one @octokit/auth-app
strategy and its 59-minute token cache, serves every investigation in the
worker. Two ways it could then hand every investigation an unusable token:

- A 2xx exchange whose body could not be read (the module's own exchange
  deadline firing mid-body, a reset connection, a proxy's non-JSON page)
  comes back from @octokit/request as data "", and auth-app returns and
  caches the resulting token: undefined as a success. The resolver sent
  "Bearer undefined", then "Bearer " on every cache hit; each evidence read
  401'd into upstream_error with no GitHubEvidenceAuthError and no operator
  log line, against the GitHubEvidenceAuth contract that an unusable
  credential must reject.
- A token GitHub refuses with 401 kept being served from the SDK cache to
  every investigation until it expired. Nothing ever asked for a new one.

mintWithoutLeaking now resolves only with a token that can be sent (a
non-empty run of visible ASCII, so a line break fetch would refuse is caught
too) and otherwise rejects with token_exchange_failed, naming no value. Any
failed authorization discards the strategy it used, so the next call builds
a fresh createAppAuth and exchanges again. It discards only that strategy,
never a replacement another investigation built meanwhile.

GitHubEvidenceAuth gains an optional invalidate(), implemented by the
installation resolver as dropping its strategy. githubJson calls it when a
request that carried an Authorization header comes back 401, and writes one
constant line to the operator log. Callers already awaiting authorization()
settle with their own exchange's result; anonymous and rejecting resolvers
have nothing to drop and do not implement it. The investigator still sees
upstream_error with httpStatus 401.

Same pattern, also fixed: githubEvidenceHeaders treated any falsy value as
"anonymous", so a resolver resolving "" or null sent a bare request. Only
undefined means anonymous now; anything else non-string or empty rejects
with GitHubEvidenceAuthError.

Call sites of added or changed symbols:
- GitHubEvidenceAuth.invalidate (new, optional): declared
  github-evidence-auth.ts:144, implemented in installationAuth
  (github-evidence-auth.ts:337), called only from githubJson
  (support-agent.ts:147). Not implemented by anonymousGitHubEvidenceAuth,
  rejecting(), or the { authorization } test seams; all still type-check.
- INSTALLATION_TOKEN (new): github-evidence-auth.ts:379 only.
- mintWithoutLeaking (now resolves Promise<string>): its one caller,
  installationAuth (github-evidence-auth.ts:312).
- installationAuth: its one caller, sharedInstallationAuth
  (github-evidence-auth.ts:289).
- githubEvidenceHeaders: githubJson (support-agent.ts:115), plus tests.
- githubJson: read_source commits and contents (support-agent.ts:358, 377),
  read_release (support-agent.ts:450).
The module has no other importers: support-agent.ts and the two test files.
The handoff reason is written through Prisma to
Message.escalationRequiredReason and then into the ESCALATION job's jsonb
payload. sliceWithoutSplittingSurrogates only guaranteed that its own cut
did not split a surrogate pair, which is not enough to store the result:

- Prisma's query engine refuses an ill-formed string at the first write,
  Message.create, and Postgres refuses U+0000 and unpaired surrogates in
  text and jsonb. Either way the AI_RESPONSE job throws before posting, so
  there is no reply row and no handoff, and every retry fails the same way.
- The input can already be ill-formed. On the investigation failure path
  the reasoning is error.message, and V8's JSON.parse error quotes the
  leading half of an emoji that a reply opens with.
- A negative bound reached .slice(0, negative), which returns nearly the
  whole string and can end in a lone surrogate.

The helper is renamed boundForStorage. It replaces every unpaired
surrogate (high, low, or a reversed pair) and every U+0000 with U+FFFD,
one code unit for one so the length is unchanged, then cuts to at most
maxLength code units without splitting a pair. A bound that is not
positive, NaN included, returns ''. The regex is compiled once at module
level, since the ES2022 target does not type String.prototype.toWellFormed.

Call sites of boundForStorage:
- packages/outpost/ai/src/index.ts (re-export)
- packages/outpost/ai/src/pipeline.ts (handoffReason composition)
- packages/outpost/queue/src/handlers/ai-response.ts (low-confidence arm
  of nonDeliveryEscalationReason; rename and comment only)
- packages/outpost/ai/src/formatter.test.ts

The comments at the pipeline and ai-response call sites no longer
attribute the failure only to the job's jsonb payload.

Tests feed a lone high surrogate, a lone low surrogate, a reversed pair
and U+0000, under and over the bound, plus negative, zero and NaN bounds.
pipeline.test.ts also covers a failed investigation whose message carries
a lone surrogate, and model reasoning that carries one on a forced
escalation. The lone-low cases also kill the mutant that drops the
lone-low branch of LONE_SURROGATE.
Both alignment cases checked that the bounded reason had no lone surrogate, but not that the cut ever came near an emoji. If the deterministic reasons in front of the model's text grew past the bound on their own, both cases would pass without testing the guard. They now also assert that a whole emoji ends the reason.
…-confidence one

handleAiResponse documents that it bounds the pipeline's reason whenever
it repeats it into an escalation, but only the low-confidence arm of
nonDeliveryEscalationReason did. The suppressed arm interpolated
pipelineResult.handoffReason raw:

- Not trimmed, so a whitespace-only reason wrote
  "AI response withheld (   ) — needs a human answer" instead of falling
  back to the groundedness findings or the default.
- Not bounded and not made storable. The value goes to
  Message.escalationRequiredReason and the ESCALATION job's payload.
  Prisma's query engine refuses an ill-formed string at the first write,
  Message.create, and Postgres refuses U+0000 and unpaired surrogates in
  text and jsonb, so a reason carrying either fails the job before it
  posts, on every retry. The groundedness fallback had the same gaps.

A local repeatableReason() now trims, applies boundForStorage at
MAX_REPEATED_HANDOFF_REASON, and returns '' for a blank or absent reason,
so each arm picks its own fallback.

Changed sites in packages/outpost/queue/src/handlers/ai-response.ts:
- repeatableReason(): new helper next to MAX_REPEATED_HANDOFF_REASON.
- knownHandoffReason: now repeatableReason(handoffReason). The
  low-confidence arm interpolates it directly; its output is unchanged.
- withheldReason: new. handoff reason, else the groundedness findings,
  each through repeatableReason, else 'Insufficient verified evidence'.
- Suppressed arm of nonDeliveryEscalationReason: uses withheldReason
  (durable escalationRequiredReason and queued ESCALATION reason).
- The "Ungrounded draft withheld" console.warn: uses withheldReason, so
  the log names the reason the human is handed.

Tests in queue/src/__tests__/ai-response.test.ts:
- Suppressed arm: a blank handoff reason falls back to the groundedness
  findings, and to the default when those are blank too (exact strings,
  stored, queued and logged).
- Suppressed arm: a 5000-unit handoff reason or groundedness fallback is
  stored and queued with exactly 2000 units of it.
- Suppressed arm: a reason with U+0000, a lone low and a lone high
  surrogate is stored and queued with U+FFFD in their place. LONE_SURROGATE
  is restated locally: the queue tsconfig roots at queue/src and refuses
  an import from ai/src (TS6059).
- Low-confidence arm: reasons of exactly 2000 and 2001 units both keep
  exactly 2000. The emoji test cuts at 1999 under a bound of 1999 or 2000,
  so a 1999 mutant used to survive; it now fails 4 tests, and a 2001
  mutant fails 5.
Two comments said a lone UTF-16 surrogate is dangerous because Postgres
rejects it in the ESCALATION job's jsonb payload. Measured against
Prisma 6.19.3 and Postgres 17.11, the first and decisive refusal comes
earlier: Prisma's query engine rejects any string with an unpaired
surrogate at the first write, prisma.message.create of the AI response
row, which carries Message.escalationRequiredReason. It fails with
errors such as "unexpected end of hex escape" before anything posts.
Postgres also refuses unpaired surrogates and U+0000 in text and jsonb,
but Prisma never sends the value that far.

Corrected the comments in test-utils/surrogates.ts and above the
"never leaves half an emoji at the end of a bounded handoff reason"
test in ai-response.test.ts. Comments that already name the
Message.create refusal are unchanged. No code or test logic changed.
Each claim below is settled by the code location named with it.

- outpost-web runs the support agent's GitHub evidence reads. The web QA
  route builds a pipeline (apps/web/src/app/api/qa/route.ts:52, `new
  AIPipeline()`), the pipeline builds a SupportAgent on the OpenAI path
  (packages/outpost/ai/src/pipeline.ts:105-108), and SupportAgent reads the
  App credentials through githubEvidenceAuthFromEnv
  (packages/outpost/ai/src/support-agent.ts:224). Added step 4 to the
  cutover checklist in docs/deployment.md and fixed the service scope of
  the App-credentials bullet in docs/support-agent.md.
- With none of the three variables set, reads are anonymous; with a partial
  or malformed set, they fail (githubEvidenceAuthFromEnv in
  packages/outpost/ai/src/github-evidence-auth.ts:219-251).
- GitHub App Setup step 4 said the web service reads the variables through
  the platform adapter registry. Only the worker's AI_RESPONSE handler calls
  getAdapter (packages/outpost/queue/src/handlers/ai-response.ts:1078); the
  worker also reads them in the reaction poll
  (packages/outpost/queue/src/handlers/github-reaction-poll.ts:60-62).
  outpost-web reads them only through the evidence auth.
  GITHUB_WEBHOOK_SECRET is read only by apps/github-app/src/config.ts:8.
- A 401 on an authenticated evidence read invalidates the resolver and logs
  one constant line, "[SupportAgent] GitHub rejected the evidence credential
  with HTTP 401"; an anonymous 401 logs nothing
  (packages/outpost/ai/src/support-agent.ts:145-150). Documented in the
  operator-log paragraph of docs/support-agent.md, without the invalidation
  mechanism.
…stored

The delivery-failure text in handleAiResponse comes from adapter and Prisma
error messages, and some adapters put a whole HTTP response body in them.
It reached the Message row and the ESCALATION payload unbounded, and a
U+0000 or an unpaired surrogate in it made the write throw, which blocked
the escalation and lost the diagnostic.

It now goes through boundForStorage (via storableErrorText, bound
MAX_STORED_ERROR_TEXT = 2000) where it is derived, at all three sites:
- the misconfigured-adapter catch ("platform adapter misconfigured: ...")
- the failed postResponse catch (the adapter's raw message)
- the no-adapter arm whose suggestedResponse write failed

So every place that writes it sees the bounded, storable text:
- escalationReason, and through it the owed escalationRequiredReason
  replacement and the queued ESCALATION payload reason
- responseError in the PENDING updateMany and in the settled update
- the operator summary log

The one other raw error written to the row gets the same treatment:
- responseError "Delivery succeeded but the DELIVERED state write failed:
  ..." on the deliveryConfirmed fallback write
Both layers that compose a handoff reason decided "is this blank?" and
"which fallback?" on the raw text, and only then cleaned it with
boundForStorage. A part made only of whitespace, U+0000 or lone
surrogates survived as blank text or a bare U+FFFD and blocked the
fallback, and a cut could leave trailing whitespace.

- ai/src/formatter.ts: new storableReason(parts, maxLength). Each part
  is made storable and trimmed, a part left with only whitespace and
  U+FFFD is dropped, the rest are joined with '; ', bounded with
  boundForStorage, and trimmed at the end. Returns '' when nothing
  usable remains. Exported from the ai index.
- ai/src/pipeline.ts: handoffReason composes the deterministic reasons
  and the model reasoning through storableReason(..., 2000), so a blank
  or unstorable reasoning drops out and the existing fallback strings
  apply when nothing remains.
- queue/src/handlers/ai-response.ts: repeatableReason takes parts and
  delegates to storableReason. withheldReason passes the groundedness
  findings as parts instead of joining them first, and is computed only
  on the suppressed path, so a published result is never asked for
  groundedness (a result without it threw a TypeError). The
  low-confidence arm's output is unchanged.

Tests:
- pipeline.test.ts: a whitespace, NUL-only or lone-surrogate-only
  reasoning leaves only the deterministic reasons on a forced
  escalation, and falls back to "Independent verification was
  unavailable or malformed" (failed investigation) or "... found
  insufficient support" (verified too low); a cut never ends in
  whitespace.
- ai-response.test.ts: several blank or unstorable-only findings give
  the default; a NUL-only or lone-surrogate-only handoff reason falls
  back on the withheld arm and adds nothing on the low-confidence arm;
  no trailing whitespace after a cut on either arm; a high-confidence
  result without groundedness publishes without throwing.
…ropped

The storable handoff-reason tests asserted only that the reason could be
stored, so a pipeline that dropped the diagnosis, stripped its bad units, or
fell back to its own reason passed them. They now pin the cleaned diagnosis,
with U+FFFD where each bad unit was, and reject the fallback and ''.

Adds String.prototype.isWellFormed as an oracle independent of the restated
production regex, a fractional-bound row for boundForStorage, and an
ill-formed reason on the published low-confidence arm. Relabels the
'past the bound' case, whose lone surrogate sits at index 0, and pins its
length to the bound.
…by rebuilding

Rebuilding the App strategy on every authenticated 401 was wrong against
the real SDK in two ways. It threw away a just-minted token GitHub was
still replicating, so each replacement was just as new. And it could not
keep the refused bearer out of a call that overlapped the refusal:
@octokit/auth-app@7.2.2 shares every pending read, cache hits included,
through one module-level map keyed by installation, repositories and
permissions, not by strategy and not by `refresh`
(dist-node/index.js:99-114, :167-180). A call made just after
invalidate() joined the old strategy's pending read and got the refused
bearer.

The resolver now keeps one strategy and lets the SDK refresh:

- invalidate(refusedAuthorization) acts only when the value is the
  bearer it last issued and that token is at least 5 s old by the SDK's
  createdAt (:246). That is the line auth-app's own 401 retry draws
  (:337 FIVE_SECONDS_IN_MS, :399-412). Inside the window the refusal is
  ignored and the next read reuses the token.
- Once it acts, the next read passes refresh: true, which skips the
  cache (:182). A read that still returns the refused bearer has joined
  a pending read. It retries once after that read settles, and a second
  refused answer fails as token_exchange_failed. A refused bearer is
  never returned.
- Any failed or malformed read makes the next read refresh too. A 2xx
  with no usable token is cached before it is returned (:259), and the
  next read no longer reads it back.
- Accepted tokens exclude `|`, which the SDK cache joins and splits on
  (:57-64, :89-96), and all whitespace and control characters. A
  createdAt that is present but not a time marks a split cache entry,
  so that token is rejected too.
- githubEvidenceHeaders requires a string of the form
  "<scheme> <credential>". It now rejects whitespace-only values, a
  bare scheme, and non-strings that would only pass a regex after
  being converted to text.
- InstallationTokenSource now says what arrives: token and createdAt
  are optional, and refresh is an option. The comment that @octokit/request
  returns "" for a non-JSON 2xx is corrected. @octokit/request@9.2.4
  getResponseData returns the text for text/* bodies and an ArrayBuffer
  for other types (dist-bundle/index.js:130-149).
- githubJson passes the refused Authorization value to invalidate, and
  only for a 401 to a request that carried one.

The new contract tests drive the real createAppAuth through the
production factory, fully offline. They use a synthetic RSA key
generated in the test and a stubbed fetch for the token endpoint. The
caching fake is retired: it asserted outcomes the real SDK cannot
produce. The new support-agent tests cover a 401 on read_source as well
as read_release, an authenticated 403, 404 or 500 (no invalidate), and
an anonymous 401 (no invalidate).

Call sites of changed symbols (paths under packages/outpost/ai/src):
- GitHubEvidenceAuth.invalidate: implemented by installationAuth
  (github-evidence-auth.ts:426), called by githubJson
  (support-agent.ts:149), exercised in github-evidence-auth.test.ts and
  support-agent.test.ts.
- githubEvidenceHeaders: githubJson (support-agent.ts:115), tests in
  github-evidence-auth.test.ts.
- githubJson: read_source (support-agent.ts:360, :379) and read_release
  (support-agent.ts:452).
- InstallationTokenSource / InstallationTokenFactory:
  appInstallationTokens (github-evidence-auth.ts:229),
  githubEvidenceAuthFromEnv (:263), the shared slot (:300),
  sharedInstallationAuth (:328), installationAuth (:352, :354), and the
  test fakes fakeAppAuth (github-evidence-auth.test.ts:23-28) and
  failingExchange (support-agent.test.ts:1314, :1418).
- installationAuth: sharedInstallationAuth (github-evidence-auth.ts:338).
- mintWithoutLeaking: installationAuth's read (github-evidence-auth.ts:363).
- INSTALLATION_TOKEN, AUTHORIZATION_VALUE, TOKEN_REPLICATION_LAG_MS,
  noUsableToken: module-private, used only within github-evidence-auth.ts.
…repeats

Comments only; no code or test logic changes.

- "Fails on every retry / every sweep" overclaimed. A retry re-runs the
  pipeline (generateSupportResponse) and can produce a different reason, and
  a refused Message.create rethrows in handleAiResponse and leaves no row, so
  the PENDING-only sweep (pending-response-sweep.ts) never sees the ticket.
  Comments now say the attempt fails before it posts, nothing is stored,
  nobody is summoned, and a retry that produces the same reason fails the
  same way.
- The V8 JSON.parse quote reaching the reasoning was stated unconditionally.
  In @openai/agents-core 0.18.0, processFinalOutputWithRedaction
  (dist/utils/finalOutputError.mjs) throws the fixed
  REDACTED_FINAL_OUTPUT_ERROR_MESSAGE whenever logging.dontLogModelData is
  true, which is the default (dist/config.mjs: isEnabled(flag, true)). Only
  with OPENAI_AGENTS_DONT_LOG_MODEL_DATA=false|0 or
  setSensitiveDataLoggingEnabled(true) does formatFinalOutputTypeError
  (dist/runner/turnResolution.mjs) quote error.message through
  truncateForDeveloper. The defence stays: the input can be ill-formed.
- MAX_REPEATED_HANDOFF_REASON said it "only binds if that bound ever moves".
  The groundedness findings a withheld draft falls back to are returned raw
  by the pipeline and bounded only by repeatableReason, so it is their sole
  bound.
- boundForStorage said a bound "that is not positive" yields ''. Math.floor
  runs first, so any maxLength below 1 (0.5 included) yields ''.
- The Prisma/Postgres rationale was copied into about ten comments and had
  drifted ("Prisma refuses U+0000", "Postgres refuses unpaired surrogates in
  text"). It is now stated once, in the boundForStorage doc, with the
  versions measured against a scratch cluster: Prisma 6.19.3's query engine
  refuses an ill-formed string parameter ("lone leading surrogate in hex
  escape"), and Postgres 17.11 refuses U+0000 in text (22021) and jsonb
  (22P05). Other sites point at boundForStorage.
A failed investigation and an investigator route both skip the verifier, yet a reason that cleaned to blank fell back to a verifier label. The fallback now names the investigation failure, the route, or the draft check that committed the handoff, and names the verifier only when nothing else did. The hostile corpus gains the route, enforced-lint and degraded-verifier arms and asserts each arm's fallback; it uses the shared isWellFormed oracle.
storableReason called part.replace() on every part, so a part that is not a
string at runtime threw a TypeError despite the declared string | undefined.
An Error whose message was overridden with a non-string is enough, and then
the queue handler's delivery-failure path escalated nothing on the spot. The
code before this round coerced it with a template literal.

Each part now goes through reasonText: null and undefined become '', anything
else goes through String(), and a conversion that throws (a throwing
toString, an object with no prototype) becomes '' as well. The signature
widens to readonly unknown[], which is what it now accepts.

The parts used to be joined with '; ' and then bounded, so a cut inside or
just after a separator left ';', '; ' or '; ' plus U+FFFD at the end. The parts
are now joined in order until the bound cuts one. The cut part keeps what
boundForStorage leaves of it, less trailing whitespace, unless only
whitespace and U+FFFD remain. In that case it is dropped with the separator
before it. The result is still the longest clean prefix of the whole join.

The boundForStorage doc now says what a refusal loses at each write. At
Message.create the classification is already written, so the reply row and
the handoff are lost. At the later storableErrorText writes it is the error
text on the row, or the escalation. The measured-versions sentence is
unchanged.

formatter.test.ts gets direct storableReason tests: per-part cleaning,
trimming, blank parts, mixed blank and usable parts, cuts inside the
separator and inside a surrogate pair, non-string parts, '' when nothing is
usable, and a sweep over every bound. The sweep checks the result against a
brute-force longest-clean-prefix oracle and checks idempotence. The tests
fail on the previous code, and on mutants that drop the per-part trim or the
per-part blank check.

storableReason call sites, all typechecked against the rebuilt ai/dist:
- ai/src/pipeline.ts: handoffReason, storableReason([...deterministicReasons,
  generatedResponse.reasoning], 2000)
- queue/src/handlers/ai-response.ts: repeatableReason(...parts)
- queue/src/handlers/ai-response.ts: storableErrorText, storableReason([message],
  MAX_STORED_ERROR_TEXT) and storableReason([`${label}: ${detail}`],
  MAX_STORED_ERROR_TEXT)
- ai/src/index.ts: re-export only
…asures

Pin three behaviours the review found unguarded: a refresh asked for by a refusal survives a read already under way, a source with no createdAt is aged from first hand-out, and an authenticated 401 logs exactly the constant line (nothing for an anonymous 401 or a 403/404).

Correct comments that overclaimed: the resolver uses auth-app's five seconds but measures at the report, not the send; the SDK's key errors are fixed strings, so the boundary exists because third-party text is untrusted; getResponseData's charset=utf-8 branch; the header and metadata remarks.

docs/support-agent.md no longer promises a fresh token after every 401.
Tests and comments only; the handler is unchanged.

- The corpus failed 27 delivery-failure cases under SHADOW_MODE=true. The
  restoreShadowMode block captured SHADOW_MODE when it was collected, which
  is before the file's beforeAll clears it, so its afterEach wrote the
  ambient value back for every block after it. The corpus now clears
  SHADOW_MODE in its own beforeEach and restores it after each test, and the
  restoreShadowMode block reads the value per test. Either change alone
  keeps the file green under SHADOW_MODE=true.
- Every corpus case now expects the exact reason, so a labelled delivery arm
  must say "no usable error message" after its label, and a case at a bound
  pins it: moving either bound to 1999, 1000 or 2001 fails the corpus.
- Each delivery site also runs on a reply that owed no handoff, which checks
  the responseError written by the plain update as well as the queued
  reason.
- New cases cut inside the '; ' separator, just after it, and on an
  unstorable unit after it, and mix blank parts with usable ones. The reason
  must not contain "(;" or "; )", or end its parenthetical with ";".
- The delivery-failure describe checked isWellFormed, LONE_SURROGATE and
  U+0000 on its own expected literal. It now checks the captured write and
  payload arguments.
- A failed post whose Error.message is a number, an object read through a
  getter, an object whose toString throws, or an object with no prototype
  still escalates at once with a storable reason. These fail when
  storableReason calls .replace() on a non-string part.
- Comments no longer say that nothing is stored when Message.create refuses
  a reason, that every delivery-failure reason lands in
  escalationRequiredReason before the post, or that the corpus covers every
  arm; recovery reasons are named as out of scope, and boundForStorage is
  cited for what a refusal loses. The bound pair is described as at the
  bound and one over.
@jerelvelarde

Copy link
Copy Markdown
Collaborator Author

All three are in, pushed as 59965af. Thanks for keeping the branch current with main while this sat — I'd done the work locally and never pushed it, which is the second time I've left you waiting on something that was already finished. Sorry.

The App permission

The docs now carry it in two places: step 3 of the cutover, and the App setup list (Issues: Read & write, Discussions: Read & write, Contents: Read-only, with a note that Metadata is automatic). There's also an explicit procedure for an App that already exists — an org owner or App manager sets Contents under Permissions & events, and the installation then has to accept the updated permissions, because GitHub issues no narrowed token until it does.

I could not answer your actual question. gh api /orgs/CopilotKit/installations needs admin:org and my token doesn't have it, so I can't see what the live App actually holds. That one still needs someone who can open the App's settings page. If it already has Contents, nothing to do beyond the docs; if it doesn't, the installation needs to accept the update before the OpenAI default deploys, or every evidence read fails from that moment.

Worth recording what the failure looks like so it's recognisable: the model's tool result reports auth_unavailable, and the worker logs [SupportAgent] GitHub evidence authentication unavailable: with category token_exchange_failed — the same category any failed exchange gets, so the log alone won't tell you it's a missing permission rather than a bad key.

The 2000-character cut

Fixed, and wider than you described. The guard isn't just "drop a trailing high surrogate" — the store refuses three things, so all three are handled: a high surrogate with no low one after it, a low surrogate with no high one before it (a reversed pair is two separate faults, not one), and U+0000. Each becomes U+FFFD before the string is bounded.

Two things your note led to that I wouldn't have found otherwise:

  • It isn't only the low-confidence reason. The withheld-response reason and the delivery-failure reason take the same path into the same jsonb payload, so all three are bounded and cleaned through one helper now.
  • Blankness has to be decided after cleaning, not before. A reason that is entirely ill-formed cleans to nothing, and the old order would have stored an empty string and summoned a human with no reason at all. There's now a named fallback for that case rather than silence.

The comment names Prisma 6.19.3's first write as the exact place it fails, so the next person doesn't have to rediscover which layer refuses it.

The per-ticket token

Done — sharedInstallationAuth resolves one resolver per process, so the token one investigation mints is the one the next reads back, and the in-flight-sharing comments are now true instead of aspirational.

Two things fell out of it that are worth knowing:

  • The resolver key can't contain |. @octokit/auth-app@7.2.2 caches tokens in a |-joined string, so a value containing one comes back truncated at the separator — a cached ghs_a|b returns as the bearer ghs_a. Excluded from the key rather than left to surface as a mystery 401.
  • A shared resolver has to honour refresh, or a token GitHub has already rejected gets pinned for the rest of the process. It now retires a refused token the way auth-app itself does instead of rebuilding the strategy.

The deployment preconditions

In the docs as a cutover step: OPENAI_API_KEY on worker and web, old claude-* overrides cleared, with the reason spelled out — validateConfig runs when a pipeline is built, not at boot, so an Anthropic-only deployment doesn't fail loudly. Every AI_RESPONSE job fails before a pending row exists, retries, dead-letters, and the reporter gets nothing with no handoff, while sentiment quietly degrades to neutral.

State

Branch merged up to your 8e031ee and pushed. Full repo suite fresh (not cached): 6462 tests across 178 files, 11/11 turbo tasks, exit 0. Repo-wide typecheck clean, 11/11.

One scheduling note, since you said "before this goes anywhere near production": outpost is in Jordan's Node advisory thread as an open question — seven of our eight images are node:20-alpine, which is EOL and stays vulnerable, and release-bot's node:22.20 is below the fixed 22.23.4. I've answered the exposure question separately (short version: no Express, no body-parser, nothing decompresses Brotli anywhere in outpost, so the pre-auth decoder path doesn't exist here — but the images still need bumping). Not this PR's problem, but it probably decides the deploy window.

@NathanTarbert NathanTarbert left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Jerel, this all looks great.

The shared resolver does what the comments now say. Every ticket in a process reads from the same token cache, and a new key or installation still gets a fresh one. The refresh handling is careful too. A late refusal on an old token can't throw away a newer one, and investigations that refresh at the same time share one exchange instead of piling up. Good catch on the | in auth-app's cache. That would have been a really confusing 401 to chase down.

The reason cleaning is more thorough than what I asked for, and I'm glad you followed it into the withheld and delivery-failure reasons. Deciding blankness after cleaning is the right call. A reason that cleans down to nothing still reaches a person with a named fallback, instead of an empty note. The deployment steps read clearly as well, especially the part about why an Anthropic-only deployment fails quietly rather than at boot.

On the App permission, I'll check the installation myself and make sure Contents is granted and accepted before the OpenAI default goes out. Thanks for writing down what the failure looks like in the logs. That will save whoever hits it some time.

One thing that's next door to this, and fine as a follow-up rather than here. The reply row created at ai-response.ts:988 stores the model's text as it came back, and so do suggestedResponse and the shadow SYSTEM row. That text never goes through the cleaner. It's parsed from JSON, so if the model copied a \u0000 or a lone surrogate out of a source it quoted, that message.create would fail. The reply and the handoff would both be lost, which is the same thing storableReason now prevents for the reasons. It was already like this before this PR, and I'd expect it to be rare. If you agree it's worth closing, running the response text through the same cleaner without the 2000 bound would cover it.

Approving. Thanks again for all the care on this one!

@NathanTarbert
NathanTarbert merged commit e953686 into main Oct 22, 2026
2 checks passed
@NathanTarbert
NathanTarbert deleted the jerel/luna-support-responses branch October 22, 2026 20:48
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.

2 participants