Merge remote-tracking branch 'origin/master' into worktree/skill-invocation-controls
This commit is contained in:
@@ -44,6 +44,21 @@ export function apply(ctx: ClientContext): void {
|
||||
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:
|
||||
// the fiber effect below is its teardown boundary.
|
||||
const fetches = new Map<SessionId, CatalogFetch>()
|
||||
// Per-session lexicon invalidation listeners (subscribeLexicon consumers).
|
||||
const lexiconListeners = new Map<SessionId, Set<() => void>>()
|
||||
|
||||
const notifyLexicon = (sessionId: SessionId): void => {
|
||||
for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) {
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
// Contain listener failures: settlement notifies from an ignored
|
||||
// promise chain (a throw would surface as an unhandled rejection)
|
||||
// and one faulty consumer must not starve the others.
|
||||
console.error('[ui-skill] lexicon listener failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => {
|
||||
const existing = fetches.get(sessionId)
|
||||
@@ -58,7 +73,10 @@ export function apply(ctx: ClientContext): void {
|
||||
fetches.set(sessionId, entry)
|
||||
promise.then(
|
||||
// Settled snapshot backs the synchronous lexicon reads.
|
||||
(skills) => { entry.settled = skills },
|
||||
(skills) => {
|
||||
entry.settled = skills
|
||||
notifyLexicon(sessionId)
|
||||
},
|
||||
// A failed fetch must not poison the key: the next consumer retries.
|
||||
() => {
|
||||
if (fetches.get(sessionId) === entry) fetches.delete(sessionId)
|
||||
@@ -72,6 +90,7 @@ export function apply(ctx: ClientContext): void {
|
||||
if (entry === undefined) return
|
||||
fetches.delete(key)
|
||||
entry.abort.abort()
|
||||
notifyLexicon(key)
|
||||
}
|
||||
|
||||
const clearAll = (): void => {
|
||||
@@ -97,6 +116,16 @@ export function apply(ctx: ClientContext): void {
|
||||
lexicon(session) {
|
||||
return fetches.get(session.sessionId)?.settled?.map(skill => skill.name)
|
||||
},
|
||||
subscribeLexicon(session, listener) {
|
||||
const key = session.sessionId
|
||||
const listeners = lexiconListeners.get(key) ?? new Set()
|
||||
listeners.add(listener)
|
||||
lexiconListeners.set(key, listeners)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (listeners.size === 0) lexiconListeners.delete(key)
|
||||
}
|
||||
},
|
||||
onPick({ candidate }) {
|
||||
// Decision 21: plain-text reference — the literal lands in the draft
|
||||
// and ships to the model verbatim (trailing space closes the token).
|
||||
|
||||
@@ -208,6 +208,33 @@ describe('lexicon', () => {
|
||||
// Another session's key is independent — cold until its own fetch.
|
||||
expect(source.lexicon!(proj('s2'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('subscribeLexicon notifies on catalog settle and on invalidation, per session', async () => {
|
||||
const { list } = countingList()
|
||||
const { ctx, source } = await bench(list)
|
||||
const s1 = vi.fn()
|
||||
const s2 = vi.fn()
|
||||
source.subscribeLexicon!(proj('s1'), s1)
|
||||
source.subscribeLexicon!(proj('s2'), s2)
|
||||
await source.candidates(proj('s1'), req(''))
|
||||
expect(s1).toHaveBeenCalledTimes(1)
|
||||
expect(s2).not.toHaveBeenCalled()
|
||||
// Reset invalidates every cached session: each key notifies its own listeners.
|
||||
await source.candidates(proj('s2'), req(''))
|
||||
ctx.emit('connection/reset')
|
||||
expect(s1).toHaveBeenCalledTimes(2)
|
||||
expect(s2).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('an unsubscribed lexicon listener stops receiving notifications', async () => {
|
||||
const { list } = countingList()
|
||||
const { source } = await bench(list)
|
||||
const listener = vi.fn()
|
||||
const off = source.subscribeLexicon!(proj('s1'), listener)
|
||||
off()
|
||||
await source.candidates(proj('s1'), req(''))
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pick and codec', () => {
|
||||
|
||||
Reference in New Issue
Block a user