@jskit-ai/assistant-core 0.1.222 → 0.1.224
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/examples/conversation/package.json +1 -1
- package/package.json +1 -1
- package/src/client/conversation/AssistantGoalControl.vue +8 -8
- package/src/server/conversation/claudeHistory.js +9 -3
- package/src/server/conversation/claudeProcess.js +60 -20
- package/src/server/conversation/claudeTurn.js +190 -41
- package/src/server/conversation/codexProvider.js +67 -3
- package/src/server/conversation/providers/claude.js +5 -1
- package/src/server/conversation/providers/claudeDriver.js +5 -1
- package/src/server/conversation/providers/codex.js +325 -216
- package/src/server/conversation/providers/codexDriver.js +14 -1
- package/src/server/conversation/providers/scoped.js +39 -23
- package/src/server/conversation/runtime.js +20 -1
- package/src/server/conversation/transcript.js +24 -10
- package/src/shared/conversation/pagination.js +93 -2
- package/src/shared/conversation/patches.js +7 -4
- package/test/claudeConversationSession.test.js +3 -2
- package/test/claudeProcess.test.js +139 -1
- package/test/claudeTurn.test.js +253 -3
- package/test/codexConversationSession.test.js +27 -4
- package/test/codexGoal.test.js +152 -0
- package/test/conversation.test.js +62 -0
- package/test/conversationRuntime.test.js +168 -4
- package/test/conversationSession.test.js +48 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
3
|
|
|
3
4
|
const INSTRUCTION_REVISION_ENV = "JSKIT_CONVERSATION_INSTRUCTIONS";
|
|
4
5
|
|
|
@@ -10,6 +11,7 @@ export function createCodexConversationAdapter(host) {
|
|
|
10
11
|
const instructionPrompts = new Map();
|
|
11
12
|
const tasks = new Map();
|
|
12
13
|
const goalChanges = new Map();
|
|
14
|
+
const settingsUpdates = new Map();
|
|
13
15
|
const threadGenerations = new Map();
|
|
14
16
|
let contextGeneration = 0;
|
|
15
17
|
|
|
@@ -38,246 +40,300 @@ export function createCodexConversationAdapter(host) {
|
|
|
38
40
|
// A loaded native thread can ignore shell_environment_policy on resume.
|
|
39
41
|
// Detaching alone is insufficient when another subscriber retains it: prove
|
|
40
42
|
// the effective managed environment before recording a new binding.
|
|
41
|
-
async function withThreadContext(threadId, params, operation) {
|
|
43
|
+
async function withThreadContext(threadId, params, operation, settings) {
|
|
42
44
|
const threadGeneration = threadGenerations.get(threadId) || 0;
|
|
43
45
|
const previous = tasks.get(threadId) || Promise.resolve();
|
|
44
46
|
const task = previous.catch(() => null).then(() => host.runRequest(async () => {
|
|
45
|
-
const client = await host.client();
|
|
46
|
-
const generation = contextGeneration;
|
|
47
|
-
const bound = bindings.get(threadId);
|
|
48
|
-
const requestedParams = params;
|
|
49
|
-
const retainInstalledInstructions = bound?.params.developerInstructions != null && !params.ephemeral && !bound.params.ephemeral;
|
|
50
|
-
params = await prepareInstructions(retainInstalledInstructions
|
|
51
|
-
? { ...params, systemPrompt: undefined, developerInstructions: bound.params.developerInstructions }
|
|
52
|
-
: params, threadId);
|
|
53
|
-
let signal = AbortSignal.timeout(host.timeoutMs || 10_000);
|
|
54
|
-
const controlDeadline = performance.now() + (host.timeoutMs || 10_000);
|
|
55
|
-
const assertRecoveryOpen = () => {
|
|
56
|
-
signal.throwIfAborted();
|
|
57
|
-
if (generation !== contextGeneration || threadGeneration !== (threadGenerations.get(threadId) || 0)) {
|
|
58
|
-
throw new Error("The assistant connection closed during recovery.");
|
|
59
|
-
}
|
|
60
|
-
};
|
|
61
|
-
const request = (method, input) => {
|
|
62
|
-
assertRecoveryOpen();
|
|
63
|
-
return client.request(method, input, { signal });
|
|
64
|
-
};
|
|
65
|
-
const fixedPolicy = params.config?.shell_environment_policy ||
|
|
66
|
-
(bound?.managed === false ? bound.params.config.shell_environment_policy : null);
|
|
67
|
-
if (fixedPolicy && (params.developerInstructions == null || params.ephemeral || bound?.params.ephemeral)) {
|
|
68
|
-
if (bound && params.developerInstructions != null && instructionPrompts.get(threadId) !== params.developerInstructions) {
|
|
69
|
-
throw new Error("Start a new ephemeral conversation to change its instructions.");
|
|
70
|
-
}
|
|
71
|
-
assertRecoveryOpen();
|
|
72
|
-
return operation(threadParameters({ ...bound?.params, ...params }, {}));
|
|
73
|
-
}
|
|
74
|
-
let environment = instructionEnvironment(params, fixedPolicy?.set || await host.prepareEnvironment(
|
|
75
|
-
bound?.params?.config?.shell_environment_policy?.set || host.environment || {}
|
|
76
|
-
));
|
|
77
|
-
const previousConfig = { ...bound?.params?.config };
|
|
78
|
-
if (params.modelProvider) {
|
|
79
|
-
for (const key of Object.keys(previousConfig)) {
|
|
80
|
-
if (key === "model_providers" || key === "model_catalog_json" || key === "web_search" || key === "openai_base_url" || key.startsWith("model_providers.")) delete previousConfig[key];
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
let requestParams = {
|
|
84
|
-
...bound?.params,
|
|
85
|
-
...params,
|
|
86
|
-
config: {
|
|
87
|
-
...previousConfig,
|
|
88
|
-
...params?.config,
|
|
89
|
-
shell_environment_policy: { ...(fixedPolicy || { inherit: "none" }), set: environment }
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
let nativeModelProvider = "";
|
|
93
|
-
let nativeHistoryPath = "";
|
|
94
|
-
const readStatus = async () => {
|
|
95
|
-
const { thread } = await request("thread/read", { threadId, includeTurns: false });
|
|
96
|
-
if (thread?.historyMode !== "paginated") {
|
|
97
|
-
throw Object.assign(new Error("This conversation uses an unsupported Codex history format. Renew the session to keep your files and saved chat and start a fresh conversation. You can write the handover yourself."), {
|
|
98
|
-
code: "assistant_codex_history_unsupported"
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
nativeModelProvider = thread?.modelProvider || "";
|
|
102
|
-
nativeHistoryPath = thread?.path || "";
|
|
103
|
-
return typeof thread?.status === "string" ? thread.status : thread?.status?.type;
|
|
104
|
-
};
|
|
105
|
-
let nativeStatus = await readStatus();
|
|
106
|
-
if (!bound && !requestParams.modelProvider && nativeModelProvider) {
|
|
107
|
-
// A restored observer has no saved request parameters. Recover the
|
|
108
|
-
// native thread's provider configuration before resuming its controls.
|
|
109
|
-
requestParams = { ...requestParams, modelProvider: nativeModelProvider };
|
|
110
|
-
requestParams = await host.prepareParameters?.(requestParams) || requestParams;
|
|
111
|
-
}
|
|
112
|
-
requestParams = await host.prepareHistory(requestParams, client, { signal, historyPath: nativeHistoryPath });
|
|
113
|
-
const providerChanged = Boolean(requestParams.modelProvider && nativeModelProvider &&
|
|
114
|
-
requestParams.modelProvider !== nativeModelProvider);
|
|
115
|
-
if (providerChanged) {
|
|
116
|
-
const { goal } = await request("thread/goal/get", { threadId });
|
|
117
|
-
if (!["idle", "notLoaded"].includes(nativeStatus) || goal && !["complete", "completed"].includes(goal.status)) {
|
|
118
|
-
throw Object.assign(new Error("Stop the current turn and finish or clear its goal before changing model providers."), {
|
|
119
|
-
code: "assistant_codex_provider_switch_busy"
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
const executionId = String(host.runtime()?.executionId || "");
|
|
124
|
-
// Refresh project hook trust only for a cold thread in a replacement
|
|
125
|
-
// process. A new socket or a changed command environment is not evidence
|
|
126
|
-
// that the provider process died.
|
|
127
|
-
if (nativeStatus === "notLoaded" && executionId && bound?.executionId !== executionId) {
|
|
128
|
-
requestParams = await host.prepareResume?.(threadId, requestParams, {
|
|
129
|
-
runtime: host.runtime(),
|
|
130
|
-
processChanged: Boolean(bound?.executionId)
|
|
131
|
-
}) || requestParams;
|
|
132
|
-
}
|
|
133
|
-
let bindingMatches = !providerChanged && bound?.client === client &&
|
|
134
|
-
bound.params.developerInstructions === requestParams.developerInstructions &&
|
|
135
|
-
bound.params.config.openai_base_url === requestParams.config.openai_base_url &&
|
|
136
|
-
JSON.stringify(bound.params.config.model_providers) === JSON.stringify(requestParams.config.model_providers) &&
|
|
137
|
-
Object.keys(requestParams.config).filter((key) => key.startsWith("model_providers.")).every((key) =>
|
|
138
|
-
JSON.stringify(bound.params.config[key]) === JSON.stringify(requestParams.config[key])) &&
|
|
139
|
-
JSON.stringify(bound.params.config.shell_environment_policy) === JSON.stringify(requestParams.config.shell_environment_policy);
|
|
140
|
-
const controlBindingMatches = bindingMatches;
|
|
141
|
-
const installedRequestParams = requestParams;
|
|
142
|
-
if (retainInstalledInstructions && (!bindingMatches || nativeStatus !== "active")) {
|
|
143
|
-
// An active healthy turn keeps its installed prompt. Real control
|
|
144
|
-
// recovery and the next idle admission still read current instructions.
|
|
145
|
-
assertRecoveryOpen();
|
|
146
|
-
const remainingControlMs = Math.floor(controlDeadline - performance.now());
|
|
147
|
-
if (remainingControlMs <= 0) {
|
|
148
|
-
throw new DOMException("The operation was aborted due to timeout", "TimeoutError");
|
|
149
|
-
}
|
|
150
|
-
// Instruction composition preceded the control clock originally. Only
|
|
151
|
-
// this deferred host read is excluded; spent native time is retained.
|
|
152
|
-
params = await prepareInstructions(requestedParams, threadId);
|
|
153
|
-
signal = AbortSignal.timeout(remainingControlMs);
|
|
154
|
-
environment = instructionEnvironment(params, environment);
|
|
155
|
-
requestParams = { ...requestParams,
|
|
156
|
-
...(Object.hasOwn(params, "developerInstructions") ? { developerInstructions: params.developerInstructions } : {}),
|
|
157
|
-
config: { ...requestParams.config, shell_environment_policy: { ...requestParams.config.shell_environment_policy, set: environment } }
|
|
158
|
-
};
|
|
159
|
-
bindingMatches = bindingMatches && bound.params.developerInstructions === requestParams.developerInstructions &&
|
|
160
|
-
JSON.stringify(bound.params.config.shell_environment_policy) === JSON.stringify(requestParams.config.shell_environment_policy);
|
|
161
|
-
}
|
|
162
|
-
const instructionOnly = retainInstalledInstructions && controlBindingMatches && !bindingMatches;
|
|
163
|
-
let instructionsChanged = requestParams.developerInstructions != null &&
|
|
164
|
-
instructionPrompts.get(threadId) !== requestParams.developerInstructions;
|
|
165
47
|
let pausedGoal = null;
|
|
166
|
-
let resumed = null;
|
|
167
48
|
const goalChange = goalChanges.get(threadId);
|
|
168
49
|
const isSamePausedGoal = (goal, original) => goal?.status === "paused" &&
|
|
169
50
|
goal.createdAt === original.createdAt && goal.objective === original.objective;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
if (!bindingMatches || nativeStatus === "notLoaded") {
|
|
184
|
-
log("reload_started", { reason: bound ? "control-environment-changed" : "unverified-thread-binding", nativeStatus });
|
|
185
|
-
if (nativeStatus !== "notLoaded") {
|
|
51
|
+
async function apply() {
|
|
52
|
+
const client = await host.client();
|
|
53
|
+
const generation = contextGeneration;
|
|
54
|
+
const bound = bindings.get(threadId);
|
|
55
|
+
let requestedSettings;
|
|
56
|
+
if (settings) {
|
|
57
|
+
settings.controller.signal.throwIfAborted();
|
|
58
|
+
settings.client = client;
|
|
59
|
+
if (!settings.prepared) {
|
|
60
|
+
const request = (method, input) => client.request(method, input, {
|
|
61
|
+
signal: AbortSignal.any([settings.controller.signal, AbortSignal.timeout(host.timeoutMs || 10_000)])
|
|
62
|
+
});
|
|
186
63
|
const { goal } = await request("thread/goal/get", { threadId });
|
|
187
64
|
if (goal?.status === "active") {
|
|
65
|
+
settings.goal = goal;
|
|
188
66
|
const result = await request("thread/goal/set", { threadId, status: "paused" });
|
|
189
67
|
pausedGoal = result.goal;
|
|
190
|
-
if (!isSamePausedGoal(pausedGoal, goal))
|
|
191
|
-
|
|
192
|
-
}
|
|
68
|
+
if (!isSamePausedGoal(pausedGoal, goal)) throw new Error("Codex did not confirm the goal's scheduling pause.");
|
|
69
|
+
settings.goal = pausedGoal;
|
|
193
70
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const turnId = page.data?.[0]?.id;
|
|
204
|
-
if (!turnId) throw new Error("Codex's running turn could not be identified for control recovery.");
|
|
205
|
-
assertRecoveryOpen();
|
|
206
|
-
host.recordInterruption?.(threadId, turnId);
|
|
207
|
-
await request("turn/interrupt", { threadId, turnId });
|
|
208
|
-
while (await readStatus() !== "idle") {
|
|
209
|
-
signal.throwIfAborted();
|
|
210
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
211
|
-
}
|
|
71
|
+
for (;;) {
|
|
72
|
+
const { thread } = await request("thread/read", { threadId, includeTurns: false });
|
|
73
|
+
const status = typeof thread?.status === "string" ? thread.status : thread?.status?.type;
|
|
74
|
+
if (["idle", "notLoaded"].includes(status)) break;
|
|
75
|
+
if (status !== "active") throw new Error("Codex's current turn could not be observed before changing settings.");
|
|
76
|
+
if (!settings.deferred) {
|
|
77
|
+
settings.deferred = true;
|
|
78
|
+
try { host.log?.("settings_deferred", {}, { threadId }); } catch { /* Diagnostics do not own scheduling. */ }
|
|
79
|
+
settings.accepted.resolve({ ok: true, pending: true });
|
|
212
80
|
}
|
|
81
|
+
await delay(250, undefined, { signal: settings.controller.signal });
|
|
213
82
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
83
|
+
settings.prepared = true;
|
|
84
|
+
}
|
|
85
|
+
requestedSettings = settings.params;
|
|
86
|
+
params = await host.prepareParameters?.(requestedSettings) || requestedSettings;
|
|
87
|
+
}
|
|
88
|
+
const requestedParams = params;
|
|
89
|
+
const retainInstalledInstructions = bound?.params.developerInstructions != null && !params.ephemeral && !bound.params.ephemeral;
|
|
90
|
+
params = await prepareInstructions(retainInstalledInstructions
|
|
91
|
+
? { ...params, systemPrompt: undefined, developerInstructions: bound.params.developerInstructions }
|
|
92
|
+
: params, threadId);
|
|
93
|
+
let signal = AbortSignal.timeout(host.timeoutMs || 10_000);
|
|
94
|
+
const controlDeadline = performance.now() + (host.timeoutMs || 10_000);
|
|
95
|
+
const assertRecoveryOpen = () => {
|
|
96
|
+
signal.throwIfAborted();
|
|
97
|
+
settings?.controller.signal.throwIfAborted();
|
|
98
|
+
if (generation !== contextGeneration || threadGeneration !== (threadGenerations.get(threadId) || 0)) {
|
|
99
|
+
throw new Error("The assistant connection closed during recovery.");
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const request = (method, input) => {
|
|
103
|
+
assertRecoveryOpen();
|
|
104
|
+
return client.request(method, input, { signal });
|
|
105
|
+
};
|
|
106
|
+
const fixedPolicy = params.config?.shell_environment_policy ||
|
|
107
|
+
(bound?.managed === false ? bound.params.config.shell_environment_policy : null);
|
|
108
|
+
if (!settings && fixedPolicy && (params.developerInstructions == null || params.ephemeral || bound?.params.ephemeral)) {
|
|
109
|
+
if (bound && params.developerInstructions != null && instructionPrompts.get(threadId) !== params.developerInstructions) {
|
|
110
|
+
throw new Error("Start a new ephemeral conversation to change its instructions.");
|
|
111
|
+
}
|
|
112
|
+
assertRecoveryOpen();
|
|
113
|
+
return operation(threadParameters({ ...bound?.params, ...params }, {}));
|
|
114
|
+
}
|
|
115
|
+
let environment = instructionEnvironment(params, fixedPolicy?.set || await host.prepareEnvironment(
|
|
116
|
+
bound?.params?.config?.shell_environment_policy?.set || host.environment || {}
|
|
117
|
+
));
|
|
118
|
+
const previousConfig = { ...bound?.params?.config };
|
|
119
|
+
if (params.modelProvider) {
|
|
120
|
+
for (const key of Object.keys(previousConfig)) {
|
|
121
|
+
if (key === "model_providers" || key === "model_catalog_json" || key === "web_search" || key === "openai_base_url" || key.startsWith("model_providers.")) delete previousConfig[key];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
let requestParams = {
|
|
125
|
+
...bound?.params,
|
|
126
|
+
...params,
|
|
127
|
+
config: {
|
|
128
|
+
...previousConfig,
|
|
129
|
+
...params?.config,
|
|
130
|
+
shell_environment_policy: { ...(fixedPolicy || { inherit: "none" }), set: environment }
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
let nativeModelProvider = "";
|
|
134
|
+
let nativeHistoryPath = "";
|
|
135
|
+
const readStatus = async () => {
|
|
136
|
+
const { thread } = await request("thread/read", { threadId, includeTurns: false });
|
|
137
|
+
if (thread?.historyMode !== "paginated") {
|
|
138
|
+
throw Object.assign(new Error("This conversation uses an unsupported Codex history format. Renew the session to keep your files and saved chat and start a fresh conversation. You can write the handover yourself."), {
|
|
139
|
+
code: "assistant_codex_history_unsupported"
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
nativeModelProvider = thread?.modelProvider || "";
|
|
143
|
+
nativeHistoryPath = thread?.path || "";
|
|
144
|
+
return typeof thread?.status === "string" ? thread.status : thread?.status?.type;
|
|
145
|
+
};
|
|
146
|
+
let nativeStatus = await readStatus();
|
|
147
|
+
if (settings && nativeStatus === "active") {
|
|
148
|
+
settings.prepared = false;
|
|
149
|
+
return apply();
|
|
150
|
+
}
|
|
151
|
+
if (!bound && !requestParams.modelProvider && nativeModelProvider) {
|
|
152
|
+
// A restored observer has no saved request parameters. Recover the
|
|
153
|
+
// native thread's provider configuration before resuming its controls.
|
|
154
|
+
requestParams = { ...requestParams, modelProvider: nativeModelProvider };
|
|
155
|
+
requestParams = await host.prepareParameters?.(requestParams) || requestParams;
|
|
156
|
+
}
|
|
157
|
+
requestParams = await host.prepareHistory(requestParams, client, { signal, historyPath: nativeHistoryPath });
|
|
158
|
+
const providerChanged = Boolean(requestParams.modelProvider && nativeModelProvider &&
|
|
159
|
+
requestParams.modelProvider !== nativeModelProvider);
|
|
160
|
+
if (providerChanged) {
|
|
161
|
+
if (!["idle", "notLoaded"].includes(nativeStatus)) {
|
|
162
|
+
throw Object.assign(new Error("Stop the current turn before immediately changing model providers."), {
|
|
163
|
+
code: "assistant_codex_provider_switch_busy"
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const executionId = String(host.runtime()?.executionId || "");
|
|
168
|
+
// Refresh project hook trust only for a cold thread in a replacement
|
|
169
|
+
// process. A new socket or a changed command environment is not evidence
|
|
170
|
+
// that the provider process died.
|
|
171
|
+
if (nativeStatus === "notLoaded" && executionId && bound?.executionId !== executionId) {
|
|
172
|
+
requestParams = await host.prepareResume?.(threadId, requestParams, {
|
|
173
|
+
runtime: host.runtime(),
|
|
174
|
+
processChanged: Boolean(bound?.executionId)
|
|
175
|
+
}) || requestParams;
|
|
176
|
+
}
|
|
177
|
+
let bindingMatches = !providerChanged && bound?.client === client &&
|
|
178
|
+
bound.params.developerInstructions === requestParams.developerInstructions &&
|
|
179
|
+
bound.params.config.openai_base_url === requestParams.config.openai_base_url &&
|
|
180
|
+
JSON.stringify(bound.params.config.model_providers) === JSON.stringify(requestParams.config.model_providers) &&
|
|
181
|
+
Object.keys(requestParams.config).filter((key) => key.startsWith("model_providers.")).every((key) =>
|
|
182
|
+
JSON.stringify(bound.params.config[key]) === JSON.stringify(requestParams.config[key])) &&
|
|
183
|
+
JSON.stringify(bound.params.config.shell_environment_policy) === JSON.stringify(requestParams.config.shell_environment_policy);
|
|
184
|
+
const controlBindingMatches = bindingMatches;
|
|
185
|
+
const installedRequestParams = requestParams;
|
|
186
|
+
if (retainInstalledInstructions && (!bindingMatches || nativeStatus !== "active")) {
|
|
187
|
+
// An active healthy turn keeps its installed prompt. Real control
|
|
188
|
+
// recovery and the next idle admission still read current instructions.
|
|
189
|
+
assertRecoveryOpen();
|
|
190
|
+
const remainingControlMs = Math.floor(controlDeadline - performance.now());
|
|
191
|
+
if (remainingControlMs <= 0) {
|
|
192
|
+
throw new DOMException("The operation was aborted due to timeout", "TimeoutError");
|
|
193
|
+
}
|
|
194
|
+
// Instruction composition preceded the control clock originally. Only
|
|
195
|
+
// this deferred host read is excluded; spent native time is retained.
|
|
196
|
+
params = await prepareInstructions(requestedParams, threadId);
|
|
197
|
+
signal = AbortSignal.timeout(remainingControlMs);
|
|
198
|
+
environment = instructionEnvironment(params, environment);
|
|
199
|
+
requestParams = { ...requestParams,
|
|
200
|
+
...(Object.hasOwn(params, "developerInstructions") ? { developerInstructions: params.developerInstructions } : {}),
|
|
201
|
+
config: { ...requestParams.config, shell_environment_policy: { ...requestParams.config.shell_environment_policy, set: environment } }
|
|
202
|
+
};
|
|
203
|
+
bindingMatches = bindingMatches && bound.params.developerInstructions === requestParams.developerInstructions &&
|
|
204
|
+
JSON.stringify(bound.params.config.shell_environment_policy) === JSON.stringify(requestParams.config.shell_environment_policy);
|
|
205
|
+
}
|
|
206
|
+
const instructionOnly = retainInstalledInstructions && controlBindingMatches && !bindingMatches;
|
|
207
|
+
let instructionsChanged = requestParams.developerInstructions != null &&
|
|
208
|
+
instructionPrompts.get(threadId) !== requestParams.developerInstructions;
|
|
209
|
+
let resumed = null;
|
|
210
|
+
const log = (event, fields = {}) => host.log?.(event, fields, { threadId, environment });
|
|
211
|
+
try {
|
|
212
|
+
assertRecoveryOpen();
|
|
213
|
+
if (instructionOnly) {
|
|
214
|
+
// A native goal can start work while the host reads its prompt.
|
|
215
|
+
nativeStatus = await readStatus();
|
|
216
|
+
if (nativeStatus === "active") {
|
|
217
|
+
requestParams = installedRequestParams;
|
|
218
|
+
environment = requestParams.config.shell_environment_policy.set;
|
|
219
|
+
bindingMatches = true;
|
|
220
|
+
instructionsChanged = false;
|
|
223
221
|
}
|
|
224
222
|
}
|
|
225
223
|
if (!bindingMatches || nativeStatus === "notLoaded") {
|
|
226
|
-
|
|
227
|
-
await host.beforeResume?.(threadId);
|
|
228
|
-
resumed = await request("thread/resume", { excludeTurns: true, ...requestParams, threadId });
|
|
229
|
-
if (requestParams.modelProvider && resumed.modelProvider !== requestParams.modelProvider) {
|
|
230
|
-
throw Object.assign(new Error("Another native subscriber retained the previous model provider. Close the native assistant terminal and retry."), {
|
|
231
|
-
code: "assistant_codex_provider_binding_retained"
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
if (!["idle", "active"].includes(await readStatus())) throw new Error("Codex did not confirm the thread loaded.");
|
|
224
|
+
log("reload_started", { reason: bound ? "control-environment-changed" : "unverified-thread-binding", nativeStatus });
|
|
235
225
|
if (nativeStatus !== "notLoaded") {
|
|
236
|
-
await
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
226
|
+
const { goal } = await request("thread/goal/get", { threadId });
|
|
227
|
+
if (goal?.status === "active") {
|
|
228
|
+
const result = await request("thread/goal/set", { threadId, status: "paused" });
|
|
229
|
+
pausedGoal = result.goal;
|
|
230
|
+
if (!isSamePausedGoal(pausedGoal, goal)) {
|
|
231
|
+
throw new Error("Codex did not confirm that the same goal stopped scheduling work.");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (await readStatus() !== "idle") {
|
|
235
|
+
if (settings) {
|
|
236
|
+
settings.prepared = false;
|
|
237
|
+
return apply();
|
|
238
|
+
} else if (instructionOnly) {
|
|
239
|
+
// Never interrupt work solely to refresh its instructions.
|
|
240
|
+
requestParams = installedRequestParams;
|
|
241
|
+
environment = requestParams.config.shell_environment_policy.set;
|
|
242
|
+
bindingMatches = true;
|
|
243
|
+
instructionsChanged = false;
|
|
244
|
+
} else {
|
|
245
|
+
const page = await request("thread/turns/list", { threadId, limit: 1, sortDirection: "desc", itemsView: "summary" });
|
|
246
|
+
const turnId = page.data?.[0]?.id;
|
|
247
|
+
if (!turnId) throw new Error("Codex's running turn could not be identified for control recovery.");
|
|
248
|
+
assertRecoveryOpen();
|
|
249
|
+
host.recordInterruption?.(threadId, turnId);
|
|
250
|
+
await request("turn/interrupt", { threadId, turnId });
|
|
251
|
+
while (await readStatus() !== "idle") {
|
|
252
|
+
signal.throwIfAborted();
|
|
253
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (pausedGoal) {
|
|
258
|
+
const { goal } = await request("thread/goal/get", { threadId });
|
|
259
|
+
pausedGoal = isSamePausedGoal(goal, pausedGoal) ? goal : null;
|
|
260
|
+
}
|
|
261
|
+
if (!bindingMatches) {
|
|
262
|
+
const detached = await request("thread/unsubscribe", { threadId });
|
|
263
|
+
if (!["unsubscribed", "notSubscribed", "notLoaded"].includes(detached?.status)) {
|
|
264
|
+
throw new Error("Codex did not confirm detachment before control recovery.");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
243
267
|
}
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
268
|
+
if (!bindingMatches || nativeStatus === "notLoaded") {
|
|
269
|
+
bindings.delete(threadId);
|
|
270
|
+
await host.beforeResume?.(threadId);
|
|
271
|
+
resumed = await request("thread/resume", { excludeTurns: true, ...requestParams, threadId });
|
|
272
|
+
if (requestParams.modelProvider && resumed.modelProvider !== requestParams.modelProvider) {
|
|
273
|
+
throw Object.assign(new Error("Another native subscriber retained the previous model provider. Close the native assistant terminal and retry."), {
|
|
274
|
+
code: "assistant_codex_provider_binding_retained"
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
if (!["idle", "active"].includes(await readStatus())) throw new Error("Codex did not confirm the thread loaded.");
|
|
278
|
+
if (nativeStatus !== "notLoaded") {
|
|
279
|
+
await host.verifyEnvironment(threadId, environment, request, signal, {
|
|
280
|
+
requiredKeys: requestParams.developerInstructions == null ? [] : [INSTRUCTION_REVISION_ENV]
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
const checked = fixedPolicy ? environment : instructionEnvironment(params, await host.prepareEnvironment(environment));
|
|
284
|
+
if (JSON.stringify(checked) !== JSON.stringify(environment)) {
|
|
285
|
+
throw new Error("Managed controls changed again during thread recovery.");
|
|
286
|
+
}
|
|
287
|
+
if (instructionsChanged) {
|
|
288
|
+
// Resume config supplies the next compacted context, but existing
|
|
289
|
+
// history retains its original developer message. A revision in
|
|
290
|
+
// the developer lane makes this change effective before new work.
|
|
291
|
+
// An unknown binding also needs restoration; ordinary turns and
|
|
292
|
+
// socket reconnects with an acknowledged binding do not repeat it.
|
|
293
|
+
await request("thread/inject_items", { threadId, items: [{ type: "message", role: "developer", content: [{
|
|
294
|
+
type: "input_text", text: "These are the current application instructions. They replace earlier application instructions; other instructions remain in effect.\n\n" + requestParams.developerInstructions
|
|
295
|
+
}] }] });
|
|
296
|
+
assertRecoveryOpen();
|
|
297
|
+
instructionPrompts.set(threadId, requestParams.developerInstructions);
|
|
298
|
+
}
|
|
253
299
|
assertRecoveryOpen();
|
|
254
|
-
|
|
300
|
+
bindings.set(threadId, { client, executionId, params: requestParams, managed: !fixedPolicy });
|
|
301
|
+
log("ready");
|
|
255
302
|
}
|
|
256
|
-
assertRecoveryOpen();
|
|
257
|
-
bindings.set(threadId, { client, executionId, params: requestParams, managed: !fixedPolicy });
|
|
258
|
-
log("ready");
|
|
259
303
|
}
|
|
304
|
+
} catch (cause) {
|
|
305
|
+
host.onRecoveryFailure?.(threadId);
|
|
306
|
+
bindings.delete(threadId);
|
|
307
|
+
log("recovery_failed", { code: cause?.code || "", reason: cause.message });
|
|
308
|
+
throw Object.assign(new Error(["assistant_agent_control_binding_retained", "assistant_codex_provider_binding_retained"].includes(cause?.code)
|
|
309
|
+
? `${cause.message} Your conversation and work are preserved.`
|
|
310
|
+
: "The assistant connection could not be verified. Your conversation and files are preserved. Retry the connection, or renew the session to continue in a fresh conversation.", { cause }), {
|
|
311
|
+
code: "assistant_agent_control_recovery_failed", retryable: true
|
|
312
|
+
});
|
|
260
313
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const result = await operation(requestParams, resumed);
|
|
274
|
-
if (pausedGoal && goalChanges.get(threadId) === goalChange) {
|
|
275
|
-
const { goal } = await request("thread/goal/get", { threadId });
|
|
276
|
-
if (isSamePausedGoal(goal, pausedGoal) && goal.updatedAt === pausedGoal.updatedAt) {
|
|
277
|
-
await request("thread/goal/set", { threadId, status: "active" });
|
|
314
|
+
// A newer settings save supersedes the older choice before scheduling
|
|
315
|
+
// another turn. Ordinary caller operations are never retried.
|
|
316
|
+
assertRecoveryOpen();
|
|
317
|
+
if (settings && settings.params !== requestedSettings) return apply();
|
|
318
|
+
const result = await operation(requestParams, resumed);
|
|
319
|
+
if (settings && settings.params !== requestedSettings) return apply();
|
|
320
|
+
if (pausedGoal && goalChanges.get(threadId) === goalChange) {
|
|
321
|
+
const { goal } = await request("thread/goal/get", { threadId });
|
|
322
|
+
if (settings && settings.params !== requestedSettings) return apply();
|
|
323
|
+
if (goalChanges.get(threadId) === goalChange && isSamePausedGoal(goal, pausedGoal) && goal.updatedAt === pausedGoal.updatedAt) {
|
|
324
|
+
await request("thread/goal/set", { threadId, status: "active" });
|
|
325
|
+
}
|
|
278
326
|
}
|
|
327
|
+
if (settings && settings.params !== requestedSettings) {
|
|
328
|
+
settings.prepared = false;
|
|
329
|
+
return apply();
|
|
330
|
+
}
|
|
331
|
+
// Retire the completed update before yielding to its caller, so a new
|
|
332
|
+
// save cannot attach itself to an operation that has already finished.
|
|
333
|
+
if (settings && settingsUpdates.get(threadId) === settings) settingsUpdates.delete(threadId);
|
|
334
|
+
return result;
|
|
279
335
|
}
|
|
280
|
-
return
|
|
336
|
+
return apply();
|
|
281
337
|
}, "codex-app-server-thread-controls"));
|
|
282
338
|
tasks.set(threadId, task);
|
|
283
339
|
try {
|
|
@@ -287,7 +343,52 @@ export function createCodexConversationAdapter(host) {
|
|
|
287
343
|
}
|
|
288
344
|
}
|
|
289
345
|
|
|
346
|
+
function scheduleSettings(threadId, params, operation) {
|
|
347
|
+
const pending = settingsUpdates.get(threadId);
|
|
348
|
+
if (pending) {
|
|
349
|
+
pending.params = params;
|
|
350
|
+
return pending.accepted.promise;
|
|
351
|
+
}
|
|
352
|
+
const accepted = Promise.withResolvers();
|
|
353
|
+
const settings = { params, accepted, controller: new AbortController(),
|
|
354
|
+
goalChange: goalChanges.get(threadId) };
|
|
355
|
+
settingsUpdates.set(threadId, settings);
|
|
356
|
+
const task = withThreadContext(threadId, params, operation, settings);
|
|
357
|
+
void task.then(result => {
|
|
358
|
+
try { host.log?.("settings_applied", { model: settings.params.model, modelProvider: settings.params.modelProvider }, { threadId }); } catch { /* Diagnostics do not own scheduling. */ }
|
|
359
|
+
accepted.resolve(result);
|
|
360
|
+
}, async error => {
|
|
361
|
+
accepted.reject(error);
|
|
362
|
+
try { host.log?.("settings_failed", { code: error.code || "native_settings_failed" }, { threadId }); } catch { /* Logging cannot own native cleanup. */ }
|
|
363
|
+
// If a deferred reload fails, expose the actual paused goal for explicit
|
|
364
|
+
// recovery. Never resume it speculatively or hide a user's later control.
|
|
365
|
+
if (settingsUpdates.get(threadId) === settings) settingsUpdates.delete(threadId);
|
|
366
|
+
if (!settings.controller.signal.aborted && settings.client?.isOpen?.()) {
|
|
367
|
+
try {
|
|
368
|
+
const { goal } = await settings.client.request("thread/goal/get", { threadId }, {
|
|
369
|
+
signal: AbortSignal.timeout(host.timeoutMs || 10_000)
|
|
370
|
+
});
|
|
371
|
+
host.publishGoal?.(threadId, goal);
|
|
372
|
+
} catch { /* Native observation owns disconnect failures. */ }
|
|
373
|
+
}
|
|
374
|
+
}).finally(() => {
|
|
375
|
+
if (settingsUpdates.get(threadId) === settings) settingsUpdates.delete(threadId);
|
|
376
|
+
});
|
|
377
|
+
return accepted.promise;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function projectGoal(threadId, goal) {
|
|
381
|
+
const pending = settingsUpdates.get(threadId);
|
|
382
|
+
if (goal?.status === "paused" && pending?.goal && pending.goalChange === goalChanges.get(threadId) &&
|
|
383
|
+
goal.createdAt === pending.goal.createdAt && goal.objective === pending.goal.objective &&
|
|
384
|
+
(pending.goal.status === "active" || goal.updatedAt === pending.goal.updatedAt)) return { ...goal, status: "active" };
|
|
385
|
+
return goal;
|
|
386
|
+
}
|
|
387
|
+
|
|
290
388
|
return Object.freeze({
|
|
389
|
+
scheduleSettings,
|
|
390
|
+
hasPendingSettings: id => settingsUpdates.has(id),
|
|
391
|
+
projectGoal,
|
|
291
392
|
prepareInstructions,
|
|
292
393
|
threadParameters,
|
|
293
394
|
withThreadContext,
|
|
@@ -296,6 +397,8 @@ export function createCodexConversationAdapter(host) {
|
|
|
296
397
|
if (binding.params.developerInstructions != null) instructionPrompts.set(id, binding.params.developerInstructions);
|
|
297
398
|
},
|
|
298
399
|
invalidateThread(id) {
|
|
400
|
+
settingsUpdates.get(id)?.controller.abort();
|
|
401
|
+
settingsUpdates.delete(id);
|
|
299
402
|
threadGenerations.set(id, (threadGenerations.get(id) || 0) + 1);
|
|
300
403
|
bindings.delete(id);
|
|
301
404
|
},
|
|
@@ -303,6 +406,12 @@ export function createCodexConversationAdapter(host) {
|
|
|
303
406
|
get bindingCount() { return bindings.size; },
|
|
304
407
|
get threadEnvironments() { return bindings; },
|
|
305
408
|
get threadEnvironmentTasks() { return tasks; },
|
|
306
|
-
close() {
|
|
409
|
+
close() {
|
|
410
|
+
contextGeneration += 1;
|
|
411
|
+
for (const pending of settingsUpdates.values()) pending.controller.abort();
|
|
412
|
+
settingsUpdates.clear();
|
|
413
|
+
bindings.clear();
|
|
414
|
+
instructionPrompts.clear();
|
|
415
|
+
}
|
|
307
416
|
});
|
|
308
417
|
}
|