Pārlūkot izejas kodu

fix(opencode): retry unsuccessful child-session lookups

Drew Ritter 5 dienas atpakaļ
vecāks
revīzija
96ecdeb0fa

+ 18 - 9
.opencode/plugins/superpowers.js

@@ -174,15 +174,24 @@ const isChildSession = async (fetchSession, sessionID) => {
   let isChild = false;
   try {
     const result = await fetchSession(sessionID);
-    // Defensive dual-shape unwrap: fetchers may return the session record
-    // itself (V2 ctx) or an SDK envelope { data: Session } (V1 client). An
-    // envelope never carries parentID at the top level, so if `result` has
-    // one it already IS the session record — never unwrap past it.
-    const session = result && typeof result === 'object' && result.data && typeof result.data === 'object' && !('parentID' in result)
-      ? result.data
-      : result;
-    // parentID presence is the child-session signal on both flavors.
-    isChild = Boolean(session && typeof session === 'object' && session.parentID);
+    // V1 returns a successful SDK envelope while V2 returns a direct session
+    // record. Validate both shapes before classifying or caching the result;
+    // resolved SDK errors must follow the same fail-open path as rejections.
+    if (!result || typeof result !== 'object' || Array.isArray(result)) {
+      throw new Error('Session lookup returned no usable record');
+    }
+    if (result.error != null || result.response?.ok === false) {
+      throw new Error('Session lookup was unsuccessful');
+    }
+    const session = 'data' in result ? result.data : result;
+    if (!session || typeof session !== 'object' || Array.isArray(session) || session.id !== sessionID) {
+      throw new Error('Session lookup returned an invalid session identity');
+    }
+    if (session.parentID !== undefined &&
+        (typeof session.parentID !== 'string' || session.parentID.length === 0)) {
+      throw new Error('Session lookup returned an invalid parent identity');
+    }
+    isChild = session.parentID !== undefined;
   } catch (err) {
     // Fail open: on lookup errors keep injecting (previous behavior) and do
     // not cache, so a transient failure can recover on the next step.

+ 2 - 0
tests/opencode/run-tests.sh

@@ -45,6 +45,7 @@ while [[ $# -gt 0 ]]; do
             echo "Tests:"
             echo "  test-plugin-loading.sh  Verify plugin installation and structure"
             echo "  test-bootstrap-caching.sh  Verify bootstrap content caching"
+            echo "  test-session-bootstrap.sh  Verify session classification and lookup recovery"
             echo "  test-skill-registration.sh  Verify V2 skill registration contract (2.0.4 path field)"
             echo "  test-tools.sh           Test use_skill and find_skills tools (integration)"
             echo "  test-priority.sh        Test skill priority resolution (integration)"
@@ -62,6 +63,7 @@ done
 tests=(
     "test-plugin-loading.sh"
     "test-bootstrap-caching.sh"
+    "test-session-bootstrap.sh"
     "test-skill-registration.sh"
 )
 

+ 144 - 0
tests/opencode/test-session-bootstrap.mjs

@@ -0,0 +1,144 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import { pathToFileURL } from 'node:url';
+
+const [, , inputPath] = process.argv;
+assert.ok(inputPath, 'pass the plugin module path');
+const pluginURL = pathToFileURL(fs.realpathSync(inputPath));
+const marker = '<EXTREMELY_IMPORTANT>\nYou have superpowers.';
+let generation = 0;
+
+function reply(flavor, session) {
+  return flavor === 'v1' ? { data: session } : session;
+}
+
+function makeEvent(flavor, sessionID) {
+  const text = { type: 'text', text: 'Execute the assigned task' };
+  return {
+    sessionID,
+    messages: [flavor === 'v1'
+      ? { info: { role: 'user', sessionID }, parts: [text] }
+      : { role: 'user', content: [text] }],
+  };
+}
+
+function bootstrapCount(event) {
+  return event.messages.flatMap((message) => message.parts ?? message.content ?? []).filter(
+    (part) => part.type === 'text' && part.text.startsWith(marker)
+  ).length;
+}
+
+async function makeHarness(flavor, fetchSession) {
+  const mod = await import(`${pluginURL.href}?session-test=${++generation}`);
+  const lookups = [];
+  const registered = [];
+  const get = async (id) => {
+    lookups.push(id);
+    return fetchSession(id, lookups.length);
+  };
+  let invoke;
+  if (flavor === 'v1') {
+    const hooks = await mod.SuperpowersPlugin({
+      client: { session: { get: ({ path: { id } }) => get(id) } },
+      directory: '.',
+    });
+    invoke = (event) => hooks['experimental.chat.messages.transform']({}, event);
+  } else {
+    await mod.default.setup({
+      skill: { transform: async (transform) => transform({ add: (skill) => registered.push(skill) }) },
+      session: {
+        get: ({ sessionID }) => get(sessionID),
+        hook: async (name, callback) => { if (name === 'context') invoke = callback; },
+      },
+    });
+  }
+  assert.equal(typeof invoke, 'function');
+  return { invoke, lookups, registered };
+}
+
+for (const flavor of ['v1', 'v2']) {
+  for (const [kind, extra, expected] of [
+    ['root', {}, 1],
+    ['child', { parentID: 'parent' }, 0],
+    ['fork', { fork: { sessionID: 'origin' } }, 1],
+  ]) {
+    const id = `${flavor}-${kind}`;
+    const h = await makeHarness(flavor, () => reply(flavor, { id, ...extra }));
+    const event = makeEvent(flavor, id);
+    await h.invoke(event);
+    assert.equal(bootstrapCount(event), expected, `${id}: first request`);
+    await h.invoke(event);
+    assert.equal(bootstrapCount(event), expected, `${id}: repeated event`);
+    const fresh = makeEvent(flavor, id);
+    await h.invoke(fresh);
+    assert.equal(bootstrapCount(fresh), expected, `${id}: fresh request`);
+    assert.deepEqual(h.lookups, [id], `${id}: cache successful classification`);
+    if (flavor === 'v2' && kind === 'child') {
+      assert.ok(h.registered.some((skill) => skill.id === 'brainstorming'));
+    }
+  }
+
+  const failures = [
+    ['throws', () => { throw new Error('temporary lookup failure'); }],
+    ['missing', () => undefined],
+    ['null', () => null],
+    ['empty', () => reply(flavor, {})],
+    ['wrong-id', () => reply(flavor, { id: 'different-session' })],
+    ['invalid-parent', (id) => reply(flavor, { id, parentID: 42 })],
+  ];
+  if (flavor === 'v1') {
+    failures.push(['resolved-http-error', () => ({
+      data: undefined,
+      error: { name: 'UnknownError', data: { message: 'temporary 503' } },
+      response: { ok: false, status: 503 },
+    })]);
+  }
+  for (const [kind, firstResult] of failures) {
+    const id = `${flavor}-${kind}`;
+    const h = await makeHarness(flavor, (sessionID, call) => call === 1
+      ? firstResult(sessionID)
+      : reply(flavor, { id: sessionID, parentID: 'parent' }));
+    const counts = [];
+    for (let step = 0; step < 2; step++) {
+      const event = makeEvent(flavor, id);
+      await h.invoke(event);
+      counts.push(bootstrapCount(event));
+    }
+    assert.deepEqual(counts, [1, 0], `${id}: recover on the next request`);
+    assert.deepEqual(h.lookups, [id, id], `${id}: never cache the failure`);
+  }
+
+  const isolated = await makeHarness(flavor, (id) => reply(flavor,
+    id === 'child-session' ? { id, parentID: 'parent' } : { id }));
+  for (const [id, expected] of [['root-session', 1], ['child-session', 0], ['root-session', 1], ['child-session', 0]]) {
+    const event = makeEvent(flavor, id);
+    await isolated.invoke(event);
+    assert.equal(bootstrapCount(event), expected);
+  }
+  assert.deepEqual(isolated.lookups, ['root-session', 'child-session']);
+
+  const bounded = await makeHarness(flavor, (id) => reply(flavor, { id, parentID: 'parent' }));
+  for (let index = 0; index <= 512; index++) {
+    const event = makeEvent(flavor, `eviction-${index}`);
+    await bounded.invoke(event);
+    assert.equal(bootstrapCount(event), 0);
+  }
+  const evicted = makeEvent(flavor, 'eviction-0');
+  await bounded.invoke(evicted);
+  assert.equal(bootstrapCount(evicted), 0);
+  assert.equal(bounded.lookups.filter((id) => id === 'eviction-0').length, 2);
+
+  const restarted = await makeHarness(flavor, (id) => reply(flavor, { id, parentID: 'parent' }));
+  const afterRestart = makeEvent(flavor, 'eviction-0');
+  await restarted.invoke(afterRestart);
+  assert.equal(bootstrapCount(afterRestart), 0);
+  assert.deepEqual(restarted.lookups, ['eviction-0']);
+
+  const unknown = await makeHarness(flavor, () => { throw new Error('must not look up a missing ID'); });
+  const noID = makeEvent(flavor, undefined);
+  await unknown.invoke(noID);
+  assert.equal(bootstrapCount(noID), 1);
+  assert.deepEqual(unknown.lookups, []);
+}
+
+console.log('Session classification, recovery and cache lifetime passed');

+ 4 - 0
tests/opencode/test-session-bootstrap.sh

@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+node "$SCRIPT_DIR/test-session-bootstrap.mjs" "$SCRIPT_DIR/../../.opencode/plugins/superpowers.js"