remote-codex 0.11.38 → 0.11.40

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.
@@ -22,6 +22,11 @@ function wait(ms = 0) {
22
22
  return new Promise((resolve) => setTimeout(resolve, ms));
23
23
  }
24
24
 
25
+ function uuidV7At(timestamp = Date.now()) {
26
+ const timestampHex = Math.trunc(timestamp).toString(16).padStart(12, '0');
27
+ return `${timestampHex.slice(0, 8)}-${timestampHex.slice(8)}-7000-8000-000000000001`;
28
+ }
29
+
25
30
  class FakeQuery implements Query {
26
31
  interrupted = false;
27
32
  closed = false;
@@ -541,6 +546,7 @@ describe('ClaudeRuntimeAdapter', () => {
541
546
 
542
547
  it('reconciles active multimodal transcript turns back to the live runtime turn id', async () => {
543
548
  let queryMessages: SDKMessage[] = [systemInit()];
549
+ const currentMessageUuid = uuidV7At();
544
550
  const adapter = new ClaudeRuntimeAdapter({
545
551
  home: '/tmp/claude-home',
546
552
  command: 'claude',
@@ -556,7 +562,7 @@ describe('ClaudeRuntimeAdapter', () => {
556
562
  getSessionMessages: (async () => [
557
563
  {
558
564
  type: 'user',
559
- uuid: '019e4657-bd3c-72d1-b59d-324ed8a4b1ec',
565
+ uuid: currentMessageUuid,
560
566
  session_id: 'claude-session-1',
561
567
  message: {
562
568
  role: 'user',
@@ -609,6 +615,99 @@ describe('ClaudeRuntimeAdapter', () => {
609
615
  expect(reloadedSession.turns[0]?.providerTurnId).toBe(started.providerTurnId);
610
616
  });
611
617
 
618
+ it('does not reconcile an old matching user-only turn as the active turn', async () => {
619
+ const historicalMessageUuid = uuidV7At(Date.now() - 10 * 60_000);
620
+ const adapter = new ClaudeRuntimeAdapter({
621
+ home: '/tmp/claude-home',
622
+ command: 'claude',
623
+ query: (() => new FakeQuery([systemInit()], { holdOpen: true })) as any,
624
+ listSessions: (async () => [] satisfies SDKSessionInfo[]) as any,
625
+ getSessionInfo: (async () => ({
626
+ sessionId: 'claude-session-1',
627
+ summary: 'Existing session',
628
+ cwd: '/tmp/workspace',
629
+ })) as any,
630
+ getSessionMessages: (async () => [{
631
+ type: 'user',
632
+ uuid: historicalMessageUuid,
633
+ session_id: 'claude-session-1',
634
+ message: { role: 'user', content: 'Repeat this prompt' },
635
+ parent_tool_use_id: null,
636
+ }] satisfies SessionMessage[]) as any,
637
+ });
638
+
639
+ const started = await adapter.startTurn({
640
+ providerSessionId: 'claude-session-1',
641
+ prompt: 'Repeat this prompt',
642
+ model: 'sonnet',
643
+ workspacePath: '/tmp/workspace',
644
+ });
645
+ const session = await adapter.readSession('claude-session-1');
646
+
647
+ expect(session.turns).toHaveLength(1);
648
+ expect(session.turns[0]).toMatchObject({
649
+ providerTurnId: `claude-turn-${historicalMessageUuid}`,
650
+ status: 'completed',
651
+ });
652
+
653
+ await adapter.interruptTurn({
654
+ providerSessionId: 'claude-session-1',
655
+ providerTurnId: started.providerTurnId,
656
+ });
657
+ });
658
+
659
+ it('reconciles only the current prompt when an older user-only turn exists', async () => {
660
+ const historicalMessageUuid = uuidV7At(Date.now() - 10 * 60_000);
661
+ const currentMessageUuid = uuidV7At();
662
+ const adapter = new ClaudeRuntimeAdapter({
663
+ home: '/tmp/claude-home',
664
+ command: 'claude',
665
+ query: (() => new FakeQuery([systemInit()], { holdOpen: true })) as any,
666
+ listSessions: (async () => [] satisfies SDKSessionInfo[]) as any,
667
+ getSessionInfo: (async () => ({
668
+ sessionId: 'claude-session-1',
669
+ summary: 'Existing session',
670
+ cwd: '/tmp/workspace',
671
+ })) as any,
672
+ getSessionMessages: (async () => [
673
+ {
674
+ type: 'user',
675
+ uuid: historicalMessageUuid,
676
+ session_id: 'claude-session-1',
677
+ message: { role: 'user', content: 'An older prompt' },
678
+ parent_tool_use_id: null,
679
+ },
680
+ {
681
+ type: 'user',
682
+ uuid: currentMessageUuid,
683
+ session_id: 'claude-session-1',
684
+ message: { role: 'user', content: 'The current prompt' },
685
+ parent_tool_use_id: null,
686
+ },
687
+ ] satisfies SessionMessage[]) as any,
688
+ });
689
+
690
+ const started = await adapter.startTurn({
691
+ providerSessionId: 'claude-session-1',
692
+ prompt: 'The current prompt',
693
+ model: 'sonnet',
694
+ workspacePath: '/tmp/workspace',
695
+ });
696
+ const session = await adapter.readSession('claude-session-1');
697
+
698
+ expect(session.turns.map((turn) => turn.providerTurnId)).toEqual([
699
+ `claude-turn-${historicalMessageUuid}`,
700
+ started.providerTurnId,
701
+ ]);
702
+ expect(session.turns[0]?.items[0]).toMatchObject({ text: 'An older prompt' });
703
+ expect(session.turns[1]).toMatchObject({ status: 'inProgress' });
704
+
705
+ await adapter.interruptTurn({
706
+ providerSessionId: 'claude-session-1',
707
+ providerTurnId: started.providerTurnId,
708
+ });
709
+ });
710
+
612
711
  it('keeps image blocks visible when reading Claude session history', async () => {
613
712
  const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-history-image-'));
614
713
  const adapter = new ClaudeRuntimeAdapter({
@@ -824,6 +923,91 @@ describe('ClaudeRuntimeAdapter', () => {
824
923
  heldQuery.close();
825
924
  });
826
925
 
926
+ it('keeps an active Claude session running when its detail is re-read', async () => {
927
+ const heldQuery = new FakeQuery([systemInit()], { holdOpen: true });
928
+ const adapter = makeAdapter((prompt) => {
929
+ if (prompt === hiddenInitPrompt()) {
930
+ return [systemInit(), result()];
931
+ }
932
+ return heldQuery;
933
+ });
934
+
935
+ await adapter.startSession({
936
+ cwd: '/tmp/workspace',
937
+ model: 'sonnet',
938
+ approvalMode: 'guarded',
939
+ sandboxMode: 'workspace-write',
940
+ });
941
+ await adapter.startTurn({
942
+ providerSessionId: 'claude-session-1',
943
+ providerTurnId: 'turn-1',
944
+ prompt: 'Keep running',
945
+ } as any);
946
+ await wait();
947
+
948
+ await expect(adapter.readSession('claude-session-1')).resolves.toMatchObject({
949
+ providerSessionId: 'claude-session-1',
950
+ status: 'running',
951
+ });
952
+
953
+ heldQuery.close();
954
+ });
955
+
956
+ it('emits complete assistant progress messages before the Claude turn finishes', async () => {
957
+ const heldQuery = new FakeQuery([
958
+ systemInit(),
959
+ {
960
+ type: 'assistant',
961
+ message: {
962
+ id: 'msg_progress',
963
+ type: 'message',
964
+ role: 'assistant',
965
+ model: 'sonnet',
966
+ content: [{ type: 'text', text: 'I found the failing integration.' }],
967
+ usage: {} as any,
968
+ },
969
+ parent_tool_use_id: null,
970
+ uuid: '00000000-0000-4000-8000-000000000050' as any,
971
+ session_id: 'claude-session-1',
972
+ },
973
+ ], { holdOpen: true });
974
+ const adapter = makeAdapter((prompt) => {
975
+ if (prompt === hiddenInitPrompt()) {
976
+ return [systemInit(), result()];
977
+ }
978
+ return heldQuery;
979
+ });
980
+ const events: AgentRuntimeEvent[] = [];
981
+ adapter.on('event', (event) => events.push(event));
982
+
983
+ await adapter.startSession({
984
+ cwd: '/tmp/workspace',
985
+ model: 'sonnet',
986
+ approvalMode: 'guarded',
987
+ sandboxMode: 'workspace-write',
988
+ });
989
+ await adapter.startTurn({
990
+ providerSessionId: 'claude-session-1',
991
+ providerTurnId: 'turn-1',
992
+ prompt: 'Investigate the failure',
993
+ } as any);
994
+ await wait();
995
+
996
+ expect(events).toContainEqual(
997
+ expect.objectContaining({
998
+ type: 'item.completed',
999
+ item: expect.objectContaining({
1000
+ id: 'msg_progress:content:0',
1001
+ kind: 'agentMessage',
1002
+ text: 'I found the failing integration.',
1003
+ }),
1004
+ }),
1005
+ );
1006
+ expect(events.some((event) => event.type === 'turn.completed')).toBe(false);
1007
+
1008
+ heldQuery.close();
1009
+ });
1010
+
827
1011
  it('merges adjacent Claude thinking blocks into one reasoning item', () => {
828
1012
  const items = assistantMessageToHistoryItems({
829
1013
  messageId: 'msg_1',
@@ -1988,7 +2172,11 @@ describe('ClaudeRuntimeAdapter', () => {
1988
2172
  : null,
1989
2173
  )
1990
2174
  .filter(Boolean),
1991
- ).toEqual(['claude-turn-visible', 'claude-turn-visible']);
2175
+ ).toEqual([
2176
+ 'claude-turn-visible',
2177
+ 'claude-turn-visible',
2178
+ 'claude-turn-visible',
2179
+ ]);
1992
2180
  });
1993
2181
 
1994
2182
  it('interrupts an active query', async () => {
@@ -247,6 +247,33 @@ interface ActiveClaudeTurn {
247
247
  }
248
248
 
249
249
  const promptPhotoTokenPattern = /\[PHOTO\s+([^\]]+)\]/g;
250
+ const activeTranscriptMatchWindowMs = 120_000;
251
+
252
+ function normalizePromptForTurnReconciliation(value: string) {
253
+ return value
254
+ // Claude history can rewrite or omit an image marker. The surrounding
255
+ // text and UUIDv7 timestamp remain stable reconciliation identifiers.
256
+ .replace(/\[PHOTO\s+[^\]]+\]/g, ' ')
257
+ .replace(/\s+/g, ' ')
258
+ .trim();
259
+ }
260
+
261
+ function userPromptForTurn(turn: AgentTurn) {
262
+ return turn.items.find((item) => item.kind === 'userMessage')?.text ?? null;
263
+ }
264
+
265
+ function isRecentActiveTranscriptTurn(turn: AgentTurn, activeStartedAt: string) {
266
+ if (!turn.startedAt) {
267
+ return true;
268
+ }
269
+ const transcriptStartedAt = Date.parse(turn.startedAt);
270
+ const activeStartedAtMs = Date.parse(activeStartedAt);
271
+ return (
272
+ !Number.isFinite(transcriptStartedAt)
273
+ || !Number.isFinite(activeStartedAtMs)
274
+ || Math.abs(transcriptStartedAt - activeStartedAtMs) <= activeTranscriptMatchWindowMs
275
+ );
276
+ }
250
277
 
251
278
  function mimeTypeForImagePath(filePath: string) {
252
279
  const extension = path.extname(filePath).toLowerCase();
@@ -1503,6 +1530,12 @@ export class ClaudeRuntimeAdapter extends EventEmitter implements AgentRuntime {
1503
1530
  return {
1504
1531
  ...summary,
1505
1532
  cwd,
1533
+ // Claude's persisted session metadata remains "idle" while a query is
1534
+ // streaming. The in-memory runtime is authoritative for that interval:
1535
+ // returning the stale persisted status makes detail polling clear the
1536
+ // active provider turn, which in turn drops live tool items from the
1537
+ // transcript until Claude writes its final history record.
1538
+ status: activeTurn ? 'running' : summary.status,
1506
1539
  turns: this.reconcileActiveTranscriptTurn(providerSessionId, turns, activeTurn),
1507
1540
  };
1508
1541
  }
@@ -1760,12 +1793,17 @@ export class ClaudeRuntimeAdapter extends EventEmitter implements AgentRuntime {
1760
1793
  const activeItems = [...activeTurn.itemOrder]
1761
1794
  .map((itemId) => activeTurn.items.get(itemId))
1762
1795
  .filter((item): item is AgentHistoryItem => Boolean(item));
1763
- const transcriptTurnIndex = turns.findLastIndex(
1764
- (turn) =>
1765
- turn.status === 'completed' &&
1766
- turn.items.some((item) => item.kind === 'userMessage') &&
1767
- !turn.items.some((item) => item.kind === 'agentMessage'),
1768
- );
1796
+ const normalizedPrompt = normalizePromptForTurnReconciliation(prompt);
1797
+ const transcriptTurnIndex = turns.findLastIndex((turn) => {
1798
+ const transcriptPrompt = userPromptForTurn(turn);
1799
+ return (
1800
+ turn.status === 'completed'
1801
+ && transcriptPrompt !== null
1802
+ && normalizePromptForTurnReconciliation(transcriptPrompt) === normalizedPrompt
1803
+ && !turn.items.some((item) => item.kind === 'agentMessage')
1804
+ && isRecentActiveTranscriptTurn(turn, activeTurn.startedAt)
1805
+ );
1806
+ });
1769
1807
 
1770
1808
  if (transcriptTurnIndex < 0) {
1771
1809
  return turns;
@@ -1778,6 +1816,14 @@ export class ClaudeRuntimeAdapter extends EventEmitter implements AgentRuntime {
1778
1816
  aliases = new Map();
1779
1817
  this.historicalTurnIdAliases.set(providerSessionId, aliases);
1780
1818
  }
1819
+ for (const [historicalTurnId, displayTurnId] of aliases) {
1820
+ if (
1821
+ displayTurnId === activeTurn.providerTurnId
1822
+ && historicalTurnId !== transcriptTurn.providerTurnId
1823
+ ) {
1824
+ aliases.delete(historicalTurnId);
1825
+ }
1826
+ }
1781
1827
  aliases.set(transcriptTurn.providerTurnId, activeTurn.providerTurnId);
1782
1828
  }
1783
1829
 
@@ -1806,9 +1852,20 @@ export class ClaudeRuntimeAdapter extends EventEmitter implements AgentRuntime {
1806
1852
  if (!aliases || aliases.size === 0) {
1807
1853
  return turns;
1808
1854
  }
1855
+ const rawTurnIds = new Set(turns.map((turn) => turn.providerTurnId));
1856
+ const usedTurnIds = new Set<string>();
1809
1857
  return turns.map((turn) => {
1810
1858
  const providerTurnId = aliases.get(turn.providerTurnId);
1811
- return providerTurnId ? { ...turn, providerTurnId } : turn;
1859
+ if (
1860
+ !providerTurnId
1861
+ || usedTurnIds.has(providerTurnId)
1862
+ || (providerTurnId !== turn.providerTurnId && rawTurnIds.has(providerTurnId))
1863
+ ) {
1864
+ usedTurnIds.add(turn.providerTurnId);
1865
+ return turn;
1866
+ }
1867
+ usedTurnIds.add(providerTurnId);
1868
+ return { ...turn, providerTurnId };
1812
1869
  });
1813
1870
  }
1814
1871
 
@@ -2054,9 +2111,15 @@ export class ClaudeRuntimeAdapter extends EventEmitter implements AgentRuntime {
2054
2111
  const nextItem = withHistoryItemCreatedAt(item, messageCreatedAt);
2055
2112
  const existing = state.items.get(nextItem.id);
2056
2113
  addOrUpdateItem(state, nextItem);
2057
- if (nextItem.kind !== 'agentMessage' && !existing) {
2114
+ if (nextItem.kind === 'agentMessage') {
2115
+ // Unlike stream_event text deltas, complete assistant messages are
2116
+ // delivered as SDK assistant events. Emit these immediately so
2117
+ // progress text between Claude tool calls is visible before the
2118
+ // enclosing turn finishes.
2119
+ this.emitItem(state, nextItem, 'item.completed', { force: true });
2120
+ } else if (!existing) {
2058
2121
  this.emitItem(state, nextItem, 'item.started');
2059
- } else if (nextItem.kind !== 'agentMessage' && existing) {
2122
+ } else {
2060
2123
  this.emitItem(state, nextItem, 'item.started', { force: true });
2061
2124
  }
2062
2125
  }