feat: add verified desktop ChatGPT subscription connection
This commit is contained in:
+49
-10
@@ -7,6 +7,10 @@ import {
|
||||
type TeachingAgent,
|
||||
} from "../agent/teacher-agent.ts";
|
||||
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
|
||||
import { chatGPTBackgroundAllowed, chatGPTScopeCurrent } from "../agent/chatgpt-bridge.ts";
|
||||
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
|
||||
import { ChatGPTRequestCycle, type ChatGPTDispatchLifecycle } from "../agent/chatgpt-request-cycle.ts";
|
||||
import { reconstructRequestCause } from "../core/native-messages.ts";
|
||||
import { createJobRunner } from "../agent/subagents/runner.ts";
|
||||
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||
import type { ModelRole } from "../../config.ts";
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
noteSteerVisible,
|
||||
reconcileUserSteers,
|
||||
registerLiveAgent,
|
||||
restoreUncommittedAutomatic,
|
||||
setJobRespawn,
|
||||
setTurnRunner,
|
||||
settleSteers,
|
||||
@@ -41,7 +46,7 @@ import { persistTurn, triggerExtraction } from "./turn-service.ts";
|
||||
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
|
||||
import { composeInstructions } from "../agent/teacher-agent.ts";
|
||||
import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.ts";
|
||||
import { catalogContextLimits } from "./model-catalog.ts";
|
||||
import { catalogContextLimits, refreshConnectionCatalog } from "./model-catalog.ts";
|
||||
import { emit } from "../core/events.ts";
|
||||
import { settledTurnResponse } from "./turn-runs.ts";
|
||||
import { asAgentMessages } from "../core/native-messages.ts";
|
||||
@@ -201,6 +206,12 @@ export async function handleChatInput(
|
||||
// persist (nothing durable changes); wake-pipeline turns ride the same
|
||||
// path. The affected tier is strong — the conversation chain's own tier
|
||||
// (a chat.model override still resolves through it).
|
||||
if (conversation.provider === "chatgpt") {
|
||||
await refreshConnectionCatalog();
|
||||
if (input.type === "steers" && !chatGPTBackgroundAllowed()) {
|
||||
return Response.json({ error: "Background use is off for this ChatGPT account. Automatic follow-up remains deferred in its queue." }, { status: 403 });
|
||||
}
|
||||
}
|
||||
if (!providerKeyPresent(conversation.provider)) {
|
||||
const envelope = providerKeyMissingEnvelope(conversation.provider, ["strong"]);
|
||||
console.info(`[chat ${chatId}] turn refused — ${JSON.stringify(envelope)}`);
|
||||
@@ -216,12 +227,22 @@ export async function handleChatInput(
|
||||
// placement mirrors the BYOK/key gates — BEFORE appends and
|
||||
// beginTurn, no liveness effects — and the record re-reads after
|
||||
// because compaction appends the marker.
|
||||
const conversationEngine = piEngineForSpec(conversation);
|
||||
const callPurpose = { background: input.type === "steers" || (input.type === "continue" && reconstructRequestCause(record.messages) !== "foreground") };
|
||||
let requestCycle: ChatGPTRequestCycle | undefined;
|
||||
const lifecycle: ChatGPTDispatchLifecycle = {
|
||||
captured: (scope) => requestCycle?.captured(scope),
|
||||
beforeDispatch: (scope) => requestCycle?.beforeDispatch(scope),
|
||||
noDispatch: (kind) => requestCycle?.noDispatch(kind),
|
||||
};
|
||||
let conversationEngine: PiEngine;
|
||||
try { conversationEngine = piEngineForSpec(conversation, () => callPurpose.background, lifecycle); }
|
||||
catch { return Response.json({ error: conversation.provider === "chatgpt" ? chatGPTStreamError("model catalog") : "The selected model is unavailable." }, { status: 503 }); }
|
||||
const modelIdForCompaction = `${conversationEngine.model.provider}:${conversationEngine.model.id}`;
|
||||
await ensureCompacted(
|
||||
{
|
||||
chatStore: stores.chatStore,
|
||||
engine: conversationEngine,
|
||||
engine: piEngineForSpec(conversation, true),
|
||||
canStart: () => conversation.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
|
||||
modelId: modelIdForCompaction,
|
||||
kind: record.kind === "practice" ? "practice" : hasProbeDone(record.messages) ? "teach" : "probe",
|
||||
thresholdTokens: effectiveThresholdTokens(
|
||||
@@ -287,12 +308,14 @@ export async function handleChatInput(
|
||||
// ride this same write-ahead - the pump de-queued them at fire, so this
|
||||
// is the ONLY copy ever appended.)
|
||||
const merged = [...record.messages, ...appends];
|
||||
const stagedAutomatic = conversation.provider === "chatgpt" && input.type === "steers";
|
||||
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
|
||||
try {
|
||||
stores.chatStore.saveMessages(chatId, merged);
|
||||
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
|
||||
// Dock fix (owner ruling 2026-09-26): persisted markers are visible
|
||||
// in the record — flip their jobs now (the wake's steers ride this
|
||||
// write-ahead; the delivering turn may run for minutes).
|
||||
for (const appended of appends) {
|
||||
for (const appended of stagedAutomatic ? [] : appends) {
|
||||
if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -339,8 +362,19 @@ export async function handleChatInput(
|
||||
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
|
||||
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
|
||||
// user: quiz/ask/fs/plan/probe_done are always-on).
|
||||
if (conversation.provider === "chatgpt") requestCycle = new ChatGPTRequestCycle({
|
||||
initialMessages: asAgentMessages(merged),
|
||||
staged: stagedAutomatic ? asAgentMessages(appends) : undefined,
|
||||
foregroundOwed: !callPurpose.background,
|
||||
purpose: callPurpose,
|
||||
scopeCurrent: chatGPTScopeCurrent,
|
||||
commitStaged: () => { stores.chatStore.saveMessages(chatId, merged); persistBaseCount = merged.length; },
|
||||
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
|
||||
});
|
||||
const agentOpts: AgentBuildOptions = {
|
||||
engine,
|
||||
callPurpose,
|
||||
requestCycle,
|
||||
messages: asAgentMessages(merged),
|
||||
onUsage: collectUsage,
|
||||
toolToggles: effective.agent.tools,
|
||||
@@ -439,6 +473,7 @@ export async function handleChatInput(
|
||||
providerEventNote(chatId, "turn", `run start (base: ${merged.length} messages)`);
|
||||
const handle = runPiTurn({
|
||||
agent: constructed,
|
||||
requestCycle,
|
||||
// The coalescing window seam (COMPACTION LAW, 2026-10-09): tests
|
||||
// tighten it via LH_UPDATE_FLUSH_MS; production rides the 100ms
|
||||
// default in ui-stream.
|
||||
@@ -454,13 +489,14 @@ export async function handleChatInput(
|
||||
// route through here: they ride the error-result path and reach the
|
||||
// transcript directly (the retry context the model saw).
|
||||
onError: (error) => {
|
||||
if (conversation.provider === "chatgpt") return chatGPTStreamError(String(error));
|
||||
console.error(`[chat ${chatId}] turn error:`, error);
|
||||
return "An error occurred.";
|
||||
},
|
||||
onFinish: (turnMessages, isAborted) => {
|
||||
// Wave B: the record write is the agent's own final state —
|
||||
// the new messages are the slice past the merged base.
|
||||
const stamped = persistTurn(stores.chatStore, chatId, merged.length, turnMessages, isAborted);
|
||||
const stamped = persistTurn(stores.chatStore, chatId, persistBaseCount, turnMessages, isAborted);
|
||||
// usage log append (idempotent per turn id) — the stats bar
|
||||
// refolds this on the client after the turn settles. The
|
||||
// provenance message is the turn's FIRST new assistant message.
|
||||
@@ -479,7 +515,7 @@ export async function handleChatInput(
|
||||
// PRACTICE (owner, 2026-10-04): practice sessions write nothing —
|
||||
// no extraction, no graph, no coverage (v1: the session itself
|
||||
// is the deliverable).
|
||||
if (record.kind !== "practice" && extractionEnabled(effective.graph)) {
|
||||
if ((!requestCycle || requestCycle.dispatched) && record.kind !== "practice" && extractionEnabled(effective.graph)) {
|
||||
// Fresh per-turn read of the graph section's chunk budget
|
||||
// (extraction-UX redesign, 2026-09-24): the trigger compares
|
||||
// against it and the pipeline's batches share it.
|
||||
@@ -520,18 +556,20 @@ export async function handleChatInput(
|
||||
// beneath-threshold record is a silent no-op. Skipped under
|
||||
// tests (the extraction precedent — scripted chats are
|
||||
// fixtures; the service itself is pinned directly).
|
||||
if (process.env.NODE_ENV !== "test")
|
||||
if (process.env.NODE_ENV !== "test" && (!requestCycle || requestCycle.dispatched))
|
||||
kickCompaction(() => {
|
||||
const fresh = effectiveSettings();
|
||||
const spec = resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, fresh.models.strong);
|
||||
if (!spec) return null;
|
||||
const kickEngine = piEngineForSpec(spec);
|
||||
if (!spec || (spec.provider === "chatgpt" && !chatGPTBackgroundAllowed())) return null;
|
||||
let kickEngine: PiEngine;
|
||||
try { kickEngine = piEngineForSpec(spec, true); } catch { return null; }
|
||||
const kickModelId = `${kickEngine.model.provider}:${kickEngine.model.id}`;
|
||||
const freshRecord = stores.chatStore.getChat(chatId);
|
||||
if (!freshRecord) return null;
|
||||
return {
|
||||
chatStore: stores.chatStore,
|
||||
engine: kickEngine,
|
||||
canStart: () => spec.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
|
||||
modelId: kickModelId,
|
||||
kind: freshRecord.kind === "practice" ? "practice" : hasProbeDone(freshRecord.messages) ? "teach" : "probe",
|
||||
thresholdTokens: effectiveThresholdTokens(
|
||||
@@ -558,6 +596,7 @@ export async function handleChatInput(
|
||||
// must cost zero permanently-held slots beyond the one live view. The
|
||||
// turn itself is a server-side process (ruling 2026-09-25) — it never
|
||||
// needed this connection, only a view did.
|
||||
if (stagedAutomatic && requestCycle && !(await requestCycle.admission)) return Response.json({ error: "Automatic follow-up remains deferred in its queue." }, { status: 403 });
|
||||
return Response.json({ ok: true });
|
||||
} catch (err) {
|
||||
// The stream never started, so no onFinish will fire — clear the registry
|
||||
|
||||
Reference in New Issue
Block a user