Ctrl K
ComunidadGuías…

test/context-management-signed-replay.test.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * CTX-01: a finite `clear_thinking_20251015` keep can only be honoured while
 * signed thinking replay is enabled when it retains every replayable signed
 * turn — anthropicToOpenAI re-attaches exactly that signed thinking (the
 * `signedReplay` attach), and the upstream verifies each signature. The edit
 * clears the OLDEST thinking turns, so the check is order-aware:
 * {type:"thinking_turns",N} drops `thinkingTurnIndexes().slice(0, turns - N)`
 * and is accepted when N covers every thinking turn or when no dropped turn is
 * replayable. [signed, mixed] keep 1 therefore rejects (it drops the signed
 * turn) while [mixed, signed] keep 1 accepts (it drops only the mixed turn).
 * keep:"none" keeps none, so it is refused whenever a replayable signed turn
 * exists. keep:"all", {type:"all"}, replay off and histories the translator
 * does not replay stay accepted.
 *
 * M1 — an ABSENT keep is not keep-all: upstream resolves the default per
 * model class ([UNVERIFIED] keep-all on Opus 4.5+ / Sonnet 4.6+ / Fable,
 * last-turn-only on Haiku ≤4.5 / Opus ≤4.1 / Sonnet ≤4.5). Only the keep-all
 * class accepts outright; every other model — including names the resolver
 * cannot classify — runs the same dropped-prefix check an explicit
 * {thinking_turns:1} would.
 *
 * M2 — `redacted_thinking` IS a thinking block for the keep window (upstream
 * preservation rules count it; [UNVERIFIED] against this backend), so a
 * turn carrying only redacted_thinking blocks occupies a slot in
 * thinkingTurnIndexes and shifts which turns a finite keep drops.
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { validateMessagesRequest } from '../src/handlers/messages.js';

// Catalog model that signedThinkingReplayEnabled resolves to the anthropic
// provider. Fake signature shape borrowed from thinking-signature-replay.test.js.
const MODEL = 'claude-opus-5-5-medium';
const SIG = 'EggKBggLEgKquxgB';
const ENV_KEY = 'DEVIN_CONNECT_REPLAY_SIGNED_THINKING';

// Assistant turns, each carrying one signed thinking block — the shape the
// translator replays. `turns` selects how many replayable signed turns exist.
function signedHistory(turns = 2) {
  const messages = [{ role: 'user', content: 'go' }];
  for (let i = 0; i < turns; i++) {
    messages.push({
      role: 'assistant',
      content: [
        { type: 'thinking', thinking: `secret ${i + 1}`, signature: SIG },
        { type: 'text', text: `answer ${i + 1}` },
      ],
    });
    if (i < turns - 1) messages.push({ role: 'user', content: `again ${i + 1}` });
  }
  messages.push({ role: 'user', content: 'final' });
  return messages;
}

// Single assistant turns for the order-aware cases. `signedTurn` is replayable
// (exactly one signed thinking block); `unsignedTurn` is a thinking turn the
// translator drops; `mixedTurn` mixes one signed and one unsigned thinking
// block, so it counts as a thinking turn but is never replayed.
function signedTurn(label) {
  return {
    role: 'assistant',
    content: [
      { type: 'thinking', thinking: `secret ${label}`, signature: SIG },
      { type: 'text', text: `answer ${label}` },
    ],
  };
}

function unsignedTurn(label) {
  return {
    role: 'assistant',
    content: [
      { type: 'thinking', thinking: `unsigned ${label}`, signature: '' },
      { type: 'text', text: `answer ${label}` },
    ],
  };
}

function mixedTurn(label) {
  return {
    role: 'assistant',
    content: [
      { type: 'thinking', thinking: `secret ${label}`, signature: SIG },
      { type: 'thinking', thinking: `unsigned ${label}`, signature: '' },
      { type: 'text', text: `answer ${label}` },
    ],
  };
}

// M2: an assistant turn whose only thinking block is `redacted_thinking`.
// The translator drops it like every non-signed block, but upstream counts it
// as a thinking turn for the clear_thinking keep window.
function redactedTurn(label) {
  return {
    role: 'assistant',
    content: [
      { type: 'redacted_thinking', data: `redacted-${label}` },
      { type: 'text', text: `answer ${label}` },
    ],
  };
}

