Skip to content

Commit d73f3ac

Browse files
committed
refactor: split subagent delivery state
1 parent 3cf806d commit d73f3ac

35 files changed

Lines changed: 1588 additions & 613 deletions

src/agents/agent-command.live-model-switch.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ const state = vi.hoisted(() => ({
4545
resolvedSkills: [],
4646
version: 0,
4747
})),
48+
prepareInternalSessionEffectsTranscriptMock: vi.fn(),
49+
removeInternalSessionEffectsTranscriptMock: vi.fn(),
4850
authProfileStoreMock: { profiles: {} } as { profiles: Record<string, unknown> },
4951
sessionEntryMock: undefined as unknown,
5052
sessionStoreMock: undefined as unknown,
@@ -211,6 +213,13 @@ vi.mock("../config/sessions/transcript-resolve.runtime.js", () => ({
211213
}),
212214
}));
213215

216+
vi.mock("./internal-session-effects.js", () => ({
217+
prepareInternalSessionEffectsTranscript: (...args: unknown[]) =>
218+
state.prepareInternalSessionEffectsTranscriptMock(...args),
219+
removeInternalSessionEffectsTranscript: (...args: unknown[]) =>
220+
state.removeInternalSessionEffectsTranscriptMock(...args),
221+
}));
222+
214223
vi.mock("../infra/agent-events.js", () => ({
215224
clearAgentRunContext: (...args: unknown[]) => state.clearAgentRunContextMock(...args),
216225
emitAgentEvent: (...args: unknown[]) => state.emitAgentEventMock(...args),
@@ -808,6 +817,10 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
808817
state.deliverAgentCommandResultMock.mockResolvedValue(undefined);
809818
state.updateSessionStoreAfterAgentRunMock.mockResolvedValue(undefined);
810819
state.trajectoryFlushMock.mockResolvedValue(undefined);
820+
state.prepareInternalSessionEffectsTranscriptMock.mockResolvedValue(
821+
"/tmp/openclaw-internal-run.jsonl",
822+
);
823+
state.removeInternalSessionEffectsTranscriptMock.mockResolvedValue(undefined);
811824
});
812825

813826
afterEach(() => {
@@ -900,6 +913,46 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
900913
expect(stored?.pendingFinalDeliveryIntentId).toBeUndefined();
901914
});
902915

916+
it("keeps internal session-effect CLI runs out of visible session state", async () => {
917+
setupSingleAttemptFallback();
918+
const visibleEntry: SessionEntry = {
919+
sessionId: "session-1",
920+
updatedAt: 1,
921+
sessionFile: "/tmp/session.jsonl",
922+
providerOverride: "anthropic",
923+
modelOverride: "claude",
924+
modelOverrideSource: "user",
925+
skillsSnapshot: { prompt: "visible", skills: [{ name: "existing" }], version: 1 },
926+
};
927+
const sessionStore: Record<string, SessionEntry> = { "agent:main:main": visibleEntry };
928+
state.sessionEntryMock = visibleEntry;
929+
state.sessionStoreMock = sessionStore;
930+
state.storePathMock = "/tmp/openclaw-session-store.json";
931+
const attemptCalls: Array<{ sessionFile?: string; sessionEntry?: SessionEntry }> = [];
932+
state.runAgentAttemptMock.mockImplementation(async (params) => {
933+
attemptCalls.push(params as { sessionFile?: string; sessionEntry?: SessionEntry });
934+
return makeSuccessResult("openai", "gpt-5.4");
935+
});
936+
937+
await agentCommand({
938+
message: "internal resume",
939+
to: "+1234567890",
940+
sessionEffects: "internal",
941+
suppressPromptPersistence: true,
942+
});
943+
944+
expect(state.prepareInternalSessionEffectsTranscriptMock).toHaveBeenCalledWith({
945+
sessionFile: "/tmp/session.jsonl",
946+
runId: expect.any(String),
947+
});
948+
expect(attemptCalls).toHaveLength(1);
949+
expect(attemptCalls[0]?.sessionFile).toBe("/tmp/openclaw-internal-run.jsonl");
950+
expect(attemptCalls[0]?.sessionEntry).toBe(visibleEntry);
951+
expect(state.persistSessionEntryMock).not.toHaveBeenCalled();
952+
expect(state.updateSessionStoreAfterAgentRunMock).not.toHaveBeenCalled();
953+
expect(sessionStore["agent:main:main"]).toBe(visibleEntry);
954+
});
955+
903956
it("does not duplicate finishing lifecycle when an attempt already emitted finishing", async () => {
904957
setupModelSwitchRetry({
905958
provider: "openai",

src/agents/agent-command.ts

Lines changed: 105 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
7474
import { resolveFastModeState } from "./fast-mode.js";
7575
import { ensureSelectedAgentHarnessPlugin } from "./harness/runtime-plugin.js";
7676
import { resolveAvailableAgentHarnessPolicy } from "./harness/selection.js";
77+
import { prepareInternalSessionEffectsTranscript } from "./internal-session-effects.js";
7778
import { AGENT_LANE_SUBAGENT } from "./lanes.js";
7879
import { LiveSessionModelSwitchError } from "./live-model-switch.js";
7980
import { loadManifestModelCatalog } from "./model-catalog.js";
@@ -529,6 +530,7 @@ async function agentCommandInternal(
529530
) {
530531
const resolvedDeps = await resolveAgentCommandDeps(deps);
531532
const isRawModelRun = opts.modelRun === true || opts.promptMode === "none";
533+
const suppressVisibleSessionEffects = opts.sessionEffects === "internal";
532534
const prepared = await prepareAgentCommandExecution(opts, runtime);
533535
const {
534536
body,
@@ -580,9 +582,14 @@ async function agentCommandInternal(
580582
if (!isRawModelRun && acpResolution?.kind === "ready" && sessionKey) {
581583
const attemptExecutionRuntime = await loadAttemptExecutionRuntime();
582584
const startedAt = Date.now();
583-
registerAgentRunContext(runId, {
584-
sessionKey,
585-
});
585+
registerAgentRunContext(
586+
runId,
587+
suppressVisibleSessionEffects
588+
? { isControlUiVisible: false }
589+
: {
590+
sessionKey,
591+
},
592+
);
586593
attemptExecutionRuntime.emitAcpLifecycleStart({ runId, startedAt });
587594

588595
const visibleTextAccumulator = attemptExecutionRuntime.createAcpVisibleTextAccumulator();
@@ -676,21 +683,53 @@ async function agentCommandInternal(
676683
const finalTextRaw = visibleTextAccumulator.finalizeRaw();
677684
const finalText = visibleTextAccumulator.finalize();
678685
try {
679-
const { resolveAcpSessionCwd } = await loadAcpSessionIdentifiersRuntime();
686+
const [{ resolveAcpSessionCwd }, { resolveSessionTranscriptFile }] = await Promise.all([
687+
loadAcpSessionIdentifiersRuntime(),
688+
loadTranscriptResolveRuntime(),
689+
]);
690+
const internalSource = suppressVisibleSessionEffects
691+
? await resolveSessionTranscriptFile({
692+
sessionId,
693+
sessionKey,
694+
sessionEntry,
695+
agentId: sessionAgentId,
696+
threadId: opts.threadId,
697+
})
698+
: undefined;
699+
const internalSessionFile = suppressVisibleSessionEffects
700+
? await prepareInternalSessionEffectsTranscript({
701+
sessionFile: internalSource?.sessionFile,
702+
runId,
703+
})
704+
: undefined;
705+
const transcriptSessionEntry: SessionEntry | undefined = internalSessionFile
706+
? {
707+
...(sessionEntry ?? {
708+
sessionId,
709+
updatedAt: Date.now(),
710+
sessionStartedAt: Date.now(),
711+
}),
712+
sessionId,
713+
sessionFile: internalSessionFile,
714+
}
715+
: sessionEntry;
680716
sessionEntry = await attemptExecutionRuntime.persistAcpTurnTranscript({
681717
body,
682718
transcriptBody,
683719
finalText: finalTextRaw,
684720
sessionId,
685721
sessionKey,
686-
sessionEntry,
687-
sessionStore,
688-
storePath,
722+
sessionEntry: transcriptSessionEntry,
723+
sessionStore: suppressVisibleSessionEffects ? undefined : sessionStore,
724+
storePath: suppressVisibleSessionEffects ? undefined : storePath,
689725
sessionAgentId,
690726
threadId: opts.threadId,
691727
sessionCwd: resolveAcpSessionCwd(acpResolution.meta) ?? workspaceDir,
692728
config: cfg,
693729
});
730+
if (internalSessionFile) {
731+
sessionEntry = prepared.sessionEntry;
732+
}
694733
} catch (error) {
695734
log.warn(
696735
`ACP transcript persistence failed for ${sessionKey}: ${formatErrorMessage(error)}`,
@@ -722,10 +761,11 @@ async function agentCommandInternal(
722761
const resolvedVerboseLevel =
723762
verboseOverride ?? persistedVerbose ?? (agentCfg?.verboseDefault as VerboseLevel | undefined);
724763

725-
if (sessionKey) {
764+
if (sessionKey || suppressVisibleSessionEffects) {
726765
registerAgentRunContext(runId, {
727-
sessionKey,
766+
...(sessionKey && !suppressVisibleSessionEffects ? { sessionKey } : {}),
728767
verboseLevel: resolvedVerboseLevel,
768+
isControlUiVisible: !suppressVisibleSessionEffects,
729769
});
730770
}
731771

@@ -772,7 +812,13 @@ async function agentCommandInternal(
772812
? undefined
773813
: await hydrateResolvedSkillsAsync(currentSkillsSnapshot, buildSkillsSnapshot);
774814

775-
if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) {
815+
if (
816+
skillsSnapshot &&
817+
sessionStore &&
818+
sessionKey &&
819+
needsSkillsSnapshot &&
820+
!suppressVisibleSessionEffects
821+
) {
776822
const now = Date.now();
777823
const current = sessionEntry ?? {
778824
sessionId,
@@ -796,7 +842,7 @@ async function agentCommandInternal(
796842
}
797843

798844
// Persist explicit /command overrides to the session store when we have a key.
799-
if (sessionStore && sessionKey) {
845+
if (sessionStore && sessionKey && !suppressVisibleSessionEffects) {
800846
const now = Date.now();
801847
const entry = sessionStore[sessionKey] ??
802848
sessionEntry ?? { sessionId, updatedAt: now, sessionStartedAt: now };
@@ -877,7 +923,13 @@ async function agentCommandInternal(
877923
allowedModelCatalog = visibilityPolicy.allowedCatalog;
878924
}
879925

880-
if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) {
926+
if (
927+
sessionEntry &&
928+
sessionStore &&
929+
sessionKey &&
930+
hasStoredOverride &&
931+
!suppressVisibleSessionEffects
932+
) {
881933
const entry = sessionEntry;
882934
const repaired = repairProviderWrappedModelOverride({
883935
entry,
@@ -1029,7 +1081,7 @@ async function agentCommandInternal(
10291081
authProfileOverrideSource: undefined,
10301082
authProfileOverrideCompactionCount: undefined,
10311083
};
1032-
} else if (sessionStore && sessionKey) {
1084+
} else if (sessionStore && sessionKey && !suppressVisibleSessionEffects) {
10331085
await clearSessionAuthProfileOverride({
10341086
sessionEntry: entry,
10351087
sessionStore,
@@ -1083,7 +1135,8 @@ async function agentCommandInternal(
10831135
sessionEntry &&
10841136
sessionStore &&
10851137
sessionKey &&
1086-
sessionEntry.thinkingLevel === previousThinkLevel
1138+
sessionEntry.thinkingLevel === previousThinkLevel &&
1139+
!suppressVisibleSessionEffects
10871140
) {
10881141
const entry = sessionEntry;
10891142
entry.thinkingLevel = fallbackThinkLevel;
@@ -1103,8 +1156,8 @@ async function agentCommandInternal(
11031156
const resolvedSessionFile = await resolveSessionTranscriptFile({
11041157
sessionId,
11051158
sessionKey,
1106-
sessionStore,
1107-
storePath,
1159+
sessionStore: suppressVisibleSessionEffects ? undefined : sessionStore,
1160+
storePath: suppressVisibleSessionEffects ? undefined : storePath,
11081161
sessionEntry,
11091162
agentId: sessionAgentId,
11101163
threadId: opts.threadId,
@@ -1124,6 +1177,9 @@ async function agentCommandInternal(
11241177
sessionFile = resolvedSessionFile.sessionFile;
11251178
sessionEntry = resolvedSessionFile.sessionEntry;
11261179
}
1180+
const attemptSessionFile = suppressVisibleSessionEffects
1181+
? await prepareInternalSessionEffectsTranscript({ sessionFile, runId })
1182+
: sessionFile;
11271183

11281184
const startedAt = Date.now();
11291185
const attemptLifecycleState = {
@@ -1295,7 +1351,7 @@ async function agentCommandInternal(
12951351
sessionId,
12961352
sessionKey,
12971353
sessionAgentId,
1298-
sessionFile,
1354+
sessionFile: attemptSessionFile,
12991355
workspaceDir,
13001356
body,
13011357
isFallbackRetry,
@@ -1317,11 +1373,12 @@ async function agentCommandInternal(
13171373
resolvedVerboseLevel,
13181374
agentDir,
13191375
authProfileProvider: providerForAuthProfileValidation,
1320-
sessionStore,
1321-
storePath,
1376+
sessionStore: suppressVisibleSessionEffects ? undefined : sessionStore,
1377+
storePath: suppressVisibleSessionEffects ? undefined : storePath,
13221378
allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe,
13231379
sessionHasHistory:
1324-
!isNewSession || (await attemptExecutionRuntime.sessionFileHasContent(sessionFile)),
1380+
!isNewSession ||
1381+
(await attemptExecutionRuntime.sessionFileHasContent(attemptSessionFile)),
13251382
suppressPromptPersistenceOnRetry:
13261383
opts.suppressPromptPersistence === true ||
13271384
(isFallbackRetry && attemptLifecycleState.currentTurnUserMessagePersisted),
@@ -1340,6 +1397,7 @@ async function agentCommandInternal(
13401397
sessionEntry &&
13411398
sessionStore &&
13421399
sessionKey &&
1400+
!suppressVisibleSessionEffects &&
13431401
entryMatchesAutoFallbackPrimaryProbe(sessionEntry, autoFallbackPrimaryProbe)
13441402
) {
13451403
const nextSessionEntry = { ...sessionEntry };
@@ -1493,7 +1551,7 @@ async function agentCommandInternal(
14931551
await fallbackTrajectoryRecorder?.flush();
14941552

14951553
// Update token+model fields in the session store.
1496-
if (sessionStore && sessionKey) {
1554+
if (sessionStore && sessionKey && !suppressVisibleSessionEffects) {
14971555
const { updateSessionStoreAfterAgentRun } = await loadSessionStoreRuntime();
14981556
await updateSessionStoreAfterAgentRun({
14991557
cfg,
@@ -1524,28 +1582,42 @@ async function agentCommandInternal(
15241582
if (transcriptPersistenceRunner === "cli" || embeddedAssistantGapFill) {
15251583
let persistedCliTurnTranscript = false;
15261584
try {
1585+
const transcriptSessionEntry: SessionEntry | undefined = suppressVisibleSessionEffects
1586+
? {
1587+
...(sessionEntry ?? {
1588+
sessionId,
1589+
updatedAt: Date.now(),
1590+
sessionStartedAt: Date.now(),
1591+
}),
1592+
sessionId,
1593+
sessionFile: attemptSessionFile,
1594+
}
1595+
: sessionEntry;
15271596
sessionEntry = await attemptExecutionRuntime.persistCliTurnTranscript({
15281597
body,
15291598
transcriptBody,
15301599
result,
15311600
sessionId,
15321601
sessionKey: sessionKey ?? sessionId,
1533-
sessionEntry,
1534-
sessionStore,
1535-
storePath,
1602+
sessionEntry: transcriptSessionEntry,
1603+
sessionStore: suppressVisibleSessionEffects ? undefined : sessionStore,
1604+
storePath: suppressVisibleSessionEffects ? undefined : storePath,
15361605
sessionAgentId,
15371606
threadId: opts.threadId,
15381607
sessionCwd: workspaceDir,
15391608
config: cfg,
15401609
embeddedAssistantGapFill,
15411610
});
1611+
if (suppressVisibleSessionEffects) {
1612+
sessionEntry = prepared.sessionEntry;
1613+
}
15421614
persistedCliTurnTranscript = true;
15431615
} catch (error) {
15441616
log.warn(
15451617
`Turn transcript persistence failed for ${sessionKey ?? sessionId}: ${error instanceof Error ? error.message : String(error)}`,
15461618
);
15471619
}
1548-
if (persistedCliTurnTranscript) {
1620+
if (persistedCliTurnTranscript && !suppressVisibleSessionEffects) {
15491621
sessionEntry = await (
15501622
await loadCliCompactionRuntime()
15511623
).runCliTurnCompactionLifecycle({
@@ -1579,6 +1651,7 @@ async function agentCommandInternal(
15791651
opts.deliver === true &&
15801652
sessionStore &&
15811653
sessionKey &&
1654+
!suppressVisibleSessionEffects &&
15821655
payloads.length > 0 &&
15831656
!isSubagentSessionKey(sessionKey)
15841657
) {
@@ -1612,7 +1685,7 @@ async function agentCommandInternal(
16121685

16131686
const { deliverAgentCommandResult } = await loadDeliveryRuntime();
16141687
const resolveFreshSessionEntryForDelivery =
1615-
sessionStore && sessionKey
1688+
sessionStore && sessionKey && !suppressVisibleSessionEffects
16161689
? async (): Promise<SessionEntry | undefined> => {
16171690
const { loadSessionStore } = await loadSessionStoreRuntime();
16181691
const freshStore = loadSessionStore(storePath, {
@@ -1648,7 +1721,12 @@ async function agentCommandInternal(
16481721
);
16491722

16501723
// Phase 2: Clear pending delivery payload after successful delivery.
1651-
if (sessionStore && sessionKey && !isSubagentSessionKey(sessionKey)) {
1724+
if (
1725+
sessionStore &&
1726+
sessionKey &&
1727+
!isSubagentSessionKey(sessionKey) &&
1728+
!suppressVisibleSessionEffects
1729+
) {
16521730
const entry = sessionStore[sessionKey] ?? sessionEntry;
16531731
const noPendingTextForThisRun =
16541732
opts.deliver === true &&

src/agents/command/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ export type AgentCommandOpts = {
106106
bootstrapContextRunKind?: "default" | "heartbeat" | "cron";
107107
internalEvents?: AgentInternalEvent[];
108108
inputProvenance?: InputProvenance;
109+
/** Internal runs can execute against a session without updating visible status/model/usage. */
110+
sessionEffects?: "visible" | "internal";
109111
/** Visible source replies must be sent through the message tool when set. */
110112
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
111113
/** Internal runs can omit the channel message tool entirely. */

0 commit comments

Comments
 (0)