Merge branch 'codex/simp-unify-agent-session-id' into codex/simp-ui-identity-residue
This commit is contained in:
@@ -30,15 +30,16 @@ setFactory(factory: AgentFactory): () => void
|
|||||||
async create(options: CreateAgentOptions): Promise<AgentHandle>
|
async create(options: CreateAgentOptions): Promise<AgentHandle>
|
||||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle>
|
async resume(options: ResumeAgentOptions): Promise<AgentHandle>
|
||||||
register(agent: Agent): () => void
|
register(agent: Agent): () => void
|
||||||
enter(agent: Agent): () => void
|
enter(agent: Agent, owner: Agent | undefined): () => void
|
||||||
announce(agent: Agent): void
|
announce(agent: Agent): void
|
||||||
get(id: SessionId): Agent | undefined
|
get(id: SessionId): Agent | undefined
|
||||||
list(): Agent[]
|
list(): Agent[]
|
||||||
|
roots(): Agent[]
|
||||||
```
|
```
|
||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/core/agent/src/index.ts:199`](../../packages/core/agent/src/index.ts)
|
Source: [`packages/core/agent/src/index.ts:201`](../../packages/core/agent/src/index.ts)
|
||||||
|
|
||||||
## `ctx.approval` — `ApprovalService`
|
## `ctx.approval` — `ApprovalService`
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ class AgentCreationTransaction {
|
|||||||
this.publishing = true
|
this.publishing = true
|
||||||
try {
|
try {
|
||||||
this.detachSession = agent.ctx.sessions.enter(session)
|
this.detachSession = agent.ctx.sessions.enter(session)
|
||||||
this.detachAgent = this.loopCtx.agents.enter(agent)
|
this.detachAgent = this.loopCtx.agents.enter(agent, this.ownerAgent)
|
||||||
|
|
||||||
agent.ctx.sessions.announce(session)
|
agent.ctx.sessions.announce(session)
|
||||||
this.assertActive()
|
this.assertActive()
|
||||||
|
|||||||
@@ -149,6 +149,24 @@ describe('agent scope lifecycle', () => {
|
|||||||
await ctx.agents.get(SessionId('a1'))?.whenIdle()
|
await ctx.agents.get(SessionId('a1'))?.whenIdle()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('records agents created through an agent context as non-root runtime children', async () => {
|
||||||
|
const ctx = await harness()
|
||||||
|
const root = await ctx.agents.create({
|
||||||
|
sessionId: SessionId('runtime-root'),
|
||||||
|
agentOptions: { model: 'mock' },
|
||||||
|
})
|
||||||
|
const child = await root.agent.ctx.agents.create({
|
||||||
|
sessionId: SessionId('runtime-child'),
|
||||||
|
agentOptions: { model: 'mock' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(ctx.agents.list()).toEqual([root.agent, child.agent])
|
||||||
|
expect(ctx.agents.roots()).toEqual([root.agent])
|
||||||
|
|
||||||
|
await child.dispose()
|
||||||
|
await root.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
it('scoped registrations live in the agent world and die with the agent', async () => {
|
it('scoped registrations live in the agent world and die with the agent', async () => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
|
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
|
|||||||
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
|
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
|
||||||
|
|
||||||
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
|
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
|
||||||
- Advanced ordered lifecycle: `enter(agent): () => void` performs the authoritative ID collision check and inserts without announcing; `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.
|
- Advanced ordered lifecycle: `enter(agent, owner): () => void` performs the authoritative ID collision check and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.
|
||||||
- `ctx.agents.get(id: SessionId): Agent | undefined`
|
- `ctx.agents.get(id: SessionId): Agent | undefined`
|
||||||
- `ctx.agents.list(): Agent[]`
|
- `ctx.agents.list(): Agent[]`
|
||||||
|
- `ctx.agents.roots(): Agent[]` — live agents created without an owning agent context; a resumed lineage-bearing session can still be a runtime root.
|
||||||
|
|
||||||
#### Factory seam (creation)
|
#### Factory seam (creation)
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug
|
|||||||
interface AgentEntry {
|
interface AgentEntry {
|
||||||
readonly id: SessionId
|
readonly id: SessionId
|
||||||
readonly agent: Agent
|
readonly agent: Agent
|
||||||
|
/** Runtime creator-agent ownership; independent of durable session lineage. */
|
||||||
|
readonly owner: Agent | undefined
|
||||||
readonly carrier: Scoped<Agent>
|
readonly carrier: Scoped<Agent>
|
||||||
announced: boolean
|
announced: boolean
|
||||||
announcing: boolean
|
announcing: boolean
|
||||||
@@ -303,7 +305,7 @@ export class AgentRegistry extends Service {
|
|||||||
*/
|
*/
|
||||||
register(agent: Agent): () => void {
|
register(agent: Agent): () => void {
|
||||||
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
|
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
|
||||||
yield this.enter(agent)
|
yield this.enter(agent, this.ctx.agent)
|
||||||
this.announce(agent)
|
this.announce(agent)
|
||||||
}.bind(this), 'agents.register()')
|
}.bind(this), 'agents.register()')
|
||||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||||
@@ -317,12 +319,15 @@ export class AgentRegistry extends Service {
|
|||||||
* returned detach closure into its pre-installed composite teardown before
|
* returned detach closure into its pre-installed composite teardown before
|
||||||
* calling {@link announce}. Ordinary callers use {@link register}.
|
* calling {@link announce}. Ordinary callers use {@link register}.
|
||||||
* @param agent - the prepared, unpublished agent.
|
* @param agent - the prepared, unpublished agent.
|
||||||
|
* @param owner - live agent whose scoped context created this agent, or
|
||||||
|
* undefined for a top-level runtime root. This is runtime ownership, not
|
||||||
|
* the resumed session's durable parent lineage.
|
||||||
* @returns an idempotent closure that removes this exact entry and emits
|
* @returns an idempotent closure that removes this exact entry and emits
|
||||||
* `agent/disposed` with listener failures contained. When called from a
|
* `agent/disposed` with listener failures contained. When called from a
|
||||||
* synchronous `agent/created` listener, removal and disposal wait until
|
* synchronous `agent/created` listener, removal and disposal wait until
|
||||||
* that creation dispatch unwinds.
|
* that creation dispatch unwinds.
|
||||||
*/
|
*/
|
||||||
enter(agent: Agent): () => void {
|
enter(agent: Agent, owner: Agent | undefined): () => void {
|
||||||
const id = agent.id
|
const id = agent.id
|
||||||
const carrier = scopeTarget(agent, agent)
|
const carrier = scopeTarget(agent, agent)
|
||||||
// This is the authoritative collision boundary. Concurrent create/resume
|
// This is the authoritative collision boundary. Concurrent create/resume
|
||||||
@@ -331,6 +336,7 @@ export class AgentRegistry extends Service {
|
|||||||
const entry: AgentEntry = {
|
const entry: AgentEntry = {
|
||||||
id,
|
id,
|
||||||
agent,
|
agent,
|
||||||
|
owner,
|
||||||
carrier,
|
carrier,
|
||||||
announced: false,
|
announced: false,
|
||||||
announcing: false,
|
announcing: false,
|
||||||
@@ -438,6 +444,18 @@ export class AgentRegistry extends Service {
|
|||||||
list(): Agent[] {
|
list(): Agent[] {
|
||||||
return [...this.store.values()].map(entry => entry.agent)
|
return [...this.store.values()].map(entry => entry.agent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All live top-level agents in registration order. A top-level agent was
|
||||||
|
* created without an owning agent context; durable session lineage does not
|
||||||
|
* affect this runtime relation, so a resumed fork may still be a root.
|
||||||
|
* @returns a fresh array; mutating it does not affect the registry.
|
||||||
|
*/
|
||||||
|
roots(): Agent[] {
|
||||||
|
return [...this.store.values()]
|
||||||
|
.filter(entry => entry.owner === undefined)
|
||||||
|
.map(entry => entry.agent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AgentRegistry
|
export default AgentRegistry
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ describe('AgentRegistry', () => {
|
|||||||
const dispose = ctx.agents.register(agent)
|
const dispose = ctx.agents.register(agent)
|
||||||
expect(ctx.agents.get(agent.id)).toBe(agent)
|
expect(ctx.agents.get(agent.id)).toBe(agent)
|
||||||
expect(ctx.agents.list()).toEqual([agent])
|
expect(ctx.agents.list()).toEqual([agent])
|
||||||
|
expect(ctx.agents.roots()).toEqual([agent])
|
||||||
expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/)
|
expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/)
|
||||||
|
|
||||||
dispose()
|
dispose()
|
||||||
@@ -49,6 +50,23 @@ describe('AgentRegistry', () => {
|
|||||||
expect(lifecycle).toEqual(['created:a1', 'disposed:a1'])
|
expect(lifecycle).toEqual(['created:a1', 'disposed:a1'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('tracks runtime creator ownership separately from registry order', async () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(AgentRegistry)
|
||||||
|
const root = stubAgent('root')
|
||||||
|
const child = stubAgent('child')
|
||||||
|
const detachRoot = ctx.agents.enter(root, undefined)
|
||||||
|
ctx.agents.announce(root)
|
||||||
|
const detachChild = ctx.agents.enter(child, root)
|
||||||
|
ctx.agents.announce(child)
|
||||||
|
|
||||||
|
expect(ctx.agents.list()).toEqual([root, child])
|
||||||
|
expect(ctx.agents.roots()).toEqual([root])
|
||||||
|
|
||||||
|
detachChild()
|
||||||
|
detachRoot()
|
||||||
|
})
|
||||||
|
|
||||||
it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => {
|
it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
@@ -94,7 +112,7 @@ describe('AgentRegistry', () => {
|
|||||||
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
|
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
|
||||||
|
|
||||||
const first = stubAgent('split')
|
const first = stubAgent('split')
|
||||||
const detachFirst = ctx.agents.enter(first)
|
const detachFirst = ctx.agents.enter(first, undefined)
|
||||||
expect(lifecycle).toEqual([])
|
expect(lifecycle).toEqual([])
|
||||||
ctx.agents.announce(first)
|
ctx.agents.announce(first)
|
||||||
expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/)
|
expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/)
|
||||||
@@ -102,7 +120,7 @@ describe('AgentRegistry', () => {
|
|||||||
detachFirst()
|
detachFirst()
|
||||||
|
|
||||||
const replacement = stubAgent('split')
|
const replacement = stubAgent('split')
|
||||||
const detachReplacement = ctx.agents.enter(replacement)
|
const detachReplacement = ctx.agents.enter(replacement, undefined)
|
||||||
detachFirst()
|
detachFirst()
|
||||||
expect(ctx.agents.get(replacement.id)).toBe(replacement)
|
expect(ctx.agents.get(replacement.id)).toBe(replacement)
|
||||||
expect(() => { ctx.agents.announce(first) }).toThrow(/not live/)
|
expect(() => { ctx.agents.announce(first) }).toThrow(/not live/)
|
||||||
@@ -122,7 +140,7 @@ describe('AgentRegistry', () => {
|
|||||||
})
|
})
|
||||||
ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`))
|
ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`))
|
||||||
ctx.on('agent/disposed', () => void order.push('disposed'))
|
ctx.on('agent/disposed', () => void order.push('disposed'))
|
||||||
const detach = ctx.agents.enter(agent)
|
const detach = ctx.agents.enter(agent, undefined)
|
||||||
ctx.agents.announce(agent)
|
ctx.agents.announce(agent)
|
||||||
expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed'])
|
expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed'])
|
||||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||||
|
|||||||
@@ -95,18 +95,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
|||||||
const welcome = config.welcome ?? 'ready.'
|
const welcome = config.welcome ?? 'ready.'
|
||||||
const { input, output, exit } = runtime
|
const { input, output, exit } = runtime
|
||||||
|
|
||||||
// This app owns one configured agent. Hold the live object directly: its
|
// This app owns one configured top-level agent. Hold the live object
|
||||||
// per-run id is intentionally fresh, while `main` remains only the
|
// directly: its per-run id is intentionally fresh, while `main` remains only
|
||||||
// terminal's fixed display label. At install the configured agent is the
|
// the terminal's fixed display label. Runtime creator ownership distinguishes
|
||||||
// earliest registry entry (it creates any subagents later). During HMR the
|
// that root from its subagents even if a child is registered after an HMR
|
||||||
// replacement is published after the old tree, so when old teardown finally
|
// replacement. Persisted parentSession lineage is deliberately irrelevant:
|
||||||
// emits disposed, the newest survivor is the replacement. Persisted
|
// a resumed child session can itself be this process's configured root.
|
||||||
// parentSession lineage is deliberately irrelevant: a resumed child session
|
let target: Agent | undefined = ctx.agents.roots()[0]
|
||||||
// can itself be this process's configured top-level agent.
|
ctx.on('agent/created', () => { target ??= ctx.agents.roots()[0] })
|
||||||
let target: Agent | undefined = ctx.agents.list()[0]
|
|
||||||
ctx.on('agent/created', (agent) => { target ??= agent })
|
|
||||||
ctx.on('agent/disposed', (agent) => {
|
ctx.on('agent/disposed', (agent) => {
|
||||||
if (target === agent) target = ctx.agents.list().at(-1)
|
if (target === agent) target = ctx.agents.roots().at(-1)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Transcript rendering off the durable `session/event` feed — the assistant
|
// Transcript rendering off the durable `session/event` feed — the assistant
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ function fakeContext(): Context {
|
|||||||
return {
|
return {
|
||||||
on: vi.fn(() => vi.fn()),
|
on: vi.fn(() => vi.fn()),
|
||||||
effect: vi.fn((callback: () => () => void) => callback()),
|
effect: vi.fn((callback: () => () => void) => callback()),
|
||||||
// The UI seeds its target object from the registry at install; this suite only
|
// The UI seeds its root target from the registry at install; this suite only
|
||||||
// exercises readline terminal-mode selection, so an empty roster suffices.
|
// exercises readline terminal-mode selection, so an empty roster suffices.
|
||||||
agents: { list: vi.fn(() => []) },
|
agents: { roots: vi.fn(() => []) },
|
||||||
userInteraction: { registerProvider: vi.fn(() => vi.fn()) },
|
userInteraction: { registerProvider: vi.fn(() => vi.fn()) },
|
||||||
} as unknown as Context
|
} as unknown as Context
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,8 +154,7 @@ describe('createStdioChat rendering', () => {
|
|||||||
it('renders turn/start and turn/end markers from the session feed', async () => {
|
it('renders turn/start and turn/end markers from the session feed', async () => {
|
||||||
const { ctx, out } = await setup()
|
const { ctx, out } = await setup()
|
||||||
const agent = makeAgent('main')
|
const agent = makeAgent('main')
|
||||||
// agent/created supplies the app-owned target object.
|
ctx.agents.register(agent)
|
||||||
ctx.emit('agent/created', agent)
|
|
||||||
const session = agent.session
|
const session = agent.session
|
||||||
ctx.emit('session/event', session, {
|
ctx.emit('session/event', session, {
|
||||||
type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } },
|
type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } },
|
||||||
@@ -203,7 +202,7 @@ describe('createStdioChat rendering', () => {
|
|||||||
const { ctx, input } = await setup()
|
const { ctx, input } = await setup()
|
||||||
const resumed = makeAgent('resumed')
|
const resumed = makeAgent('resumed')
|
||||||
;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent'
|
;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent'
|
||||||
ctx.emit('agent/created', resumed)
|
ctx.agents.register(resumed)
|
||||||
|
|
||||||
input.feed('continue')
|
input.feed('continue')
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
@@ -224,8 +223,8 @@ describe('createStdioChat rendering', () => {
|
|||||||
it('drops the target object on agent/disposed', async () => {
|
it('drops the target object on agent/disposed', async () => {
|
||||||
const { ctx, out } = await setup()
|
const { ctx, out } = await setup()
|
||||||
const agent = makeAgent('main')
|
const agent = makeAgent('main')
|
||||||
ctx.emit('agent/created', agent)
|
const dispose = ctx.agents.register(agent)
|
||||||
ctx.emit('agent/disposed', agent)
|
dispose()
|
||||||
// After disposal the event belongs to a non-target session, so its durable
|
// After disposal the event belongs to a non-target session, so its durable
|
||||||
// identity is rendered directly.
|
// identity is rendered directly.
|
||||||
ctx.emit('session/event', agent.session, {
|
ctx.emit('session/event', agent.session, {
|
||||||
@@ -237,7 +236,7 @@ describe('createStdioChat rendering', () => {
|
|||||||
it('keeps the target when a different agent is disposed', async () => {
|
it('keeps the target when a different agent is disposed', async () => {
|
||||||
const { ctx, out } = await setup()
|
const { ctx, out } = await setup()
|
||||||
const target = makeAgent('target')
|
const target = makeAgent('target')
|
||||||
ctx.emit('agent/created', target)
|
ctx.agents.register(target)
|
||||||
ctx.emit('agent/disposed', makeAgent('other'))
|
ctx.emit('agent/disposed', makeAgent('other'))
|
||||||
ctx.emit('session/event', target.session, {
|
ctx.emit('session/event', target.session, {
|
||||||
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
|
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
|
||||||
@@ -251,19 +250,27 @@ describe('createStdioChat rendering', () => {
|
|||||||
const child = makeAgent('child')
|
const child = makeAgent('child')
|
||||||
;(child.session.header as { parentSession?: string }).parentSession = oldRoot.id
|
;(child.session.header as { parentSession?: string }).parentSession = oldRoot.id
|
||||||
const replacement = makeAgent('replacement')
|
const replacement = makeAgent('replacement')
|
||||||
|
const lateChild = makeAgent('late-child')
|
||||||
const disposeOld = ctx.agents.register(oldRoot)
|
const disposeOld = ctx.agents.register(oldRoot)
|
||||||
ctx.agents.register(child)
|
const disposeChild = ctx.agents.enter(child, oldRoot)
|
||||||
|
ctx.agents.announce(child)
|
||||||
ctx.agents.register(replacement)
|
ctx.agents.register(replacement)
|
||||||
|
const disposeLateChild = ctx.agents.enter(lateChild, replacement)
|
||||||
|
ctx.agents.announce(lateChild)
|
||||||
|
|
||||||
// The replacement's created edge arrived while oldRoot was still targeted.
|
// The replacement's created edge arrived while oldRoot was still targeted.
|
||||||
// Once oldRoot is removed, registry order is child then replacement; the
|
// A replacement-owned child then arrived even later. Once oldRoot is
|
||||||
// most recently published survivor is the HMR replacement.
|
// removed, runtime ownership still identifies replacement as the only
|
||||||
|
// surviving root instead of selecting either newer child by insertion order.
|
||||||
disposeOld()
|
disposeOld()
|
||||||
input.feed('after hmr')
|
input.feed('after hmr')
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
|
||||||
expect(child.sent).toEqual([])
|
expect(child.sent).toEqual([])
|
||||||
|
expect(lateChild.sent).toEqual([])
|
||||||
expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]])
|
expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]])
|
||||||
|
disposeLateChild()
|
||||||
|
disposeChild()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders tool/call and tool/result session events', async () => {
|
it('renders tool/call and tool/result session events', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user