(
message: string,
options?: SendMessageOptions & { fileParts?: FilePart[] },
internal?: {
synthetic?: boolean;
agentInitiated?: boolean;
goalContinuation?: boolean;
goalKind?: GoalSyntheticMessageKind;
startStreamInBackground?: boolean;
onAccepted?: () => Promise<void> | void;
onAcceptedPreStreamFailure?: (error: SendMessageError) => Promise<void> | void;
onCanceled?: (reason: string) => Promise<void> | void;
}
)
| 2283 | } |
| 2284 | |
| 2285 | async sendMessage( |
| 2286 | message: string, |
| 2287 | options?: SendMessageOptions & { fileParts?: FilePart[] }, |
| 2288 | internal?: { |
| 2289 | synthetic?: boolean; |
| 2290 | agentInitiated?: boolean; |
| 2291 | goalContinuation?: boolean; |
| 2292 | goalKind?: GoalSyntheticMessageKind; |
| 2293 | startStreamInBackground?: boolean; |
| 2294 | onAccepted?: () => Promise<void> | void; |
| 2295 | onAcceptedPreStreamFailure?: (error: SendMessageError) => Promise<void> | void; |
| 2296 | onCanceled?: (reason: string) => Promise<void> | void; |
| 2297 | } |
| 2298 | ): Promise<Result<void, SendMessageError>> { |
| 2299 | this.assertNotDisposed("sendMessage"); |
| 2300 | |
| 2301 | assert(typeof message === "string", "sendMessage requires a string message"); |
| 2302 | |
| 2303 | const isManualUserMessage = internal?.synthetic !== true; |
| 2304 | |
| 2305 | // Last-line-of-defence pricing gate: every dispatch path (initial sends, |
| 2306 | // sendQueuedMessages, dispatchPendingFollowUp, |
| 2307 | // post-compaction follow-ups) lands here, so a budgeted goal that became |
| 2308 | // resumable while a queued unpriced-model message waited cannot bypass |
| 2309 | // enforcement. The WorkspaceService-level gate already runs first for |
| 2310 | // initial calls (and prevents persisting bad AI settings), but it cannot |
| 2311 | // catch goal-state changes that happen between queueing and dispatch. |
| 2312 | // |
| 2313 | // When rejecting a manual (user-typed) send, we MUST persist the user's |
| 2314 | // message and surface a stream-error chat event before returning. The |
| 2315 | // queue-dispatch flow in `sendQueuedMessages()` removes the message from |
| 2316 | // the queue before calling us, so a silent `Err` here would drop the |
| 2317 | // user's input without any visible feedback (Codex P1 |
| 2318 | // PRRT_kwDOPxxmWM5_s-jo). For synthetic sends (compaction, goal |
| 2319 | // continuation, etc.) the user did not type the message, so we just |
| 2320 | // return Err and let the synthetic caller log/handle it. |
| 2321 | if (this.workspaceGoalService) { |
| 2322 | const pricingGate = await this.workspaceGoalService.assertPricedModelForBudgetedGoal( |
| 2323 | this.workspaceId, |
| 2324 | options?.model |
| 2325 | ); |
| 2326 | if (!pricingGate.success) { |
| 2327 | if (isManualUserMessage) { |
| 2328 | const persisted = await this.preserveRejectedManualSend( |
| 2329 | message, |
| 2330 | options, |
| 2331 | pricingGate.error |
| 2332 | ); |
| 2333 | // The user has explicitly intervened, so the goal-safety contract |
| 2334 | // for manual sends must still apply on the rejection path: clear any |
| 2335 | // pending acknowledgment gate AND auto-pause an active goal so a |
| 2336 | // pending post-stream-end continuation does not fire as if the user |
| 2337 | // had not interrupted (Codex P1 PRRT_kwDOPxxmWM5_tOFt). Only run the |
| 2338 | // hook when an actionable manual turn was actually present — empty |
| 2339 | // payloads (Codex P2 PRRT_kwDOPxxmWM5_tUsx) would otherwise silently |
| 2340 | // disable goal continuation after a blank submit / invalid payload. |
| 2341 | if (persisted) { |
| 2342 | await this.applyManualUserMessageGoalSafety({ policy: "pause" }); |
no test coverage detected