Repository navigation
feat(ai): migrate support responses to Luna and OpenAI Agents SDK - #283
Conversation
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.
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.
…to jerel/luna-support-responses
|
All three are in, pushed as The App permissionThe docs now carry it in two places: step 3 of the cutover, and the App setup list ( I could not answer your actual question. Worth recording what the failure looks like so it's recognisable: the model's tool result reports The 2000-character cutFixed, 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:
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 tokenDone — Two things fell out of it that are worth knowing:
The deployment preconditionsIn the docs as a cutover step: StateBranch merged up to your 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 |
NathanTarbert
left a comment
There was a problem hiding this comment.
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!
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:
The current amendment round adds five further concerns, each with its own red/green record:
auth_unavailableresult the model sees unchanged.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 headb09050e. 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.
headersmap, while the transport requires those headers underrequestInit. 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/runtimeversion.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
headersmap, while the transport requires those headers underrequestInit. 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/runtimeversion.Technical details and sources
The verified evidence supports the reported defect mechanism and correction:
MCPClientConfigSSEdocumentsheaders?: Record<string, string>.{ requestInit: { headers: serverConfig.headers } }, rather than passing the raw map as the second argument.For a checkout containing the current source, the relevant implementation is:
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/runtimeversion, 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
answer / partial / route, one summary paragraph up to 80 words, details, API generation, applicability, evidence quotes, and internal handoff reasonTicket 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=enforceenables 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(tree7baae56), eleven commits after the previously reviewed headc602d6c. Current PR headcb9f8dcadds 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 tob09050e, all other runtime source is unchanged, and both changed files pass a fresh formatting check. GitHub CI run 37772441929 and security run 37772442032 passed atcb9f8dc, including build, schema checks, formatting, lint, typechecks and tests.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 tosrc, so it exercises source rather than the compiled artifact.discord-mcpanddocswere deliberately skipped — neither depends on it.--checkon all 16 changed Prettier-supported files. The seventeenth,schema.prisma, is outside the Prettier glob and was checked withprisma validateinstead, 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.docs,discord-mcp) are outside it.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 atc602d6c, 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
c602d6cimage 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/qacheck remains tied to previewa4bf059, 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: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:
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.