// History whose assistant turns appear in the given order, oldest first.
function turnHistory(...turns) {
  const messages = [{ role: 'user', content: 'go' }];
  for (const turn of turns) {
    messages.push(turn, { role: 'user', content: 'again' });
  }
  return messages;
}

function clearThinking(keep) {
  const edit = { type: 'clear_thinking_20251015' };
  if (keep !== undefined) edit.keep = keep;
  return { edits: [edit] };
}

function request(overrides = {}) {
  return {
    model: MODEL,
    max_tokens: 128000,
    context_management: clearThinking('all'),
    messages: signedHistory(),
    ...overrides,
  };
}

function withReplay(enabled, fn) {
  const saved = process.env[ENV_KEY];
  if (enabled) process.env[ENV_KEY] = '1';
  else delete process.env[ENV_KEY];
  try {
    return fn();
  } finally {
    if (saved === undefined) delete process.env[ENV_KEY];
    else process.env[ENV_KEY] = saved;
  }
}

function assertUnsupportedKeep(err, index = 0) {
  assert.ok(err, 'expected a 400');
  assert.equal(err.status, 400);
  assert.equal(err.body.error.type, 'invalid_request_error');
  assert.equal(err.body.error.code, 'unsupported_parameter');
  assert.equal(err.body.error.param, `context_management.edits[${index}].keep`);
  assert.match(err.body.error.message, /signed thinking replay is enabled/i);
  assert.match(err.body.error.message, /keep:"all"/, 'message must name the accepted form');
  assert.match(err.body.error.message, /disable|remove/i, 'message must offer a way out');
}

