mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/fix-pin-duplication
This commit is contained in:
@@ -472,10 +472,7 @@ jobs:
|
||||
# message and tag name explicitly (avoids npm's `v1.2.3` prefix
|
||||
# assumptions and any lifecycle scripts that would run on bump).
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
# Why: package version is embedded in generated skill provenance; full tag
|
||||
# history preserves released snapshots while the current version is updated.
|
||||
node config/scripts/generate-skill-bundle-manifest.mjs --write
|
||||
git add package.json resources/skills
|
||||
git add package.json
|
||||
commit_message="release: v$VERSION"
|
||||
if [[ "$EVENT_NAME" == "schedule" ]]; then
|
||||
commit_message="$commit_message [rc-slot:$SLOT]"
|
||||
|
||||
@@ -6,7 +6,11 @@ import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
|
||||
const SCHEMA_VERSION = 1
|
||||
// Why: the three artifacts version independently — bumping one shape must not
|
||||
// rewrite the others or bypass the registry's schema-gated append-only guard.
|
||||
const CURRENT_MANIFEST_SCHEMA_VERSION = 2
|
||||
const SNAPSHOT_REGISTRY_SCHEMA_VERSION = 1
|
||||
const RELEASE_MAPPING_SCHEMA_VERSION = 1
|
||||
const SCRIPT_DIR = import.meta.dirname
|
||||
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
|
||||
const SKILLS_ROOT = path.join(REPO_ROOT, 'skills')
|
||||
@@ -313,8 +317,8 @@ function skillsTreeShasAtRefs(refs) {
|
||||
}
|
||||
|
||||
function buildReleasedHistory() {
|
||||
const registry = { schemaVersion: SCHEMA_VERSION, skills: {} }
|
||||
const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] }
|
||||
const registry = { schemaVersion: SNAPSHOT_REGISTRY_SCHEMA_VERSION, skills: {} }
|
||||
const mapping = { schemaVersion: RELEASE_MAPPING_SCHEMA_VERSION, releases: [] }
|
||||
const tags = releaseTags()
|
||||
const treeShas = skillsTreeShasAtRefs(tags)
|
||||
const distinctTreeShas = [...new Set(treeShas.filter(Boolean))]
|
||||
@@ -366,7 +370,10 @@ function buildReleasedHistory() {
|
||||
return { registry, mapping }
|
||||
}
|
||||
|
||||
async function buildArtifacts(appVersion) {
|
||||
// Why: the artifacts must be pure functions of skills/ bytes and release-tag
|
||||
// history. Stamping the app version made every release cut invalidate the
|
||||
// committed output on all open branches and drag skill CI onto unrelated PRs.
|
||||
async function buildArtifacts() {
|
||||
const { registry, mapping } = buildReleasedHistory()
|
||||
const releasedSnapshotCounts = Object.fromEntries(
|
||||
Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length])
|
||||
@@ -399,14 +406,12 @@ async function buildArtifacts(appVersion) {
|
||||
currentSkills.push({
|
||||
name,
|
||||
sourcePath: `skills/${name}`,
|
||||
appVersion,
|
||||
...snapshot
|
||||
})
|
||||
}
|
||||
return {
|
||||
currentManifest: {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
appVersion,
|
||||
schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION,
|
||||
skills: currentSkills
|
||||
},
|
||||
snapshotRegistry: registry,
|
||||
@@ -419,7 +424,7 @@ async function buildArtifacts(appVersion) {
|
||||
// so a generation-logic change must not rewrite them silently. Only the one
|
||||
// unreleased working-tree append per skill may change between runs.
|
||||
function assertReleasedHistoryPreserved(committedRegistry, artifacts) {
|
||||
if (!committedRegistry || committedRegistry.schemaVersion !== SCHEMA_VERSION) {
|
||||
if (!committedRegistry || committedRegistry.schemaVersion !== SNAPSHOT_REGISTRY_SCHEMA_VERSION) {
|
||||
return
|
||||
}
|
||||
for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) {
|
||||
@@ -466,17 +471,50 @@ async function writeArtifacts(artifacts) {
|
||||
])
|
||||
}
|
||||
|
||||
// Why: cutting a release tag adds a trailing mapping row on every checkout at
|
||||
// once, before any branch can regenerate. A trailing row whose revisions all
|
||||
// equal the current manifest is provably redundant until the next real
|
||||
// regeneration (installs of those bytes classify as current and are labeled by
|
||||
// the running build), so verify must not fail branches over it.
|
||||
function isToleratedReleaseMappingPrefix(committedText, artifacts) {
|
||||
let committed
|
||||
try {
|
||||
committed = JSON.parse(committedText)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const derived = artifacts.releaseMapping
|
||||
const committedCount = Array.isArray(committed?.releases) ? committed.releases.length : -1
|
||||
if (committedCount < 0 || committedCount >= derived.releases.length) {
|
||||
return false
|
||||
}
|
||||
const prefix = {
|
||||
schemaVersion: derived.schemaVersion,
|
||||
releases: derived.releases.slice(0, committedCount)
|
||||
}
|
||||
if (committedText !== serialized(prefix)) {
|
||||
return false
|
||||
}
|
||||
const currentRevisions = Object.fromEntries(
|
||||
artifacts.currentManifest.skills.map((skill) => [skill.name, skill.releaseRevision])
|
||||
)
|
||||
return derived.releases
|
||||
.slice(committedCount)
|
||||
.every((release) => isDeepStrictEqual(release.skills, currentRevisions))
|
||||
}
|
||||
|
||||
async function verifyArtifacts(artifacts) {
|
||||
const expected = [
|
||||
[CURRENT_MANIFEST_PATH, artifacts.currentManifest],
|
||||
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry],
|
||||
[RELEASE_MAPPING_PATH, artifacts.releaseMapping]
|
||||
[CURRENT_MANIFEST_PATH, artifacts.currentManifest, null],
|
||||
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry, null],
|
||||
[RELEASE_MAPPING_PATH, artifacts.releaseMapping, isToleratedReleaseMappingPrefix]
|
||||
]
|
||||
const stale = []
|
||||
for (const [filePath, value] of expected) {
|
||||
for (const [filePath, value, tolerated] of expected) {
|
||||
try {
|
||||
await access(filePath, constants.R_OK)
|
||||
if ((await readFile(filePath, 'utf8')) !== serialized(value)) {
|
||||
const committedText = await readFile(filePath, 'utf8')
|
||||
if (committedText !== serialized(value) && !tolerated?.(committedText, artifacts)) {
|
||||
stale.push(filePath)
|
||||
}
|
||||
} catch {
|
||||
@@ -493,8 +531,7 @@ async function verifyArtifacts(artifacts) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8'))
|
||||
const artifacts = await buildArtifacts(packageJson.version)
|
||||
const artifacts = await buildArtifacts()
|
||||
assertReleasedHistoryPreserved(await readCommittedRegistry(), artifacts)
|
||||
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
|
||||
}
|
||||
@@ -514,6 +551,7 @@ export {
|
||||
collectPackageFiles,
|
||||
describeFile,
|
||||
gitTreeSha,
|
||||
isToleratedReleaseMappingPrefix,
|
||||
normalizeText,
|
||||
packageDigest,
|
||||
sortManifestFiles,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
collectPackageFiles,
|
||||
describeFile,
|
||||
gitTreeSha,
|
||||
isToleratedReleaseMappingPrefix,
|
||||
normalizeText,
|
||||
packageDigest,
|
||||
sortManifestFiles
|
||||
@@ -136,6 +137,56 @@ describe('skill bundle manifest generator', () => {
|
||||
expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow()
|
||||
})
|
||||
|
||||
it('tolerates only redundant trailing release-mapping rows', () => {
|
||||
const serialized = (value) => `${JSON.stringify(value, null, 2)}\n`
|
||||
const rows = [
|
||||
{ appVersion: '1.0.0', skills: { 'orca-cli': 1 } },
|
||||
{ appVersion: '1.1.0', skills: { 'orca-cli': 2 } }
|
||||
]
|
||||
const artifacts = {
|
||||
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 2 }] },
|
||||
releaseMapping: { schemaVersion: 1, releases: rows }
|
||||
}
|
||||
const committedPrefix = serialized({ schemaVersion: 1, releases: [rows[0]] })
|
||||
|
||||
// A just-cut tag whose bytes equal the working tree may lag in the mapping.
|
||||
expect(isToleratedReleaseMappingPrefix(committedPrefix, artifacts)).toBe(true)
|
||||
// The committed file matching the derived mapping is byte-equality's job, not tolerance.
|
||||
expect(isToleratedReleaseMappingPrefix(serialized(artifacts.releaseMapping), artifacts)).toBe(
|
||||
false
|
||||
)
|
||||
// A trailing row for bytes the committed artifacts do not describe is a real gap.
|
||||
expect(
|
||||
isToleratedReleaseMappingPrefix(committedPrefix, {
|
||||
...artifacts,
|
||||
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 3 }] }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isToleratedReleaseMappingPrefix(committedPrefix, {
|
||||
...artifacts,
|
||||
currentManifest: {
|
||||
skills: [
|
||||
{ name: 'orca-cli', releaseRevision: 2 },
|
||||
{ name: 'orca-linear', releaseRevision: 1 }
|
||||
]
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
// Rewritten earlier rows never pass, with or without trailing rows.
|
||||
expect(
|
||||
isToleratedReleaseMappingPrefix(
|
||||
serialized({
|
||||
schemaVersion: 1,
|
||||
releases: [{ appVersion: '0.9.0', skills: { 'orca-cli': 1 } }]
|
||||
}),
|
||||
artifacts
|
||||
)
|
||||
).toBe(false)
|
||||
expect(isToleratedReleaseMappingPrefix('not json', artifacts)).toBe(false)
|
||||
expect(isToleratedReleaseMappingPrefix(serialized({ schemaVersion: 1 }), artifacts)).toBe(false)
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'rejects executable files in shipped skill packages',
|
||||
async () => {
|
||||
|
||||
@@ -374,7 +374,7 @@ describe('Electron runtime package contract', () => {
|
||||
expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"')
|
||||
})
|
||||
|
||||
it('keeps release-cut version commits self-healing and taggable on retries', () => {
|
||||
it('keeps release-cut version commits skill-independent and taggable on retries', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
'utf8'
|
||||
@@ -388,14 +388,13 @@ describe('Electron runtime package contract', () => {
|
||||
const bumpIndex = bumpStep.run.indexOf(
|
||||
'npm version "$VERSION" --no-git-tag-version --allow-same-version'
|
||||
)
|
||||
const generateIndex = bumpStep.run.indexOf(
|
||||
'node config/scripts/generate-skill-bundle-manifest.mjs --write'
|
||||
)
|
||||
const stageIndex = bumpStep.run.indexOf('git add package.json resources/skills')
|
||||
const stageIndex = bumpStep.run.indexOf('git add package.json')
|
||||
expect(checkoutStep.with['fetch-depth']).toBe(0)
|
||||
expect(bumpIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(generateIndex).toBeGreaterThan(bumpIndex)
|
||||
expect(stageIndex).toBeGreaterThan(generateIndex)
|
||||
expect(stageIndex).toBeGreaterThan(bumpIndex)
|
||||
// Why: version-only cuts must not mutate content-addressed skill artifacts.
|
||||
expect(bumpStep.run).not.toContain('generate-skill-bundle-manifest')
|
||||
expect(bumpStep.run).not.toContain('resources/skills')
|
||||
expect(bumpStep.run).toContain('git diff --cached --quiet')
|
||||
expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"')
|
||||
})
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"schemaVersion": 2,
|
||||
"skills": [
|
||||
{
|
||||
"name": "computer-use",
|
||||
"sourcePath": "skills/computer-use",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68",
|
||||
"gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344",
|
||||
@@ -24,7 +22,6 @@
|
||||
{
|
||||
"name": "linear-tickets",
|
||||
"sourcePath": "skills/linear-tickets",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 4,
|
||||
"packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b",
|
||||
"gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176",
|
||||
@@ -43,7 +40,6 @@
|
||||
{
|
||||
"name": "orca-cli",
|
||||
"sourcePath": "skills/orca-cli",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 32,
|
||||
"packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f",
|
||||
"gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19",
|
||||
@@ -62,7 +58,6 @@
|
||||
{
|
||||
"name": "orca-emulator",
|
||||
"sourcePath": "skills/orca-emulator",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 4,
|
||||
"packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b",
|
||||
"gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656",
|
||||
@@ -81,7 +76,6 @@
|
||||
{
|
||||
"name": "orca-emulator-android",
|
||||
"sourcePath": "skills/orca-emulator-android",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 2,
|
||||
"packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5",
|
||||
"gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83",
|
||||
@@ -100,7 +94,6 @@
|
||||
{
|
||||
"name": "orca-linear",
|
||||
"sourcePath": "skills/orca-linear",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 2,
|
||||
"packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f",
|
||||
"gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8",
|
||||
@@ -119,7 +112,6 @@
|
||||
{
|
||||
"name": "orca-per-workspace-env",
|
||||
"sourcePath": "skills/orca-per-workspace-env",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 2,
|
||||
"packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d",
|
||||
"gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650",
|
||||
@@ -138,7 +130,6 @@
|
||||
{
|
||||
"name": "orchestration",
|
||||
"sourcePath": "skills/orchestration",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 24,
|
||||
"packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9",
|
||||
"gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982",
|
||||
|
||||
@@ -15,6 +15,9 @@ const {
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getVersion: () => '9.9.9-test'
|
||||
},
|
||||
ipcMain: {
|
||||
handle: handleMock
|
||||
}
|
||||
@@ -168,7 +171,10 @@ describe('registerSkillsHandlers', () => {
|
||||
|
||||
await handler(null)
|
||||
|
||||
expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({ repos })
|
||||
expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({
|
||||
currentAppVersion: '9.9.9-test',
|
||||
repos
|
||||
})
|
||||
expect(getWslHomeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import { discoverSkills } from '../skills/discovery'
|
||||
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills'
|
||||
@@ -61,6 +61,9 @@ export function registerSkillsHandlers(store: Store): void {
|
||||
ipcMain.handle('skills:freshnessInventory', async (): Promise<SkillFreshnessInventory> => {
|
||||
// Why: the update command targets this machine's global homes. WSL and SSH
|
||||
// inventories stay out until their installer rail has an equivalent proof.
|
||||
return inventorySkillFreshness({ repos: store.getRepos() })
|
||||
return inventorySkillFreshness({
|
||||
currentAppVersion: app.getVersion(),
|
||||
repos: store.getRepos()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,6 +11,48 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('skill bundle artifacts', () => {
|
||||
it('loads the committed schema-2 artifacts and rejects a stamped schema-1 manifest', async () => {
|
||||
const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-'))
|
||||
temporaryDirectories.push(resourceRoot)
|
||||
const target = join(resourceRoot, 'skills')
|
||||
const source = resolve('resources', 'skills')
|
||||
await mkdir(target, { recursive: true })
|
||||
const [manifest, registry, releaseMapping] = await Promise.all(
|
||||
['current-manifest.json', 'snapshot-registry.json', 'release-mapping.json'].map((name) =>
|
||||
readFile(join(source, name), 'utf8')
|
||||
)
|
||||
)
|
||||
await Promise.all([
|
||||
writeFile(join(target, 'current-manifest.json'), manifest),
|
||||
writeFile(join(target, 'snapshot-registry.json'), registry),
|
||||
writeFile(join(target, 'release-mapping.json'), releaseMapping)
|
||||
])
|
||||
|
||||
const artifacts = await loadSkillBundleArtifacts(resourceRoot)
|
||||
expect(artifacts.manifest.schemaVersion).toBe(2)
|
||||
expect(artifacts.manifest.skills.length).toBeGreaterThan(0)
|
||||
|
||||
const legacyRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-'))
|
||||
temporaryDirectories.push(legacyRoot)
|
||||
const legacyTarget = join(legacyRoot, 'skills')
|
||||
await mkdir(legacyTarget, { recursive: true })
|
||||
const legacyManifest = JSON.parse(manifest)
|
||||
legacyManifest.schemaVersion = 1
|
||||
legacyManifest.appVersion = '1.0.0'
|
||||
for (const skill of legacyManifest.skills) {
|
||||
skill.appVersion = '1.0.0'
|
||||
}
|
||||
await Promise.all([
|
||||
writeFile(join(legacyTarget, 'current-manifest.json'), JSON.stringify(legacyManifest)),
|
||||
writeFile(join(legacyTarget, 'snapshot-registry.json'), registry),
|
||||
writeFile(join(legacyTarget, 'release-mapping.json'), releaseMapping)
|
||||
])
|
||||
|
||||
await expect(loadSkillBundleArtifacts(legacyRoot)).rejects.toThrow(
|
||||
'Invalid skill bundle manifest'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed nested release entries before building provenance', async () => {
|
||||
const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-'))
|
||||
temporaryDirectories.push(resourceRoot)
|
||||
|
||||
@@ -41,14 +41,12 @@ const snapshotShape = {
|
||||
const knownSnapshotSchema = z.object(snapshotShape).strict()
|
||||
const manifestSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
appVersion: z.string().min(1),
|
||||
schemaVersion: z.literal(2),
|
||||
skills: z.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/),
|
||||
sourcePath: z.string().min(1),
|
||||
appVersion: z.string().min(1),
|
||||
...snapshotShape
|
||||
})
|
||||
.strict()
|
||||
@@ -126,7 +124,6 @@ async function readSkillBundleArtifacts(resourceRoot: string): Promise<SkillBund
|
||||
)
|
||||
for (const current of manifest.skills) {
|
||||
if (
|
||||
current.appVersion !== manifest.appVersion ||
|
||||
!registry.skills[current.name]?.some(
|
||||
(snapshot) =>
|
||||
snapshot.releaseRevision === current.releaseRevision &&
|
||||
@@ -137,6 +134,8 @@ async function readSkillBundleArtifacts(resourceRoot: string): Promise<SkillBund
|
||||
}
|
||||
}
|
||||
|
||||
// Why: historical provenance only — the current revision's label is the
|
||||
// running build's version, supplied at the inventory boundary, not stored here.
|
||||
const releasedAppVersions: Record<string, Record<number, string>> = {}
|
||||
for (const release of releaseMapping.releases) {
|
||||
for (const [name, revision] of Object.entries(release.skills)) {
|
||||
@@ -147,10 +146,6 @@ async function readSkillBundleArtifacts(resourceRoot: string): Promise<SkillBund
|
||||
releasedAppVersions[name][revision] ??= release.appVersion
|
||||
}
|
||||
}
|
||||
for (const current of manifest.skills) {
|
||||
releasedAppVersions[current.name] ??= {}
|
||||
releasedAppVersions[current.name][current.releaseRevision] = current.appVersion
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
|
||||
@@ -55,13 +55,12 @@ async function fixture() {
|
||||
const current: SkillCurrentBundleEntry = {
|
||||
name: 'orca-cli',
|
||||
sourcePath: 'skills/orca-cli',
|
||||
appVersion: '2.0.0',
|
||||
...snapshots[1]
|
||||
}
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(skillResourceRoot, 'current-manifest.json'),
|
||||
`${JSON.stringify({ schemaVersion: 1, appVersion: '2.0.0', skills: [current] }, null, 2)}\n`
|
||||
`${JSON.stringify({ schemaVersion: 2, skills: [current] }, null, 2)}\n`
|
||||
),
|
||||
writeFile(
|
||||
join(skillResourceRoot, 'snapshot-registry.json'),
|
||||
@@ -111,6 +110,7 @@ describe('read-only skill freshness inventory', () => {
|
||||
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
@@ -130,6 +130,7 @@ describe('read-only skill freshness inventory', () => {
|
||||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
@@ -151,6 +152,7 @@ describe('read-only skill freshness inventory', () => {
|
||||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
@@ -176,6 +178,7 @@ describe('read-only skill freshness inventory', () => {
|
||||
await symlink(canonical, join(claudeRoot, 'orca-cli'))
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
@@ -205,6 +208,7 @@ describe('read-only skill freshness inventory', () => {
|
||||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos,
|
||||
resourceRoot: test.resourceRoot
|
||||
@@ -223,6 +227,7 @@ describe('read-only skill freshness inventory', () => {
|
||||
const inaccessiblePath = join(test.homeDir, '.codex', 'skills', 'orca-cli')
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot,
|
||||
@@ -248,6 +253,7 @@ describe('read-only skill freshness inventory', () => {
|
||||
const inaccessiblePath = join(repoPath, '.agents', 'skills', 'orca-cli')
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos: [{ id: 'repo', path: repoPath }] as unknown as Repo[],
|
||||
resourceRoot: test.resourceRoot,
|
||||
@@ -292,6 +298,7 @@ describe('read-only skill freshness inventory', () => {
|
||||
}
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos,
|
||||
resourceRoot: test.resourceRoot
|
||||
@@ -310,6 +317,7 @@ describe('read-only skill freshness inventory', () => {
|
||||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
@@ -326,7 +334,10 @@ describe('read-only skill freshness inventory', () => {
|
||||
await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`)
|
||||
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown)
|
||||
|
||||
// Why: the injected version deliberately differs from every mapping entry
|
||||
// to prove current placements are labeled by the running build, not history.
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.1.0-unreleased',
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
@@ -335,7 +346,8 @@ describe('read-only skill freshness inventory', () => {
|
||||
expect(inventory.installations[0]).toMatchObject({
|
||||
status: 'current',
|
||||
installedReleaseRevision: 2,
|
||||
installedAppVersion: '2.0.0'
|
||||
installedAppVersion: '2.1.0-unreleased',
|
||||
currentAppVersion: '2.1.0-unreleased'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -348,6 +360,7 @@ describe('read-only skill freshness inventory', () => {
|
||||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos,
|
||||
resourceRoot: test.resourceRoot
|
||||
|
||||
@@ -30,15 +30,16 @@ export function boundRepositorySkillRoots(roots: readonly SkillScanRoot[]): {
|
||||
}
|
||||
}
|
||||
|
||||
export async function inventorySkillFreshness(
|
||||
args: {
|
||||
homeDir?: string
|
||||
cwd?: string
|
||||
repos?: Repo[]
|
||||
resourceRoot?: string
|
||||
candidateLstat?: CandidateLstat
|
||||
} = {}
|
||||
): Promise<SkillFreshnessInventory> {
|
||||
export async function inventorySkillFreshness(args: {
|
||||
// Why: the bundled artifacts are content-only; the running build supplies
|
||||
// its own version here so current placements can be labeled honestly.
|
||||
currentAppVersion: string
|
||||
homeDir?: string
|
||||
cwd?: string
|
||||
repos?: Repo[]
|
||||
resourceRoot?: string
|
||||
candidateLstat?: CandidateLstat
|
||||
}): Promise<SkillFreshnessInventory> {
|
||||
const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
|
||||
const currentByName = new Map(artifacts.manifest.skills.map((skill) => [skill.name, skill]))
|
||||
const discoveryArgs = {
|
||||
@@ -65,6 +66,7 @@ export async function inventorySkillFreshness(
|
||||
classifyHomeSkillCandidate({
|
||||
root,
|
||||
current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts,
|
||||
canonicalRootPath,
|
||||
candidateLstat: args.candidateLstat ?? ((path) => lstat(path))
|
||||
@@ -84,6 +86,7 @@ export async function inventorySkillFreshness(
|
||||
classifyUnsupportedSkillCandidate({
|
||||
root,
|
||||
current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts,
|
||||
unresolvedPath: join(root.path, current.name),
|
||||
candidateLstat
|
||||
@@ -99,6 +102,7 @@ export async function inventorySkillFreshness(
|
||||
(current) => () =>
|
||||
observeSkillFreshnessInstallation({
|
||||
current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts,
|
||||
rootId: 'repo-scan-limit',
|
||||
providers: [...new Set(omittedRepoRoots.flatMap((root) => root.providers))],
|
||||
@@ -128,6 +132,7 @@ export async function inventorySkillFreshness(
|
||||
classifyUnsupportedSkillCandidate({
|
||||
root,
|
||||
current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts,
|
||||
unresolvedPath: candidate.path,
|
||||
candidateLstat
|
||||
@@ -142,6 +147,7 @@ export async function inventorySkillFreshness(
|
||||
(current) => () =>
|
||||
observeSkillFreshnessInstallation({
|
||||
current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts,
|
||||
rootId: root.id,
|
||||
providers: root.providers,
|
||||
|
||||
@@ -57,6 +57,7 @@ function knownSnapshots(
|
||||
|
||||
export async function observeSkillFreshnessInstallation(args: {
|
||||
current: SkillCurrentBundleEntry
|
||||
currentAppVersion: string
|
||||
artifacts: SkillBundleArtifacts
|
||||
rootId: string
|
||||
providers: SkillFreshnessInstallation['providers']
|
||||
@@ -78,7 +79,7 @@ export async function observeSkillFreshnessInstallation(args: {
|
||||
topology: args.topology.topology,
|
||||
currentReleaseRevision: args.current.releaseRevision,
|
||||
currentPackageDigest: args.current.packageDigest,
|
||||
currentAppVersion: args.current.appVersion,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
errorCategory: args.topology.errorCategory
|
||||
}
|
||||
if (!args.topology.resolvedPath || !args.topology.identity) {
|
||||
@@ -105,9 +106,14 @@ export async function observeSkillFreshnessInstallation(args: {
|
||||
...base,
|
||||
status: freshnessStatus(snapshot, args.current),
|
||||
installedReleaseRevision: snapshot?.releaseRevision ?? null,
|
||||
// Why: the current revision may be unreleased (or trail the newest tag),
|
||||
// so its label is the running build's version; only historical revisions
|
||||
// resolve through the release mapping.
|
||||
installedAppVersion: snapshot
|
||||
? (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ??
|
||||
null)
|
||||
? snapshot.releaseRevision === args.current.releaseRevision
|
||||
? args.currentAppVersion
|
||||
: (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ??
|
||||
null)
|
||||
: null,
|
||||
observedPackageDigest: observed.observedDigest
|
||||
}
|
||||
@@ -126,6 +132,7 @@ export async function observeSkillFreshnessInstallation(args: {
|
||||
export async function classifyHomeSkillCandidate(args: {
|
||||
root: SkillScanRoot
|
||||
current: SkillCurrentBundleEntry
|
||||
currentAppVersion: string
|
||||
artifacts: SkillBundleArtifacts
|
||||
canonicalRootPath: string
|
||||
candidateLstat: CandidateLstat
|
||||
@@ -139,6 +146,7 @@ export async function classifyHomeSkillCandidate(args: {
|
||||
}
|
||||
return observeSkillFreshnessInstallation({
|
||||
current: args.current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts: args.artifacts,
|
||||
rootId: args.root.id,
|
||||
providers: args.root.providers,
|
||||
@@ -167,6 +175,7 @@ export async function classifyHomeSkillCandidate(args: {
|
||||
}
|
||||
return observeSkillFreshnessInstallation({
|
||||
current: args.current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts: args.artifacts,
|
||||
rootId: args.root.id,
|
||||
providers: args.root.providers,
|
||||
@@ -180,6 +189,7 @@ export async function classifyHomeSkillCandidate(args: {
|
||||
export async function classifyUnsupportedSkillCandidate(args: {
|
||||
root: SkillScanRoot
|
||||
current: SkillCurrentBundleEntry
|
||||
currentAppVersion: string
|
||||
artifacts: SkillBundleArtifacts
|
||||
unresolvedPath: string
|
||||
candidateLstat: CandidateLstat
|
||||
@@ -192,6 +202,7 @@ export async function classifyUnsupportedSkillCandidate(args: {
|
||||
}
|
||||
return observeSkillFreshnessInstallation({
|
||||
current: args.current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts: args.artifacts,
|
||||
rootId: args.root.id,
|
||||
providers: args.root.providers,
|
||||
@@ -208,6 +219,7 @@ export async function classifyUnsupportedSkillCandidate(args: {
|
||||
}
|
||||
return observeSkillFreshnessInstallation({
|
||||
current: args.current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts: args.artifacts,
|
||||
rootId: args.root.id,
|
||||
providers: args.root.providers,
|
||||
|
||||
@@ -258,6 +258,20 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
).toBe('claude')
|
||||
})
|
||||
|
||||
// Why: #8478 — OpenCode native `OC | …` titles must reclaim a stale Claude
|
||||
// launch identity so the tab icon is OpenCode, not Claude.
|
||||
it('uses OpenCode native session titles to replace stale Claude launch identity', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
hasObservedAgentSignal: true,
|
||||
isRemote: false,
|
||||
title: 'OC | Understand about the plugin',
|
||||
hookAgent: null,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBe('opencode')
|
||||
})
|
||||
|
||||
it('does not let an explicit title override launch identity before any activity is observed', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeState(overrides: Partial<AppState> = {}): AppState {
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
browserCertificateFailuresByPageId: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
activeTabId: null,
|
||||
|
||||
@@ -26,6 +26,7 @@ function makeState(overrides: Partial<AppState> = {}): AppState {
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
browserCertificateFailuresByPageId: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
activeTabId: null,
|
||||
@@ -60,6 +61,7 @@ function makeSharedOverrides(): Partial<AppState> {
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
browserCertificateFailuresByPageId: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
agentStatusByPaneKey: {},
|
||||
|
||||
@@ -1496,7 +1496,7 @@ function buildMobileBrowserTab(
|
||||
// back through ?? would resurrect a stale workspace-level error.
|
||||
loadError: activePage ? activePage.loadError : workspace.loadError,
|
||||
certificateFailure: activePage
|
||||
? (state.browserCertificateFailuresByPageId[activePage.id] ?? null)
|
||||
? (state.browserCertificateFailuresByPageId?.[activePage.id] ?? null)
|
||||
: null,
|
||||
color: unifiedTab?.color ?? null,
|
||||
isPinned: unifiedTab?.isPinned === true,
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
STRONG_IDLE_KEYWORDS_RE,
|
||||
STRONG_WORKING_KEYWORDS_RE
|
||||
} from './agent-title-core'
|
||||
export { isOpenCodeNativeTitle, isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title'
|
||||
export { getAgentLabel, isClaudeAgent } from './agent-title-identity'
|
||||
export {
|
||||
clearWorkingIndicators,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isPiAgentTitle,
|
||||
titleHasAgentName
|
||||
} from './agent-title-core'
|
||||
import { isOpenCodeNativeTitle } from './opencode-terminal-title'
|
||||
import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title'
|
||||
|
||||
/**
|
||||
@@ -17,7 +18,7 @@ import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-ti
|
||||
* Used to scope prompt-cache-timer behavior to Claude sessions only.
|
||||
*/
|
||||
export function isClaudeAgent(title: string): boolean {
|
||||
if (!title || isClaudeManagementTitle(title)) {
|
||||
if (!title || isClaudeManagementTitle(title) || isOpenCodeNativeTitle(title)) {
|
||||
return false
|
||||
}
|
||||
const lower = title.toLowerCase()
|
||||
@@ -46,6 +47,11 @@ export function getAgentLabel(title: string): string | null {
|
||||
if (isClaudeManagementTitle(title)) {
|
||||
return null
|
||||
}
|
||||
// Why: the native marker owns the whole title; its session text may name or
|
||||
// include status glyphs from other agents without changing OpenCode identity.
|
||||
if (isOpenCodeNativeTitle(title)) {
|
||||
return 'OpenCode'
|
||||
}
|
||||
// Why: Claude task titles can mention another CLI; the prefix is the identity
|
||||
// signal, not arbitrary task text.
|
||||
if (
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title'
|
||||
import { isMeaningfulOpenCodeTerminalTitle, isOpenCodeNativeTitle } from './opencode-terminal-title'
|
||||
|
||||
describe('OpenCode terminal titles', () => {
|
||||
it('recognizes native session titles', () => {
|
||||
expect(isMeaningfulOpenCodeTerminalTitle('OC | Native Stable Session')).toBe(true)
|
||||
expect(isMeaningfulOpenCodeTerminalTitle(' OC|Session ')).toBe(true)
|
||||
expect(isOpenCodeNativeTitle('OC | Understand about the plugin')).toBe(true)
|
||||
expect(isOpenCodeNativeTitle('tmux | OC | ses_123')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects generic or incomplete OpenCode titles', () => {
|
||||
it('rejects generic, incomplete, embedded, and lookalike titles', () => {
|
||||
expect(isMeaningfulOpenCodeTerminalTitle('OpenCode')).toBe(false)
|
||||
expect(isMeaningfulOpenCodeTerminalTitle('OpenCode ready')).toBe(false)
|
||||
expect(isMeaningfulOpenCodeTerminalTitle('OC |')).toBe(false)
|
||||
expect(isMeaningfulOpenCodeTerminalTitle(undefined)).toBe(false)
|
||||
// Why: lowercase is not OpenCode's native marker; avoid "oc |" cwd/task noise.
|
||||
expect(isOpenCodeNativeTitle('oc | Understand about the plugin')).toBe(false)
|
||||
// Why: mid-title OC must not steal another agent's braille/task frame.
|
||||
expect(isOpenCodeNativeTitle('⠋ Fix foo | OC | bar')).toBe(false)
|
||||
expect(isOpenCodeNativeTitle('my session | OC | task')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
// Why: OpenCode abbreviates native OSC session titles as `OC | <task>` (no
|
||||
// agent-name token). Optional single-token multiplexer prefix covers SSH/tmux
|
||||
// frames like `tmux | OC | …`. Case-sensitive `OC` avoids ordinary lowercase
|
||||
// "oc" lookalikes; require non-whitespace after the marker so bare `OC |` is not
|
||||
// identity. Used for both display-title preservation and tab-agent identity.
|
||||
const OPENCODE_NATIVE_TITLE_RE = /^(?:[^|\s]+ \| )?OC\s*\|\s*\S/u
|
||||
|
||||
export function isOpenCodeNativeTitle(title: string | null | undefined): boolean {
|
||||
return OPENCODE_NATIVE_TITLE_RE.test(title?.trim() ?? '')
|
||||
}
|
||||
|
||||
export function isMeaningfulOpenCodeTerminalTitle(title: string | null | undefined): boolean {
|
||||
// Why: bare OpenCode labels are status/identity; `OC | …` carries native session identity.
|
||||
return /^OC\s*\|\s*\S/u.test(title?.trim() ?? '')
|
||||
return isOpenCodeNativeTitle(title)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Issue #8478 — OpenCode logo / icon not coming up well (Claude glyph on
|
||||
* OpenCode tabs).
|
||||
*
|
||||
* Root cause (pre-fix): OpenCode's native OSC tab title format is `OC | <task>`.
|
||||
* Title classifiers only recognized OpenCode when the token "opencode" appeared.
|
||||
* Native `OC | …` titles fell through, so AgentIcon rendered Claude/"?".
|
||||
*
|
||||
* OpenCode source (packages/tui/src/app.tsx) sets titles as:
|
||||
* - "OpenCode" on home / default session titles
|
||||
* - `OC | ${title}` for named sessions (title truncated at 40 chars)
|
||||
* - `OC | ${pluginId}` on plugin routes
|
||||
*
|
||||
* Related: #8940 (OpenCode activity frames mislabeled Claude Code — separate
|
||||
* braille-without-token path; not fully solved here).
|
||||
*
|
||||
* Re-run:
|
||||
* pnpm exec vitest run --config config/vitest.config.ts \
|
||||
* src/shared/repro-8478-opencode-native-title-icon.test.ts
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAgentLabel, isClaudeAgent } from './agent-detection'
|
||||
import {
|
||||
resolveExplicitTerminalTitleAgentType,
|
||||
resolveTerminalTitleAgentType
|
||||
} from './terminal-title-agent-type'
|
||||
|
||||
describe('#8478 OpenCode native OC | titles map to OpenCode icon', () => {
|
||||
it('recognizes OpenCode native "OC | …" title format as opencode identity', () => {
|
||||
const native = 'OC | Understand about the plugin'
|
||||
expect(getAgentLabel(native)).toBe('OpenCode')
|
||||
expect(resolveTerminalTitleAgentType(native)).toBe('opencode')
|
||||
expect(resolveExplicitTerminalTitleAgentType(native)).toBe('opencode')
|
||||
expect(isClaudeAgent(native)).toBe(false)
|
||||
})
|
||||
|
||||
// Why: bare "OpenCode" is what the TUI sets on home/default sessions; keep it
|
||||
// classified so the icon path stays OpenCode without the OC | abbreviation.
|
||||
it('keeps bare OpenCode home titles and Claude-style frames classified correctly', () => {
|
||||
expect(getAgentLabel('OpenCode')).toBe('OpenCode')
|
||||
expect(resolveTerminalTitleAgentType('OpenCode')).toBe('opencode')
|
||||
expect(getAgentLabel('OpenCode ready')).toBe('OpenCode')
|
||||
expect(resolveTerminalTitleAgentType('OpenCode ready')).toBe('opencode')
|
||||
// Same family as #8940: braille/task frames without "opencode" still become Claude
|
||||
// when they lack the native OC marker — documented, out of scope for this fix.
|
||||
expect(isClaudeAgent('⠋ implementing the feature')).toBe(true)
|
||||
expect(getAgentLabel('⠋ implementing the feature')).toBe('Claude Code')
|
||||
})
|
||||
})
|
||||
@@ -20,12 +20,12 @@ export type SkillKnownSnapshot = {
|
||||
export type SkillCurrentBundleEntry = SkillKnownSnapshot & {
|
||||
name: string
|
||||
sourcePath: string
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
// Why: schema 2 removed the stamped app version so the committed artifact is a
|
||||
// pure function of skills/ content; the running build supplies its own version.
|
||||
export type SkillBundleManifest = {
|
||||
schemaVersion: 1
|
||||
appVersion: string
|
||||
schemaVersion: 2
|
||||
skills: SkillCurrentBundleEntry[]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAgentLabel as getSharedAgentLabel } from './agent-title-identity'
|
||||
import { isOpenCodeNativeTitle } from './opencode-terminal-title'
|
||||
import {
|
||||
isClaudeAgent,
|
||||
isGrokRotatingWorkingTitle,
|
||||
@@ -51,6 +53,39 @@ describe('resolveExplicitTerminalTitleAgentType', () => {
|
||||
expect(resolveExplicitTerminalTitleAgentType('* Review Codex behavior')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves OpenCode native abbreviated session titles before task-text identities', () => {
|
||||
expect(resolveExplicitTerminalTitleAgentType('OC | Understand about the plugin')).toBe(
|
||||
'opencode'
|
||||
)
|
||||
expect(resolveExplicitTerminalTitleAgentType('OC | Compare Codex and Claude')).toBe('opencode')
|
||||
// Why: Gemini glyphs inside OpenCode session text must not rebrand the tab.
|
||||
expect(resolveExplicitTerminalTitleAgentType('OC | ✦ Gemini CLI')).toBe('opencode')
|
||||
expect(getSharedAgentLabel('OC | Compare Codex and Claude')).toBe('OpenCode')
|
||||
expect(getSharedAgentLabel('OC | ✦ Gemini CLI')).toBe('OpenCode')
|
||||
expect(resolveExplicitTerminalTitleAgentType('tmux | OC | ses_123')).toBe('opencode')
|
||||
expect(resolveExplicitTerminalTitleAgentType('OC|compact-session')).toBe('opencode')
|
||||
expect(resolveExplicitTerminalTitleAgentType('oc | Understand about the plugin')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not find an OpenCode marker inside another agent task title', () => {
|
||||
expect(isOpenCodeNativeTitle('⠋ Fix foo | OC | bar')).toBe(false)
|
||||
expect(resolveExplicitTerminalTitleAgentType('⠋ Fix foo | OC | bar')).toBeNull()
|
||||
})
|
||||
|
||||
// Why: adversarial coverage — native OC must not steal Claude/Codex/Cursor/
|
||||
// Gemini/Pi identity, and those agents must keep resolving when titled normally.
|
||||
it('keeps other agents classified correctly alongside OpenCode native titles', () => {
|
||||
expect(resolveExplicitTerminalTitleAgentType('✳ Claude Code')).toBe('claude')
|
||||
expect(resolveExplicitTerminalTitleAgentType('⠋ Codex')).toBe('codex')
|
||||
expect(resolveExplicitTerminalTitleAgentType('✦ Gemini CLI')).toBe('gemini')
|
||||
expect(resolveExplicitTerminalTitleAgentType('Cursor Agent')).toBe('cursor')
|
||||
expect(resolveExplicitTerminalTitleAgentType('Pi ready')).toBe('pi')
|
||||
expect(resolveExplicitTerminalTitleAgentType('OpenCode ready')).toBe('opencode')
|
||||
expect(resolveTerminalTitleAgentType('OC | ⠋ implementing the feature')).toBe('opencode')
|
||||
expect(isClaudeAgent('OC | ⠋ implementing the feature')).toBe(false)
|
||||
expect(isClaudeAgent('OC | Understand about the plugin')).toBe(false)
|
||||
})
|
||||
|
||||
it('still resolves Claude when the title explicitly names Claude', () => {
|
||||
expect(resolveExplicitTerminalTitleAgentType('. Claude Code compare Opencode')).toBe('claude')
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
titleHasAgentName
|
||||
} from './agent-name-token-match'
|
||||
import { isCursorAgentTitle } from './agent-title-core'
|
||||
import { isOpenCodeNativeTitle } from './opencode-terminal-title'
|
||||
import {
|
||||
getPiCompatibleSyntheticAgentLabel,
|
||||
isLegacyPiCompatibleTitle
|
||||
@@ -80,7 +81,7 @@ export function isPiAgentTitle(title: string): boolean {
|
||||
* agents have different (or no) caching semantics.
|
||||
*/
|
||||
export function isClaudeAgent(title: string): boolean {
|
||||
if (!title || isClaudeManagementTitle(title)) {
|
||||
if (!title || isClaudeManagementTitle(title) || isOpenCodeNativeTitle(title)) {
|
||||
return false
|
||||
}
|
||||
const lower = title.toLowerCase()
|
||||
@@ -124,6 +125,11 @@ export function getAgentLabel(title: string): string | null {
|
||||
if (isClaudeManagementTitle(title)) {
|
||||
return null
|
||||
}
|
||||
// Why: the native marker owns the whole title; its session text may name or
|
||||
// include status glyphs from other agents without changing OpenCode identity.
|
||||
if (isOpenCodeNativeTitle(title)) {
|
||||
return 'OpenCode'
|
||||
}
|
||||
// Why: Claude Code title text is often the task title. If that task mentions
|
||||
// another CLI, the Claude-specific prefix is the identity signal, not the words.
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user