diff --git a/plugins/provider-codex/src/translator.test.ts b/plugins/provider-codex/src/translator.test.ts index 787ac69912..aa7ccf500d 100644 --- a/plugins/provider-codex/src/translator.test.ts +++ b/plugins/provider-codex/src/translator.test.ts @@ -728,6 +728,22 @@ describe("codex command output capture across reordering", () => { describe("codex subagent activity correlation", () => { const rootProviderThreadId = "root-provider-thread"; + function rawCollaborationCall(args: { + callId: string; + name: "followup_task" | "send_message"; + }) { + return codexEvent("rawResponseItem/completed", { + threadId: rootProviderThreadId, + turnId: "parent-turn", + item: { + type: "function_call", + name: args.name, + arguments: '{"target":"/root/lifecycle_child"}', + call_id: args.callId, + }, + }); + } + function subAgentActivity(args: { agentThreadId?: string; id: string; @@ -900,13 +916,15 @@ describe("codex subagent activity correlation", () => { ); harness.translate(childTurnCompleted("child-turn-1")); - // A follow-up to a settled agent re-opens its delegation row (same item - // id): the agent works again, and an open delegation is open work. + // `interacted` alone is ambiguous: Codex uses it for both followup_task + // and send_message. Wait for the child turn before reopening the row. expect( harness.translate( subAgentActivity({ id: "interaction-1", kind: "interacted" }), ), - ).toEqual([ + ).toEqual([]); + + expect(harness.translate(childTurnStarted("child-turn-2"))).toEqual([ expect.objectContaining({ type: "item/started", scope: turnScope(harness.turnId("parent-turn")), @@ -916,15 +934,12 @@ describe("codex subagent activity correlation", () => { status: "pending", }), }), - ]); - - expect(harness.translate(childTurnStarted("child-turn-2"))).toContainEqual( expect.objectContaining({ type: "turn/started", scope: turnScope(harness.turnId("child-turn-2")), parentToolCallId: harness.itemId("subagent-call-1"), }), - ); + ]); // The resumed turn settles the re-opened delegation again. const resumedTurnCompleted = harness.translate( @@ -947,9 +962,264 @@ describe("codex subagent activity correlation", () => { ]); }); - // Follow-ups queue: two interactions owe two more child turns. The re-arm is - // counted, so terminalizing the agent after the first follow-up must not - // discard the link the second one still needs. + // When app-server supplies the raw collaboration call, it distinguishes a + // turn-producing followup from a message that must not reserve the next + // native turn. Resumed sessions can omit this notification; the rawless + // cases below cover that event shape. + it("links an unknown resumed subagent from the raw followup intent after translator restart", () => { + const harness = createHarness(); + + expect( + harness.translate( + rawCollaborationCall({ + callId: "message-call", + name: "send_message", + }), + ), + ).toEqual([]); + expect( + harness.translate( + subAgentActivity({ id: "message-call", kind: "interacted" }), + ), + ).toEqual([]); + + expect( + harness.translate( + rawCollaborationCall({ + callId: "followup-call", + name: "followup_task", + }), + ), + ).toEqual([]); + expect( + harness.translate( + subAgentActivity({ id: "followup-call", kind: "interacted" }), + ), + ).toEqual([ + expect.objectContaining({ + type: "item/started", + scope: turnScope(harness.turnId("parent-turn")), + item: expect.objectContaining({ + type: "delegation", + id: harness.itemId("followup-call"), + childRef: "agent-thread-1", + status: "pending", + }), + }), + ]); + + expect( + harness.translate(childTurnStarted("resumed-child-turn")), + ).toContainEqual( + expect.objectContaining({ + type: "turn/started", + scope: turnScope(harness.turnId("resumed-child-turn")), + parentToolCallId: harness.itemId("followup-call"), + }), + ); + }); + + it("does not reopen a known terminal subagent for send_message", () => { + const harness = createHarness(); + harness.translate( + subAgentActivity({ id: "subagent-call-1", kind: "started" }), + ); + harness.translate(childTurnStarted("child-turn-1")); + harness.translate(childTurnCompleted("child-turn-1")); + + harness.translate( + rawCollaborationCall({ callId: "message-call", name: "send_message" }), + ); + expect( + harness.translate( + subAgentActivity({ id: "message-call", kind: "interacted" }), + ), + ).toEqual([]); + + expect( + harness.translator.prepareTurnStart({ + clientRequestId: "creq_after_message", + providerThreadId: rootProviderThreadId, + }), + ).not.toBeNull(); + const nextRootTurn = harness + .translate(childTurnStarted("next-root-turn")) + .find((event) => event.type === "turn/started"); + expect(nextRootTurn).not.toHaveProperty("parentToolCallId"); + }); + + it("links a rawless resumed subagent when its child turn starts", () => { + const harness = createHarness(); + + expect( + harness.translate( + subAgentActivity({ id: "rawless-followup", kind: "interacted" }), + ), + ).toEqual([]); + + const resumedEvents = harness.translate( + childTurnStarted("rawless-child-turn"), + ); + expect(resumedEvents).toEqual([ + expect.objectContaining({ + type: "item/started", + scope: turnScope(harness.turnId("parent-turn")), + item: expect.objectContaining({ + type: "delegation", + id: harness.itemId("rawless-followup"), + childRef: "agent-thread-1", + }), + }), + expect.objectContaining({ + type: "turn/started", + scope: turnScope(harness.turnId("rawless-child-turn")), + parentToolCallId: harness.itemId("rawless-followup"), + }), + ]); + expect(resumedEvents[0]).not.toHaveProperty("parentToolCallId"); + expect(resumedEvents[0]).not.toHaveProperty("item.parentToolCallId"); + }); + + // Production resumes report the interaction on the root thread and the + // resulting child turn on the agent's own provider thread. Once the parent + // settles, a new root prompt can start while that child is still running; + // the two provider-thread-scoped correlations must remain independent. + it("keeps root input correlation independent from a rawless resumed child thread", () => { + const harness = createHarness(); + + expect( + harness.translate( + subAgentActivity({ id: "rawless-followup", kind: "interacted" }), + ), + ).toEqual([]); + + expect( + harness.translate( + childTurnStarted("rawless-child-turn", "agent-thread-1"), + ), + ).toEqual([ + expect.objectContaining({ + type: "item/started", + scope: turnScope(harness.turnId("parent-turn")), + item: expect.objectContaining({ + type: "delegation", + id: harness.itemId("rawless-followup"), + childRef: "agent-thread-1", + }), + }), + expect.objectContaining({ + type: "turn/started", + scope: turnScope(harness.turnId("rawless-child-turn")), + parentToolCallId: harness.itemId("rawless-followup"), + }), + ]); + + harness.translate(childTurnCompleted("parent-turn")); + expect( + harness.translator.prepareTurnStart({ + clientRequestId: "creq_while_child_running", + providerThreadId: rootProviderThreadId, + }), + ).not.toBeNull(); + + const rootEvents = harness.translate(childTurnStarted("next-root-turn")); + expect(rootEvents).toContainEqual( + expect.objectContaining({ + type: "turn/started", + scope: turnScope(harness.turnId("next-root-turn")), + }), + ); + expect(rootEvents).toContainEqual( + expect.objectContaining({ + type: "turn/input/accepted", + scope: turnScope(harness.turnId("next-root-turn")), + clientRequestId: "creq_while_child_running", + }), + ); + expect( + rootEvents.find((event) => event.type === "turn/started"), + ).not.toHaveProperty("parentToolCallId"); + + expect( + harness + .translate(childTurnCompleted("rawless-child-turn", "agent-thread-1")) + .map((event) => event.type), + ).toEqual(["turn/completed", "item/completed"]); + }); + + it("discards a rawless message interaction at its parent boundary", () => { + const harness = createHarness(); + expect( + harness.translate( + subAgentActivity({ id: "rawless-message", kind: "interacted" }), + ), + ).toEqual([]); + harness.translate(childTurnCompleted("parent-turn")); + + harness.translator.prepareTurnStart({ + clientRequestId: "creq_after_rawless_message", + providerThreadId: rootProviderThreadId, + }); + const nextRootTurn = harness + .translate(childTurnStarted("next-root-after-message")) + .find((event) => event.type === "turn/started"); + expect(nextRootTurn).not.toHaveProperty("parentToolCallId"); + }); + + // A rawless message is not evidence that the next turn belongs to its + // target. If a different known child starts on the multiplexed root thread, + // that child's explicit pending delegation must win; the message is then + // discarded with its parent and cannot claim a later root turn either. + it("does not attach a rawless message to an unrelated multiplexed child", () => { + const harness = createHarness(); + expect( + harness.translate( + subAgentActivity({ + agentThreadId: "message-target-thread", + id: "rawless-message", + kind: "interacted", + }), + ), + ).toEqual([]); + + harness.translate( + subAgentActivity({ + agentThreadId: "unrelated-agent-thread", + id: "unrelated-subagent-call", + kind: "started", + }), + ); + const unrelatedChild = harness + .translate(childTurnStarted("unrelated-child-turn")) + .find((event) => event.type === "turn/started"); + expect(unrelatedChild).toEqual( + expect.objectContaining({ + type: "turn/started", + parentToolCallId: harness.itemId("unrelated-subagent-call"), + }), + ); + expect(unrelatedChild).not.toHaveProperty( + "parentToolCallId", + harness.itemId("rawless-message"), + ); + + harness.translate(childTurnCompleted("unrelated-child-turn")); + harness.translate(childTurnCompleted("parent-turn")); + expect( + harness.translator.prepareTurnStart({ + clientRequestId: "creq_after_unrelated_child", + providerThreadId: rootProviderThreadId, + }), + ).not.toBeNull(); + const nextRootTurn = harness + .translate(childTurnStarted("next-root-after-unrelated-child")) + .find((event) => event.type === "turn/started"); + expect(nextRootTurn).not.toHaveProperty("parentToolCallId"); + }); + + // Each child turn consumes one ambiguous interaction. Settling the first + // resumed turn must not discard the second interaction that still awaits + // its own turn-producing proof. it("preserves the parent link across queued follow-up resumes", () => { const harness = createHarness(); harness.translate( @@ -958,14 +1228,15 @@ describe("codex subagent activity correlation", () => { harness.translate(childTurnStarted("child-turn-1")); harness.translate(childTurnCompleted("child-turn-1")); - // The first follow-up re-opens the delegation; the second finds it open. + // Neither ambiguous interaction re-opens the delegation until a child + // turn proves that it was a turn-producing follow-up. expect( harness .translate( subAgentActivity({ id: "interaction-1", kind: "interacted" }), ) .map((event) => event.type), - ).toEqual(["item/started"]); + ).toEqual([]); expect( harness.translate( subAgentActivity({ id: "interaction-2", kind: "interacted" }), @@ -975,21 +1246,25 @@ describe("codex subagent activity correlation", () => { for (const index of [2, 3]) { expect( harness.translate(childTurnStarted(`child-turn-${index}`)), - ).toContainEqual( + ).toEqual([ + expect.objectContaining({ + type: "item/started", + item: expect.objectContaining({ + type: "delegation", + id: harness.itemId("subagent-call-1"), + }), + }), expect.objectContaining({ type: "turn/started", scope: turnScope(harness.turnId(`child-turn-${index}`)), parentToolCallId: harness.itemId("subagent-call-1"), }), - ); - // The delegation closes only once the last owed follow-up turn settles. + ]); expect( harness .translate(childTurnCompleted(`child-turn-${index}`)) .map((event) => event.type), - ).toEqual( - index === 3 ? ["turn/completed", "item/completed"] : ["turn/completed"], - ); + ).toEqual(["turn/completed", "item/completed"]); } }); diff --git a/plugins/provider-codex/src/translator.ts b/plugins/provider-codex/src/translator.ts index ed8af4cbec..ea0a0824b1 100644 --- a/plugins/provider-codex/src/translator.ts +++ b/plugins/provider-codex/src/translator.ts @@ -115,6 +115,8 @@ interface CodexPendingDelegationTurnLink { parentTurnId: string; } +type CodexInteractionKind = "followup" | "message"; + /** The collab arguments a receiver-less spawn/resume tool call carries. */ const codexDelegationArgsSchema = z .object({ @@ -226,7 +228,10 @@ function withDeltaParentRef( const codexProviderThreadIdParamsSchema = z .object({ threadId: z.string().min(1).optional(), - thread: z.object({ id: z.string().min(1) }).passthrough().optional(), + thread: z + .object({ id: z.string().min(1) }) + .passthrough() + .optional(), }) .passthrough(); @@ -437,6 +442,14 @@ export function createCodexEventTranslator( const pendingDelegationCallIds = new Set(); const pendingDelegationProviderThreadIdByCallId = new Map(); const processedSubAgentInteractionIds = new Set(); + const interactionKindsByProviderThreadId = new Map< + string, + Map + >(); + const unclassifiedInteractionsByProviderThreadId = new Map< + string, + CodexSubAgentActivityEvent[] + >(); const trackedSubAgentsByCallId = new Map(); const trackedSubAgentCallIdsByAgentThreadId = new Map(); @@ -599,6 +612,8 @@ export function createCodexEventTranslator( function clearCodexDelegationParentState( providerThreadId: string, ): ThreadDelta[] { + interactionKindsByProviderThreadId.delete(providerThreadId); + unclassifiedInteractionsByProviderThreadId.delete(providerThreadId); delegationParentToolCallIdsByProviderThreadId.delete(providerThreadId); pendingDelegationTurnLinksByProviderThreadId.delete(providerThreadId); const closes: ThreadDelta[] = []; @@ -610,7 +625,9 @@ export function createCodexEventTranslator( continue; } if (isTrackedSubAgentOpen(tracked)) { - closes.push(buildCodexSubAgentCloseDelta({ status: "failed", tracked })); + closes.push( + buildCodexSubAgentCloseDelta({ status: "failed", tracked }), + ); } tracked.terminal = true; tracked.pendingFollowups = 0; @@ -1001,6 +1018,60 @@ export function createCodexEventTranslator( return trackedSubAgentsByCallId.get(callId); } + function beginCodexTrackedSubAgent( + activity: CodexSubAgentActivityEvent, + ): ThreadDelta[] { + const tracked: CodexTrackedSubAgent = { + agentPath: activity.item.agentPath, + agentThreadId: activity.item.agentThreadId, + callId: activity.item.id, + parentProviderThreadId: activity.providerThreadId, + parentTurnId: activity.turnId, + pendingFollowups: 0, + terminal: false, + }; + trackedSubAgentsByCallId.set(tracked.callId, tracked); + trackedSubAgentCallIdsByAgentThreadId.set( + tracked.agentThreadId, + tracked.callId, + ); + + const [openDelta] = attachCodexDelegationParentLinks( + [buildCodexSubAgentOpenDelta(tracked)], + activity.providerThreadId, + ); + if (openDelta?.kind === "item.open") { + tracked.parentToolCallId = openDelta.key.parentRef; + } + // Codex can report child turns on the agent's own provider thread. Retain + // a FIFO fallback as well for older/multiplexed event shapes that report + // them on the root provider thread. + enqueuePendingDelegationTurnLink({ + callId: tracked.callId, + parentTurnId: tracked.parentTurnId, + providerThreadId: tracked.parentProviderThreadId, + }); + return openDelta ? [openDelta] : []; + } + + function consumeCodexInteractionKind(args: { + callId: string; + providerThreadId: string; + }): CodexInteractionKind | undefined { + const interactionKinds = interactionKindsByProviderThreadId.get( + args.providerThreadId, + ); + const kind = interactionKinds?.get(args.callId); + if (!kind || !interactionKinds) { + return undefined; + } + interactionKinds.delete(args.callId); + if (interactionKinds.size === 0) { + interactionKindsByProviderThreadId.delete(args.providerThreadId); + } + return kind; + } + function rearmTrackedSubAgent(tracked: CodexTrackedSubAgent): void { trackedSubAgentCallIdsByAgentThreadId.set( tracked.agentThreadId, @@ -1019,6 +1090,171 @@ export function createCodexEventTranslator( }); } + function queueUnclassifiedCodexInteraction( + activity: CodexSubAgentActivityEvent, + ): void { + const pending = + unclassifiedInteractionsByProviderThreadId.get( + activity.providerThreadId, + ) ?? []; + pending.push(activity); + unclassifiedInteractionsByProviderThreadId.set( + activity.providerThreadId, + pending, + ); + } + + function takeUnclassifiedCodexInteraction(args: { + callId?: string; + providerThreadId: string; + startedTurnId?: string; + }): CodexSubAgentActivityEvent | undefined { + let interactionProviderThreadId = args.providerThreadId; + let pending = unclassifiedInteractionsByProviderThreadId.get( + interactionProviderThreadId, + ); + if (!pending && args.callId === undefined) { + for (const [ + candidateProviderThreadId, + candidates, + ] of unclassifiedInteractionsByProviderThreadId) { + if ( + candidates.some( + (activity) => activity.item.agentThreadId === args.providerThreadId, + ) + ) { + interactionProviderThreadId = candidateProviderThreadId; + pending = candidates; + break; + } + } + } + if (!pending) { + return undefined; + } + let index = + args.callId !== undefined + ? pending.findIndex((activity) => activity.item.id === args.callId) + : -1; + if (args.callId === undefined) { + for ( + let candidateIndex = pending.length - 1; + candidateIndex >= 0; + --candidateIndex + ) { + const activity = pending[candidateIndex]; + if ( + activity && + activity.turnId !== args.startedTurnId && + (interactionProviderThreadId === args.providerThreadId || + activity.item.agentThreadId === args.providerThreadId) + ) { + index = candidateIndex; + break; + } + } + } + if (index === -1) { + return undefined; + } + const [activity] = pending.splice(index, 1); + if (pending.length === 0) { + unclassifiedInteractionsByProviderThreadId.delete( + interactionProviderThreadId, + ); + } + return activity; + } + + function materializeCodexFollowup( + activity: CodexSubAgentActivityEvent, + ): ThreadDelta[] { + const tracked = findTrackedSubAgentByAgentThreadId( + activity.item.agentThreadId, + ); + if (!tracked) { + return beginCodexTrackedSubAgent(activity); + } + if (!tracked.terminal) { + return []; + } + const wasOpen = isTrackedSubAgentOpen(tracked); + tracked.pendingFollowups += 1; + rearmTrackedSubAgent(tracked); + return wasOpen ? [] : [buildCodexSubAgentOpenDelta(tracked)]; + } + + function hasConsumablePendingDelegationLink(args: { + providerThreadId: string; + startedTurnId: string; + }): boolean { + return ( + pendingDelegationTurnLinksByProviderThreadId + .get(args.providerThreadId) + ?.some((link) => link.parentTurnId !== args.startedTurnId) ?? false + ); + } + + /** + * Codex app-server does not currently forward the raw collaboration call + * for resumed agents. It does forward `interacted`, followed by the child + * `turn/started` before the invoking parent turn completes. Hold an + * unclassified interaction until that observable turn proves it was a + * followup; a message-only interaction is discarded at its parent boundary. + */ + function materializeUnclassifiedCodexInteractions( + deltas: ThreadDelta[], + providerThreadId: string | undefined, + ): ThreadDelta[] { + if (!providerThreadId) { + return deltas; + } + const materialized: ThreadDelta[] = []; + for (const delta of deltas) { + if ( + delta.kind === "turn.open" && + delta.providerTurnId !== undefined && + !hasPendingNativeTurnStart(providerThreadId) && + !hasConsumablePendingDelegationLink({ + providerThreadId, + startedTurnId: delta.providerTurnId, + }) + ) { + const activity = takeUnclassifiedCodexInteraction({ + providerThreadId, + startedTurnId: delta.providerTurnId, + }); + if (activity) { + materialized.push(...materializeCodexFollowup(activity)); + } + } + materialized.push( + ...attachCodexDelegationParentLinks([delta], providerThreadId), + ); + if ( + delta.kind === "turn.boundary" && + delta.providerTurnId !== undefined + ) { + const pending = + unclassifiedInteractionsByProviderThreadId.get(providerThreadId); + if (pending) { + const remaining = pending.filter( + (activity) => activity.turnId !== delta.providerTurnId, + ); + if (remaining.length === 0) { + unclassifiedInteractionsByProviderThreadId.delete(providerThreadId); + } else if (remaining.length !== pending.length) { + unclassifiedInteractionsByProviderThreadId.set( + providerThreadId, + remaining, + ); + } + } + } + } + return materialized; + } + /** * A tracked sub-agent is open work while it has not reached a terminal * turn, or while it still owes a followup turn it was re-armed for. The @@ -1063,60 +1299,34 @@ export function createCodexEventTranslator( if (trackedSubAgentsByCallId.has(activity.item.id)) { return []; } - const tracked: CodexTrackedSubAgent = { - agentPath: activity.item.agentPath, - agentThreadId: activity.item.agentThreadId, - callId: activity.item.id, - parentProviderThreadId: activity.providerThreadId, - parentTurnId: activity.turnId, - pendingFollowups: 0, - terminal: false, - }; - trackedSubAgentsByCallId.set(tracked.callId, tracked); - trackedSubAgentCallIdsByAgentThreadId.set( - tracked.agentThreadId, - tracked.callId, - ); - - const [openDelta] = attachCodexDelegationParentLinks( - [buildCodexSubAgentOpenDelta(tracked)], - activity.providerThreadId, - ); - if (openDelta?.kind === "item.open") { - tracked.parentToolCallId = openDelta.key.parentRef; - } - // Codex currently multiplexes child turns onto the root provider - // thread, even though the activity includes a distinct agent thread - // id. Queue a FIFO fallback in addition to the explicit id mapping. - enqueuePendingDelegationTurnLink({ - callId: tracked.callId, - parentTurnId: tracked.parentTurnId, - providerThreadId: tracked.parentProviderThreadId, - }); - return openDelta ? [openDelta] : []; + return beginCodexTrackedSubAgent(activity); } case "interacted": { // Messaging an existing agent is activity within the original - // delegation, not a new timeline row. A completed agent can receive - // followup_task; re-arm the original parent so the next child turn - // is not projected as a root turn. + // delegation, not a new timeline row. Because app-server may omit the + // raw verb, a completed or historical agent waits for a child turn to + // prove this interaction was a followup before it is re-armed. if (processedSubAgentInteractionIds.has(activity.item.id)) { return []; } processedSubAgentInteractionIds.add(activity.item.id); + const interactionKind = consumeCodexInteractionKind({ + callId: activity.item.id, + providerThreadId: activity.providerThreadId, + }); + if (interactionKind === "message") { + return []; + } const tracked = findTrackedSubAgentByAgentThreadId( activity.item.agentThreadId, ); - if (tracked?.terminal) { - const wasOpen = isTrackedSubAgentOpen(tracked); - tracked.pendingFollowups += 1; - rearmTrackedSubAgent(tracked); - if (!wasOpen) { - // The agent works again: re-open its delegation row (the - // assembler reuses the minted item id for a known provider id). - return [buildCodexSubAgentOpenDelta(tracked)]; - } + if (tracked && !tracked.terminal) { + return []; + } + if (interactionKind === "followup") { + return materializeCodexFollowup(activity); } + queueUnclassifiedCodexInteraction(activity); return []; } case "interrupted": { @@ -1144,7 +1354,10 @@ export function createCodexEventTranslator( const completedDeltas: ThreadDelta[] = []; for (const delta of deltas) { completedDeltas.push(delta); - if (delta.kind !== "turn.boundary" || delta.providerTurnId === undefined) { + if ( + delta.kind !== "turn.boundary" || + delta.providerTurnId === undefined + ) { continue; } const callId = delegationParentToolCallIdsByTurnId.get( @@ -1183,6 +1396,34 @@ export function createCodexEventTranslator( const { threadId: providerThreadId, item } = paramsResult.data; if (item.type === "function_call") { + // subAgentActivity collapses both verbs to `interacted`; retain the raw + // intent so send_message cannot reserve a turn that only followup_task + // will start. + if (item.name === "followup_task" || item.name === "send_message") { + const pendingActivity = takeUnclassifiedCodexInteraction({ + callId: item.call_id, + providerThreadId, + }); + if (pendingActivity) { + return item.name === "followup_task" + ? materializeCodexFollowup(pendingActivity) + : []; + } + if (!processedSubAgentInteractionIds.has(item.call_id)) { + const interactionKinds = + interactionKindsByProviderThreadId.get(providerThreadId) ?? + new Map(); + interactionKinds.set( + item.call_id, + item.name === "followup_task" ? "followup" : "message", + ); + interactionKindsByProviderThreadId.set( + providerThreadId, + interactionKinds, + ); + } + return []; + } if (!CODEX_SHELL_TOOL_NAMES.has(item.name)) { return []; } @@ -1396,7 +1637,7 @@ export function createCodexEventTranslator( ); } - const parentLinkedDeltas = attachCodexDelegationParentLinks( + const parentLinkedDeltas = materializeUnclassifiedCodexInteractions( translateCodexEventToDeltas(event, eventTranslationState), providerThreadId, );