describe('CTX-01 clear_thinking_20251015 finite keep vs signed replay', () => {
  it('accepts keep:{type:"thinking_turns",value:1} when the single signed turn is kept', () => {
    withReplay(true, () => {
      assert.equal(validateMessagesRequest(request({
        messages: signedHistory(1),
        context_management: clearThinking({ type: 'thinking_turns', value: 1 }),
      })), null);
    });
  });

  it('accepts keep:{type:"thinking_turns",value:5} when it exceeds the signed turn count', () => {
    withReplay(true, () => {
      assert.equal(validateMessagesRequest(request({
        messages: signedHistory(1),
        context_management: clearThinking({ type: 'thinking_turns', value: 5 }),
      })), null);
    });
  });

  it('accepts keep:{type:"thinking_turns",value:2} when both signed turns are kept', () => {
    withReplay(true, () => {
      assert.equal(validateMessagesRequest(request({
        context_management: clearThinking({ type: 'thinking_turns', value: 2 }),
      })), null);
    });
  });

  it('rejects keep:{type:"thinking_turns",value:1} when two signed turns would be cleared', () => {
    withReplay(true, () => {
      const err = validateMessagesRequest(request({
        context_management: clearThinking({ type: 'thinking_turns', value: 1 }),
      }));
      assertUnsupportedKeep(err);
    });
  });

  it('rejects keep:1 for [signed, mixed]: the dropped prefix holds the signed turn', () => {
    withReplay(true, () => {
      const err = validateMessagesRequest(request({
        messages: turnHistory(signedTurn('1'), mixedTurn('2')),
        context_management: clearThinking({ type: 'thinking_turns', value: 1 }),
      }));
      assertUnsupportedKeep(err);
    });
  });

  it('accepts keep:1 for [mixed, signed]: the dropped prefix holds only the mixed turn', () => {
    withReplay(true, () => {
      assert.equal(validateMessagesRequest(request({
        messages: turnHistory(mixedTurn('1'), signedTurn('2')),
        context_management: clearThinking({ type: 'thinking_turns', value: 1 }),
      })), null);
    });
  });

  it('rejects keep:1 and accepts keep:2 for [signed, signed]', () => {
    withReplay(true, () => {
      const messages = turnHistory(signedTurn('1'), signedTurn('2'));
      const err = validateMessagesRequest(request({
        messages,
        context_management: clearThinking({ type: 'thinking_turns', value: 1 }),
      }));
      assertUnsupportedKeep(err);
      assert.equal(validateMessagesRequest(request({
        messages,
        context_management: clearThinking({ type: 'thinking_turns', value: 2 }),
      })), null);
    });
  });

  it('rejects keep:1 for [signed, unsigned]: the dropped prefix holds the signed turn', () => {
    withReplay(true, () => {
      const err = validateMessagesRequest(request({
        messages: turnHistory(signedTurn('1'), unsignedTurn('2')),
        context_management: clearThinking({ type: 'thinking_turns', value: 1 }),
      }));
      assertUnsupportedKeep(err);
    });
  });

  it('rejects keep:"none" when replay is on and two signed turns exist', () => {
    withReplay(true, () => {
      const err = validateMessagesRequest(request({ context_management: clearThinking('none') }));
      assertUnsupportedKeep(err);
    });
  });

  it('rejects keep:"none" when replay is on and a single signed turn exists', () => {
    withReplay(true, () => {
      const err = validateMessagesRequest(request({
        messages: signedHistory(1),
        context_management: clearThinking('none'),
      }));
      assertUnsupportedKeep(err);
    });
  });

  it('accepts keep:"all", {type:"all"} and an absent keep on a keep-all-class model', () => {
    withReplay(true, () => {
      assert.equal(validateMessagesRequest(request({ context_management: clearThinking('all') })), null);
      assert.equal(validateMessagesRequest(request({ context_management: clearThinking({ type: 'all' }) })), null);
      // MODEL is claude-opus-5-5-medium — the keep-all class, so an absent keep
      // retains every thinking turn and must be accepted (M1).
      assert.equal(validateMessagesRequest(request({ context_management: clearThinking(undefined) })), null);
    });
  });

  it('rejects an absent keep on a keep-1-class model when it would drop a signed turn (M1)', () => {
    withReplay(true, () => {
      // claude-haiku-4-5 resolves to claude-4.5-haiku: upstream's absent-keep
      // default is the LAST thinking turn only, so over [signed, signed] the
      // edit clears the older signed turn this bridge would re-attach.
      const err = validateMessagesRequest(request({
        model: 'claude-haiku-4-5',
        context_management: clearThinking(undefined),
      }));
      assertUnsupportedKeep(err);
    });
  });

  it('accepts an absent keep on a keep-1-class model when the kept turn covers every signed turn (M1)', () => {
    withReplay(true, () => {
      // keep-1 keeps the LAST thinking turn; with a single signed turn that is
      // the replayed one, so nothing the edit clears is re-attached.
      assert.equal(validateMessagesRequest(request({
        model: 'claude-haiku-4-5',
        messages: signedHistory(1),
        context_management: clearThinking(undefined),
      })), null);
      // [redacted, signed]: the dropped prefix holds only the redacted turn.
      assert.equal(validateMessagesRequest(request({
        model: 'claude-haiku-4-5',
        messages: turnHistory(redactedTurn('1'), signedTurn('2')),
        context_management: clearThinking(undefined),
      })), null);
    });
  });

  it('treats an unclassifiable claude-* model as keep-1 for an absent keep (M1, conservative)', () => {
    withReplay(true, () => {
      // Not in the catalogue: signedThinkingReplayEnabled still resolves it to
      // the anthropic provider via the claude- prefix, and the keep-all class
      // table cannot prove keep-all — so the safe keep-1 check applies.
      const err = validateMessagesRequest(request({
        model: 'claude-opus-9-9-nonexistent',
        context_management: clearThinking(undefined),
      }));
      assertUnsupportedKeep(err);
    });
  });

  it('an absent keep is unaffected when replay is off, on any model class (M1)', () => {
    withReplay(false, () => {
      assert.equal(validateMessagesRequest(request({
        model: 'claude-haiku-4-5',
        context_management: clearThinking(undefined),
      })), null);
    });
  });

  it('counts a redacted_thinking-only turn in the keep window (M2)', () => {
    withReplay(true, () => {
      // [signed, redacted]: upstream counts BOTH as thinking turns, so keep 1
      // retains only the redacted turn and clears the signed one — exactly the
      // case CTX-01 must refuse. Before the fix the redacted turn was not
      // counted, turns.length was 1, and keep 1 was wrongly accepted.
      const err = validateMessagesRequest(request({
        messages: turnHistory(signedTurn('1'), redactedTurn('2')),
        context_management: clearThinking({ type: 'thinking_turns', value: 1 }),
      }));
      assertUnsupportedKeep(err);
      // keep 2 retains both thinking turns — accepted.
      assert.equal(validateMessagesRequest(request({
        messages: turnHistory(signedTurn('1'), redactedTurn('2')),
        context_management: clearThinking({ type: 'thinking_turns', value: 2 }),
      })), null);
      // [redacted, signed]: keep 1 drops only the redacted prefix — accepted.
      assert.equal(validateMessagesRequest(request({
        messages: turnHistory(redactedTurn('1'), signedTurn('2')),
        context_management: clearThinking({ type: 'thinking_turns', value: 1 }),
      })), null);
    });
  });

  it('accepts a finite keep when replay is off', () => {
    withReplay(false, () => {
      assert.equal(validateMessagesRequest(request({ context_management: clearThinking('none') })), null);
      assert.equal(validateMessagesRequest(request({
        context_management: clearThinking({ type: 'thinking_turns', value: 1 }),
      })), null);
    });
  });

  it('accepts a finite keep when the history has no signed thinking', () => {
    withReplay(true, () => {
      const messages = [
        { role: 'user', content: 'go' },
        { role: 'assistant', content: [{ type: 'text', text: 'one' }] },
        { role: 'user', content: 'again' },
      ];
      assert.equal(validateMessagesRequest(request({ messages, context_management: clearThinking('none') })), null);
      assert.equal(validateMessagesRequest(request({
        messages,
        context_management: clearThinking({ type: 'thinking_turns', value: 1 }),
      })), null);
    });
  });

  it('accepts a finite keep for history the translator does not replay (unsigned or multi-block turn)', () => {
    withReplay(true, () => {
      const unsigned = [{
        role: 'user', content: 'go',
      }, {
        role: 'assistant',
        content: [
          { type: 'thinking', thinking: 'not signed', signature: '' },
          { type: 'text', text: 'one' },
        ],
      }];
      assert.equal(validateMessagesRequest(request({ messages: unsigned, context_management: clearThinking('none') })), null);

      const multiBlock = [{
        role: 'user', content: 'go',
      }, {
        role: 'assistant',
        content: [
          { type: 'thinking', thinking: 'a', signature: SIG },
          { type: 'thinking', thinking: 'b', signature: SIG },
          { type: 'text', text: 'one' },
        ],
      }];
      assert.equal(validateMessagesRequest(request({
        messages: multiBlock,
        context_management: clearThinking('none'),
      })), null);
    });
  });

  it('keeps keep shape errors first: a malformed keep is still invalid_request_error', () => {
    withReplay(true, () => {
      const err = validateMessagesRequest(request({ context_management: clearThinking('sometimes') }));
      assert.ok(err, 'expected a 400');
      assert.equal(err.status, 400);
      assert.equal(err.body.error.type, 'invalid_request_error');
      assert.equal(err.body.error.code, undefined, 'shape errors are not unsupported_parameter');
      assert.equal(err.body.error.param, undefined);
    });
  });

  it('names the offending edit index when a later edit carries the finite keep', () => {
    withReplay(true, () => {
      const err = validateMessagesRequest(request({
        context_management: { edits: [
          { type: 'clear_thinking_20251015', keep: 'all' },
          { type: 'clear_thinking_20251015', keep: 'none' },
        ] },
      }));
      assertUnsupportedKeep(err, 1);
    });
  });

  // G-04: `edits.findIndex` stopped at the FIRST finite keep, so a satisfiable edit
  // earlier in the list hid a later edit that drops a replayable signed turn. Each
  // edit is a directive of its own; the request must be refused at the offending
  // index instead of reaching the upstream with an edit this bridge cannot honour.
  it('rejects edits [keep 2, keep 1] for [signed, signed]: the SECOND edit drops a signed turn', () => {
    withReplay(true, () => {
      const err = validateMessagesRequest(request({
        messages: turnHistory(signedTurn('1'), signedTurn('2')),
        context_management: { edits: [
          { type: 'clear_thinking_20251015', keep: { type: 'thinking_turns', value: 2 } },
          { type: 'clear_thinking_20251015', keep: { type: 'thinking_turns', value: 1 } },
        ] },
      }));
      assertUnsupportedKeep(err, 1);
    });
  });

  it('accepts edits [keep 2, keep 2] for [signed, signed]: every edit is satisfiable', () => {
    withReplay(true, () => {
      assert.equal(validateMessagesRequest(request({
        messages: turnHistory(signedTurn('1'), signedTurn('2')),
        context_management: { edits: [
          { type: 'clear_thinking_20251015', keep: { type: 'thinking_turns', value: 2 } },
          { type: 'clear_thinking_20251015', keep: { type: 'thinking_turns', value: 2 } },
        ] },
      })), null);
    });
  });
});