@excitedjs/agent-runtime-codex 1.0.0-beta.170 → 1.0.0-beta.173

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.
@@ -1,14 +1,5 @@
1
- /**
2
- * Per-dispatcher in-memory inbound submitter.
3
- *
4
- * Contract:
5
- * - accepted inbound messages are not persisted;
6
- * - Feishu message_id redelivery is deduped within this server process;
7
- * - one accepted inbound message becomes one Codex `turn/start` submission;
8
- * - Codex text output alone does not send anything to Feishu.
9
- */
10
1
  import { extractAssistantText, subscribeTurnCollection, submitTurnStart, } from './events.js';
11
- import { compileCodexOutputSchema, } from './output-schema-codec.js';
2
+ import { compileCodexOutputSchema } from './output-schema-codec.js';
12
3
  import { DEFAULT_MESSAGE_ID_DEDUPE_WINDOW, unsupportedFeatureError, } from '@excitedjs/dreamux-utils';
13
4
  export class TurnManager {
14
5
  opts;
@@ -19,298 +10,340 @@ export class TurnManager {
19
10
  pendingMessageIds = new Map();
20
11
  pendingTextInputIds = new Map();
21
12
  pendingAdmissions = new Set();
13
+ inFlightNativeAdmissions = new Set();
14
+ nextNativeAdmission = 0;
15
+ nativeTurns = new Map();
16
+ pendingActivity = new Map();
17
+ unboundObservedTurnIds = new Set();
18
+ terminalOrder = [];
19
+ protocolFailure = null;
20
+ collector = null;
21
+ collectorThreadId = null;
22
+ decisionTail = Promise.resolve();
22
23
  stopped = false;
23
- activeTurnSlot = null;
24
- /**
25
- * Turn ids submitted to Codex that have not yet reached `turn/completed`. On
26
- * `stop()` each still-pending turn is settled as `stopped` so a teammate turn
27
- * interrupted by teardown is not lost.
28
- */
29
- pendingTurns = new Map();
30
24
  idlePromise = null;
31
25
  idleResolve = null;
32
26
  log;
33
27
  messageIdDedupeWindow;
34
28
  constructor(opts) {
35
29
  this.opts = opts;
36
- this.log = opts.log ?? ((lvl, msg, err) => {
37
- const prefix = `[turn-manager ${opts.dispatcherId}] ${lvl}`;
38
- if (err !== undefined)
39
- console.error(prefix, msg, err);
30
+ this.log = opts.log ?? ((level, message, error) => {
31
+ const prefix = `[turn-manager ${opts.dispatcherId}] ${level}`;
32
+ if (error === undefined)
33
+ console.error(prefix, message);
40
34
  else
41
- console.error(prefix, msg);
35
+ console.error(prefix, message, error);
42
36
  });
43
37
  this.messageIdDedupeWindow = Math.max(0, opts.messageIdDedupeWindow ?? DEFAULT_MESSAGE_ID_DEDUPE_WINDOW);
44
38
  }
45
39
  isBusy() {
46
- return this.activeTurnSlot !== null || this.pendingTurns.size > 0;
40
+ return this.pendingAdmissions.size > 0 || [...this.nativeTurns.values()].some((record) => record.completion === null);
47
41
  }
48
42
  waitIdle() {
49
43
  if (!this.isBusy())
50
44
  return Promise.resolve();
51
- // All concurrent waiters share one promise for the current busy period; it
52
- // is replaced with a fresh one the next time the runtime goes busy.
53
- if (this.idlePromise === null) {
54
- this.idlePromise = new Promise((resolve) => {
55
- this.idleResolve = resolve;
56
- });
57
- }
45
+ this.idlePromise ??= new Promise((resolve) => { this.idleResolve = resolve; });
58
46
  return this.idlePromise;
59
47
  }
60
- /**
61
- * Submit one accepted inbound message to Codex. Returns duplicate when this
62
- * process already saw the message_id.
63
- */
64
48
  enqueue(input) {
65
- return this.trackAdmission(this.reserveSource(input.sourceId, this.seenMessageIds, this.seenMessageIdOrder, this.pendingMessageIds, () => this.enqueueUnreserved(input)));
49
+ return this.trackAdmission(this.reserveSource(input.sourceId, this.seenMessageIds, this.seenMessageIdOrder, this.pendingMessageIds, () => this.enqueueDecision(() => this.submit(input.text, null, `message ${input.sourceId || '<none>'}`))));
66
50
  }
67
- async enqueueUnreserved(input) {
68
- if (this.stopped)
69
- return { status: 'stopped' };
70
- const threadId = this.opts.getThreadId();
71
- if (threadId === null) {
72
- const error = new Error('inbound submitted without thread_id');
73
- this.log('error', error.message);
74
- return { status: 'failed', error };
75
- }
76
- let activeTurn;
77
- try {
78
- activeTurn = this.claimActiveTurnSlot(threadId, null);
79
- }
80
- catch (err) {
81
- const error = asError(err);
82
- this.log('error', error.message, error);
83
- return { status: 'failed', error };
84
- }
85
- const submissionIndex = activeTurn.slot.nextSubmissionIndex++;
86
- activeTurn.slot.pendingSubmissions += 1;
87
- let res;
88
- try {
89
- res = await submitTurnStart(this.opts.client, threadId, input.text, this.opts.turnCwd ?? null);
51
+ submitTextInput(input) {
52
+ return this.trackAdmission(this.reserveSource(input.sourceId, this.seenTextInputIds, this.seenTextInputIdOrder, this.pendingTextInputIds, async () => {
53
+ let codec = null;
54
+ if (input.outputSchema !== undefined) {
55
+ try {
56
+ codec = compileCodexOutputSchema(input.outputSchema);
57
+ }
58
+ catch (error) {
59
+ return { status: 'failed', error: asError(error) };
60
+ }
61
+ }
62
+ return this.enqueueDecision(() => this.submit(input.text, codec, 'text input'));
63
+ }));
64
+ }
65
+ async stop() {
66
+ this.stopped = true;
67
+ this.collector?.dispose();
68
+ this.collector = null;
69
+ while (this.pendingAdmissions.size > 0)
70
+ await Promise.allSettled([...this.pendingAdmissions]);
71
+ this.drainTerminalOrder();
72
+ for (const record of this.nativeTurns.values()) {
73
+ if (record.completion !== null)
74
+ continue;
75
+ for (const member of record.members)
76
+ member.settle({ kind: 'stopped' });
90
77
  }
91
- catch (err) {
92
- const error = err instanceof Error ? err : new Error(String(err));
93
- this.recordTurnStartFailure(activeTurn.slot, error);
94
- this.log('error', `turn/start submission failed for message ${input.sourceId === '' ? '<none>' : input.sourceId}: ${error.message}`, error);
95
- return { status: 'ambiguous', error };
78
+ for (const [turnId, record] of this.nativeTurns) {
79
+ if (record.completion === null)
80
+ this.nativeTurns.delete(turnId);
96
81
  }
97
- this.recordTurnStartSuccess(activeTurn.slot, res.turn.id, submissionIndex);
98
- return { status: 'submitted', turn: activeTurn.slot.runtimeTurn };
99
- }
100
- submitTextInput(input) {
101
- return this.trackAdmission(this.reserveSource(input.sourceId, this.seenTextInputIds, this.seenTextInputIdOrder, this.pendingTextInputIds, () => this.submitTextInputUnreserved(input)));
82
+ this.terminalOrder.length = 0;
83
+ this.pendingActivity.clear();
84
+ this.unboundObservedTurnIds.clear();
85
+ this.resolveIdleWaitersIfIdle();
102
86
  }
103
- async submitTextInputUnreserved(input) {
87
+ async submit(text, codec, description) {
104
88
  if (this.stopped)
105
89
  return { status: 'stopped' };
90
+ if (this.protocolFailure !== null)
91
+ return { status: 'failed', error: this.protocolFailure };
106
92
  const threadId = this.opts.getThreadId();
107
- if (threadId === null) {
108
- const error = new Error('text input submitted without thread_id');
109
- this.log('error', error.message);
110
- return { status: 'failed', error };
111
- }
112
- let codec = null;
113
- if (input.outputSchema !== undefined) {
114
- try {
115
- codec = compileCodexOutputSchema(input.outputSchema);
116
- }
117
- catch (err) {
118
- const error = asError(err);
119
- this.log('error', error.message, error);
120
- return { status: 'failed', error };
121
- }
122
- }
123
- let activeTurn;
93
+ if (threadId === null)
94
+ return { status: 'failed', error: new Error('input submitted without thread_id') };
95
+ const deferred = createRuntimeSubmission();
96
+ this.ensureCollector(threadId);
97
+ const incompatible = this.incompatibleCodecError(codec);
98
+ if (incompatible !== null)
99
+ return { status: 'failed', error: incompatible };
100
+ const admissionId = this.nextNativeAdmission++;
101
+ this.inFlightNativeAdmissions.add(admissionId);
102
+ let response;
124
103
  try {
125
- activeTurn = this.claimActiveTurnSlot(threadId, codec);
104
+ response = await submitTurnStart(this.opts.client, threadId, text, this.opts.turnCwd ?? null, codec?.wireSchema);
126
105
  }
127
- catch (err) {
128
- const error = asError(err);
129
- this.log('error', error.message, error);
130
- return { status: 'failed', error };
106
+ catch (error) {
107
+ const normalized = asError(error);
108
+ this.log('error', `turn/start submission failed for ${description}: ${normalized.message}`, normalized);
109
+ this.inFlightNativeAdmissions.delete(admissionId);
110
+ this.releaseCompletedRecords(admissionId);
111
+ this.dropOrphanActivityIfIdle();
112
+ return { status: 'ambiguous', error: normalized };
131
113
  }
132
- const submissionIndex = activeTurn.slot.nextSubmissionIndex++;
133
- activeTurn.slot.pendingSubmissions += 1;
134
- let res;
135
- try {
136
- res = await submitTurnStart(this.opts.client, threadId, input.text, this.opts.turnCwd ?? null, activeTurn.slot.codec?.wireSchema);
114
+ const observed = this.nativeTurns.get(response.turn.id);
115
+ if (this.stopped && (observed === undefined || observed.terminal === null)) {
116
+ deferred.settle({ kind: 'stopped' });
137
117
  }
138
- catch (err) {
139
- const error = err instanceof Error ? err : new Error(String(err));
140
- this.recordTurnStartFailure(activeTurn.slot, error);
141
- this.log('error', `text turn/start submission failed: ${error.message}`, error);
142
- return { status: 'ambiguous', error };
118
+ else {
119
+ this.bindSubmission(response.turn.id, deferred, codec);
143
120
  }
144
- this.recordTurnStartSuccess(activeTurn.slot, res.turn.id, submissionIndex);
145
- return { status: 'submitted', turn: activeTurn.slot.runtimeTurn };
121
+ this.inFlightNativeAdmissions.delete(admissionId);
122
+ this.releaseCompletedRecords(admissionId);
123
+ this.dropOrphanActivityIfIdle();
124
+ return { status: 'submitted', submission: deferred.submission };
146
125
  }
147
- async stop() {
148
- this.stopped = true;
149
- const activeSlot = this.activeTurnSlot;
150
- this.activeTurnSlot = null;
151
- if (activeSlot !== null) {
152
- activeSlot.stopped = true;
153
- activeSlot.collector.dispose();
154
- activeSlot.codec = null;
155
- activeSlot.settle({ status: 'stopped' });
156
- }
157
- // Any turn still in flight at teardown will never reach `turn/completed`
158
- // (the WS is closing). Settle each as `stopped` so an interrupted teammate
159
- // turn is delivered with a status rather than vanishing.
160
- for (const slot of this.pendingTurns.values()) {
161
- slot.settle({ status: 'stopped' });
162
- }
163
- this.pendingTurns.clear();
164
- this.resolveIdleWaitersIfIdle();
165
- while (this.pendingAdmissions.size > 0) {
166
- await Promise.allSettled([...this.pendingAdmissions]);
167
- }
126
+ enqueueDecision(operation) {
127
+ const task = this.decisionTail.then(operation, operation);
128
+ this.decisionTail = task.then(() => undefined, () => undefined);
129
+ return task;
168
130
  }
169
- claimActiveTurnSlot(threadId, codec) {
170
- const active = this.activeTurnSlot;
171
- if (active !== null) {
172
- assertCompatibleCodec(active.codec, codec);
173
- return { slot: active, primary: false };
131
+ incompatibleCodecError(candidate) {
132
+ for (const record of this.nativeTurns.values()) {
133
+ if (record.completion !== null || record.terminal !== null)
134
+ continue;
135
+ if (sameCodec(record.codec, candidate))
136
+ continue;
137
+ return unsupportedFeatureError('outputSchema', 'codex cannot submit an incompatible outputSchema while a native turn is active');
174
138
  }
175
- const runtimeTurn = createRuntimeTurn();
176
- const slot = {
177
- collector: subscribeTurnCollection(this.opts.client, threadId, {
178
- retainAfterTerminal: true,
179
- }),
139
+ return null;
140
+ }
141
+ ensureCollector(threadId) {
142
+ if (this.collector !== null && this.collectorThreadId === threadId)
143
+ return;
144
+ this.collector?.dispose();
145
+ this.collectorThreadId = threadId;
146
+ this.collector = subscribeTurnCollection(this.opts.client, threadId, {
147
+ retainAfterTerminal: true,
148
+ onItemStarted: (turnId, item) => this.observeItem(turnId, item, 'started', Date.now()),
149
+ onItemCompleted: (turnId, item, occurredAt) => this.observeItem(turnId, item, 'completed', occurredAt),
150
+ onTerminal: (turnId, terminal) => this.observeTerminal(turnId, terminal),
151
+ onUnscopedFailure: (error) => this.failProtocol(error),
152
+ onProtocolViolation: (error) => this.failProtocol(error),
153
+ });
154
+ }
155
+ bindSubmission(turnId, deferred, codec) {
156
+ this.unboundObservedTurnIds.delete(turnId);
157
+ const record = this.nativeTurns.get(turnId) ?? {
158
+ representative: null,
159
+ members: [],
180
160
  codec,
181
- runtimeTurn: runtimeTurn.turn,
182
- settle: runtimeTurn.settle,
183
- turnId: null,
184
- pendingSubmissions: 0,
185
- nextSubmissionIndex: 0,
186
- acceptedTurnIds: new Map(),
187
- pendingNativeTurnIds: new Set(),
188
- completedNativeTurns: new Map(),
189
- nativeFailures: new Map(),
190
- lastSubmissionError: null,
191
- stopped: false,
161
+ completion: null,
162
+ terminal: null,
163
+ releaseAfterAdmissions: null,
192
164
  };
193
- this.activeTurnSlot = slot;
194
- return { slot, primary: true };
195
- }
196
- recordTurnStartSuccess(slot, turnId, submissionIndex) {
197
- slot.pendingSubmissions = Math.max(0, slot.pendingSubmissions - 1);
198
- if (this.stopped) {
199
- slot.settle({ status: 'stopped' });
165
+ if (record.representative === null && record.members.length === 0)
166
+ record.codec = codec;
167
+ record.representative ??= deferred.submission;
168
+ if (record.completion === null)
169
+ record.members.push(deferred);
170
+ this.nativeTurns.set(turnId, record);
171
+ for (const fact of this.pendingActivity.get(turnId) ?? [])
172
+ this.emitActivity(record, fact.activity, fact.occurredAt);
173
+ this.pendingActivity.delete(turnId);
174
+ if (this.protocolFailure !== null) {
175
+ this.failRecord(turnId, record, this.protocolFailure);
200
176
  return;
201
177
  }
202
- slot.acceptedTurnIds.set(submissionIndex, turnId);
203
- slot.turnId ??= turnId;
204
- this.trackTurn(turnId, slot.collector, slot);
205
- this.finalizeSlotIfReady(slot);
206
- }
207
- recordTurnStartFailure(slot, error) {
208
- slot.pendingSubmissions = Math.max(0, slot.pendingSubmissions - 1);
209
- slot.lastSubmissionError = error;
210
- this.finalizeSlotIfReady(slot);
178
+ if (record.completion !== null)
179
+ deferred.settle({ kind: 'completion', completion: record.completion });
180
+ this.drainTerminalOrder();
211
181
  }
212
- /**
213
- * Record a submitted turn as pending and wire its completion. On
214
- * `turn/completed` the turn is removed from the pending set and the snapshot
215
- * hook fires (which is where the runtime emits the `completed` settlement). On
216
- * a terminal turn failure (collector rejects: codex `error` with
217
- * `willRetry: false`, or a `turn/completed` carrying `turn.error`) the turn is
218
- * settled as `failed` here, so a teammate turn that errors at the model level
219
- * is delivered with a status instead of hanging until teardown.
220
- */
221
- trackTurn(turnId, collector, slot) {
222
- if (slot.pendingNativeTurnIds.has(turnId))
182
+ observeTerminal(turnId, terminal) {
183
+ this.unboundObservedTurnIds.delete(turnId);
184
+ const record = this.nativeTurns.get(turnId) ?? {
185
+ representative: null, members: [], codec: null, completion: null, terminal: null,
186
+ releaseAfterAdmissions: null,
187
+ };
188
+ if (record.terminal !== null || record.completion !== null)
223
189
  return;
224
- slot.pendingNativeTurnIds.add(turnId);
225
- this.pendingTurns.set(turnId, slot);
226
- void collector.awaitTurn(turnId).then((turn) => {
227
- // Only forward completion if this turn was still pending. If `stop()`
228
- // already settled it as `stopped`, the delete returns false and we drop
229
- // the late completion so a turn is never settled twice.
230
- const pending = this.pendingTurns.get(turnId);
231
- if (pending !== undefined && this.pendingTurns.delete(turnId)) {
232
- pending.pendingNativeTurnIds.delete(turnId);
233
- let completedTurn;
234
- try {
235
- completedTurn = pending.codec === null
236
- ? turn
237
- : restoreCollectedTurn(turn, pending.codec);
238
- }
239
- catch (err) {
240
- pending.nativeFailures.set(turnId, asError(err));
241
- this.finalizeSlotIfReady(pending);
190
+ record.terminal = terminal;
191
+ record.releaseAfterAdmissions = new Set(this.inFlightNativeAdmissions);
192
+ this.nativeTurns.set(turnId, record);
193
+ this.terminalOrder.push(turnId);
194
+ this.drainTerminalOrder();
195
+ }
196
+ drainTerminalOrder() {
197
+ while (this.terminalOrder.length > 0) {
198
+ const turnId = this.terminalOrder[0];
199
+ const record = this.nativeTurns.get(turnId);
200
+ if (record === undefined || record.terminal === null)
201
+ return;
202
+ if (record.representative === null) {
203
+ if (this.pendingAdmissions.size > 0)
242
204
  return;
243
- }
244
- pending.completedNativeTurns.set(turnId, completedTurn);
245
- this.finalizeSlotIfReady(pending);
246
- }
247
- }, (err) => {
248
- // Same mutual-exclusion guard as the completed path: only settle as
249
- // `failed` if `stop()` did not already settle it as `stopped`.
250
- if (this.pendingTurns.delete(turnId)) {
251
- slot.pendingNativeTurnIds.delete(turnId);
252
- slot.nativeFailures.set(turnId, err instanceof Error ? err : new Error(String(err)));
253
- this.finalizeSlotIfReady(slot);
205
+ this.terminalOrder.shift();
206
+ this.nativeTurns.delete(turnId);
207
+ this.pendingActivity.delete(turnId);
208
+ this.unboundObservedTurnIds.delete(turnId);
209
+ this.collector?.releaseTurn(turnId);
210
+ this.log('warn', `dropping native terminal ${turnId} without an accepted submission`);
211
+ continue;
254
212
  }
255
- });
213
+ this.terminalOrder.shift();
214
+ this.finalize(turnId, record, record.terminal);
215
+ }
256
216
  }
257
- finalizeSlotIfReady(slot) {
258
- if (slot.stopped ||
259
- slot.pendingSubmissions !== 0 ||
260
- slot.pendingNativeTurnIds.size !== 0) {
217
+ finalize(turnId, record, terminal) {
218
+ if (record.completion !== null)
261
219
  return;
262
- }
263
- const accepted = [...slot.acceptedTurnIds.entries()]
264
- .sort(([left], [right]) => left - right);
265
- if (accepted.length === 0 && slot.lastSubmissionError === null)
220
+ if (record.representative === null)
266
221
  return;
267
- if (this.activeTurnSlot === slot)
268
- this.activeTurnSlot = null;
269
- slot.collector.dispose();
270
- slot.codec = null;
271
- const nativeFailure = accepted
272
- .map(([, turnId]) => slot.nativeFailures.get(turnId))
273
- .find((error) => error !== undefined);
274
- if (nativeFailure !== undefined) {
275
- slot.settle({ status: 'failed', error: nativeFailure });
276
- }
277
- else if (accepted.length === 0) {
278
- slot.settle({ status: 'failed', error: slot.lastSubmissionError });
222
+ let completion;
223
+ if (terminal instanceof Error) {
224
+ completion = Object.freeze({ status: 'failed', displaySubmission: record.representative, error: terminal });
279
225
  }
280
226
  else {
281
- const finalTurnId = accepted.at(-1)[1];
282
- const finalTurn = slot.completedNativeTurns.get(finalTurnId);
283
- if (finalTurn === undefined) {
284
- throw new Error(`codex logical Turn lost terminal output for native turn ${finalTurnId}`);
227
+ let completedTurn = terminal;
228
+ try {
229
+ if (record.codec !== null)
230
+ completedTurn = restoreCollectedTurn(terminal, record.codec);
231
+ }
232
+ catch (error) {
233
+ completion = Object.freeze({ status: 'failed', displaySubmission: record.representative, error: asError(error) });
234
+ record.completion = completion;
235
+ for (const member of record.members)
236
+ member.settle({ kind: 'completion', completion });
237
+ record.members.length = 0;
238
+ record.terminal = null;
239
+ this.releaseRecordIfReady(turnId, record);
240
+ this.resolveIdleWaitersIfIdle();
241
+ return;
285
242
  }
286
- this.opts.onTurnCompleted?.(finalTurn);
287
- slot.settle({
243
+ this.opts.onTurnCompleted?.(completedTurn);
244
+ completion = Object.freeze({
288
245
  status: 'completed',
289
- resultText: extractAssistantText(finalTurn),
246
+ displaySubmission: record.representative,
247
+ resultText: extractAssistantText(completedTurn),
290
248
  truncated: false,
291
249
  });
292
250
  }
251
+ record.completion = completion;
252
+ for (const member of record.members)
253
+ member.settle({ kind: 'completion', completion });
254
+ record.members.length = 0;
255
+ record.terminal = null;
256
+ this.releaseRecordIfReady(turnId, record);
293
257
  this.resolveIdleWaitersIfIdle();
294
258
  }
259
+ failProtocol(error) {
260
+ this.protocolFailure ??= error;
261
+ this.log('error', error.message, error);
262
+ this.terminalOrder.length = 0;
263
+ for (const [turnId, record] of this.nativeTurns) {
264
+ if (record.completion !== null)
265
+ continue;
266
+ this.failRecord(turnId, record, this.protocolFailure);
267
+ }
268
+ this.resolveIdleWaitersIfIdle();
269
+ }
270
+ failRecord(turnId, record, error) {
271
+ for (const member of record.members)
272
+ member.settle({ kind: 'failed', error });
273
+ record.members.length = 0;
274
+ this.nativeTurns.delete(turnId);
275
+ this.pendingActivity.delete(turnId);
276
+ this.unboundObservedTurnIds.delete(turnId);
277
+ this.collector?.releaseTurn(turnId);
278
+ }
279
+ releaseCompletedRecords(admissionId) {
280
+ for (const [turnId, record] of this.nativeTurns) {
281
+ record.releaseAfterAdmissions?.delete(admissionId);
282
+ this.releaseRecordIfReady(turnId, record);
283
+ }
284
+ }
285
+ releaseRecordIfReady(turnId, record) {
286
+ if (record.completion === null || (record.releaseAfterAdmissions?.size ?? 0) > 0)
287
+ return;
288
+ this.nativeTurns.delete(turnId);
289
+ this.pendingActivity.delete(turnId);
290
+ this.unboundObservedTurnIds.delete(turnId);
291
+ this.collector?.releaseTurn(turnId);
292
+ }
293
+ dropOrphanActivityIfIdle() {
294
+ if (this.inFlightNativeAdmissions.size !== 0)
295
+ return;
296
+ for (const turnId of this.unboundObservedTurnIds) {
297
+ if (this.nativeTurns.has(turnId))
298
+ continue;
299
+ this.pendingActivity.delete(turnId);
300
+ this.collector?.releaseTurn(turnId);
301
+ }
302
+ this.unboundObservedTurnIds.clear();
303
+ }
304
+ observeItem(turnId, item, phase, occurredAt) {
305
+ const record = this.nativeTurns.get(turnId);
306
+ if (record === undefined || record.representative === null) {
307
+ this.unboundObservedTurnIds.add(turnId);
308
+ if (this.inFlightNativeAdmissions.size === 0) {
309
+ this.dropOrphanActivityIfIdle();
310
+ return;
311
+ }
312
+ const activity = itemActivity(turnId, item, phase);
313
+ if (activity === null)
314
+ return;
315
+ const pending = this.pendingActivity.get(turnId) ?? [];
316
+ pending.push({ activity, occurredAt });
317
+ this.pendingActivity.set(turnId, pending);
318
+ return;
319
+ }
320
+ const activity = itemActivity(turnId, item, phase);
321
+ if (activity === null)
322
+ return;
323
+ this.emitActivity(record, activity, occurredAt);
324
+ }
325
+ emitActivity(record, activity, occurredAt) {
326
+ if (record.representative === null)
327
+ return;
328
+ try {
329
+ this.opts.activitySink(Object.freeze({ submission: record.representative, activity: Object.freeze(activity), occurredAt }));
330
+ }
331
+ catch (error) {
332
+ this.log('warn', 'codex activity projection failed', error);
333
+ }
334
+ }
295
335
  reserveSource(sourceId, committed, order, pending, operation) {
296
336
  if (sourceId === undefined || sourceId === '')
297
337
  return operation();
298
- if (committed.has(sourceId)) {
338
+ if (committed.has(sourceId))
299
339
  return Promise.resolve({ status: 'duplicate' });
300
- }
301
340
  const existing = pending.get(sourceId);
302
341
  if (existing !== undefined)
303
342
  return existing;
304
- const task = Promise.resolve()
305
- .then(operation)
306
- .catch((error) => ({
307
- status: 'ambiguous',
308
- error: asError(error),
309
- }));
343
+ const task = Promise.resolve().then(operation).catch((error) => ({ status: 'ambiguous', error: asError(error) }));
310
344
  pending.set(sourceId, task);
311
345
  void task.then((admission) => {
312
- if (admission.status === 'submitted' ||
313
- admission.status === 'ambiguous') {
346
+ if (admission.status === 'submitted' || admission.status === 'ambiguous') {
314
347
  committed.add(sourceId);
315
348
  order.push(sourceId);
316
349
  while (order.length > this.messageIdDedupeWindow) {
@@ -328,6 +361,8 @@ export class TurnManager {
328
361
  this.pendingAdmissions.add(admission);
329
362
  void admission.finally(() => {
330
363
  this.pendingAdmissions.delete(admission);
364
+ this.drainTerminalOrder();
365
+ this.resolveIdleWaitersIfIdle();
331
366
  }).catch(() => undefined);
332
367
  return admission;
333
368
  }
@@ -340,58 +375,83 @@ export class TurnManager {
340
375
  resolve?.();
341
376
  }
342
377
  }
343
- function assertCompatibleCodec(active, candidate) {
344
- if (active === null && candidate === null)
345
- return;
346
- if (active !== null && candidate !== null) {
347
- if (active.fingerprint === candidate.fingerprint)
348
- return;
349
- throw unsupportedFeatureError('outputSchema', 'codex active turn has an incompatible outputSchema');
378
+ function sameCodec(left, right) {
379
+ return left === null ? right === null : right !== null && left.fingerprint === right.fingerprint;
380
+ }
381
+ function createRuntimeSubmission() {
382
+ let resolve;
383
+ let settled = false;
384
+ const submission = Object.freeze({ settled: new Promise((value) => { resolve = value; }) });
385
+ return { submission, settle(settlement) { if (settled)
386
+ return false; settled = true; resolve(settlement); return true; } };
387
+ }
388
+ function itemActivity(turnId, item, phase) {
389
+ const itemId = typeof item.id === 'string' && item.id !== '' ? item.id : null;
390
+ if (itemId === null)
391
+ return null;
392
+ if (item.type === 'agentMessage') {
393
+ if (phase !== 'completed' || typeof item.text !== 'string' || item.text === '')
394
+ return null;
395
+ return { kind: 'assistant.message', id: `${turnId}:${itemId}:completed`, text: item.text, truncated: false };
396
+ }
397
+ const toolName = toolNameFor(item);
398
+ if (toolName === null)
399
+ return null;
400
+ const failed = phase === 'completed' && (item['status'] === 'failed' || item['error'] != null);
401
+ return {
402
+ kind: 'tool.call',
403
+ id: `${turnId}:${itemId}:${phase}`,
404
+ callId: itemId,
405
+ toolName,
406
+ status: phase === 'started' ? 'started' : failed ? 'failed' : 'completed',
407
+ arguments: toJsonValue(item['arguments'] ?? item['input'] ?? item['command'] ?? null),
408
+ result: phase === 'completed' ? toJsonValue(item['result'] ?? item['output'] ?? item['aggregatedOutput'] ?? null) : null,
409
+ error: failed ? String(item['error'] ?? 'tool call failed') : null,
410
+ };
411
+ }
412
+ function toolNameFor(item) {
413
+ if (item.type === 'commandExecution')
414
+ return 'exec_command';
415
+ if (item.type === 'fileChange')
416
+ return 'apply_patch';
417
+ if (item.type === 'mcpToolCall') {
418
+ const server = typeof item['server'] === 'string' ? item['server'] : null;
419
+ const tool = typeof item['tool'] === 'string' ? item['tool'] : null;
420
+ return server !== null && tool !== null ? `${server}.${tool}` : null;
421
+ }
422
+ if (typeof item['name'] === 'string')
423
+ return item['name'];
424
+ if (typeof item['tool'] === 'string')
425
+ return item['tool'];
426
+ return null;
427
+ }
428
+ function toJsonValue(value) {
429
+ if (value === undefined)
430
+ return null;
431
+ try {
432
+ return JSON.parse(JSON.stringify(value));
433
+ }
434
+ catch {
435
+ return String(value);
350
436
  }
351
- throw unsupportedFeatureError('outputSchema', active === null
352
- ? 'codex cannot fold structured output into an active unstructured turn'
353
- : 'codex cannot fold unstructured input into an active structured turn');
354
437
  }
355
438
  function restoreCollectedTurn(turn, codec) {
356
439
  const text = extractAssistantText(turn);
357
- if (text === null) {
358
- throw new Error('codex outputSchema restoration failed: completed turn has no ' +
359
- 'assistant JSON text');
360
- }
440
+ if (text === null)
441
+ throw new Error('codex outputSchema restoration failed: completed turn has no assistant JSON text');
361
442
  const restoredText = codec.restore(text);
362
443
  let replaced = false;
363
444
  const items = [...turn.items].reverse().map((item) => {
364
- if (replaced || item.type !== 'agentMessage' || item.text !== text) {
445
+ if (replaced || item.type !== 'agentMessage' || item.text !== text)
365
446
  return item;
366
- }
367
447
  replaced = true;
368
448
  return { ...item, text: restoredText };
369
449
  }).reverse();
370
- if (!replaced) {
450
+ if (!replaced)
371
451
  throw new Error('codex outputSchema restoration failed: assistant JSON text was not found');
372
- }
373
452
  return { ...turn, items };
374
453
  }
375
454
  function asError(error) {
376
455
  return error instanceof Error ? error : new Error(String(error));
377
456
  }
378
- function createRuntimeTurn() {
379
- let resolve;
380
- let settled = false;
381
- const turn = Object.freeze({
382
- settled: new Promise((value) => {
383
- resolve = value;
384
- }),
385
- });
386
- return {
387
- turn,
388
- settle(outcome) {
389
- if (settled)
390
- return false;
391
- settled = true;
392
- resolve(outcome);
393
- return true;
394
- },
395
- };
396
- }
397
457
  //# sourceMappingURL=turn-manager.js.map