mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(tui): run new sessions through lifecycle hooks (#100241)
* fix(tui): run new sessions through lifecycle hooks Co-authored-by: chunqiu <caopu@bupt.edu.cn> * test(tui): wait for idle before PTY new-session checks * docs(changelog): defer TUI batch entries --------- Co-authored-by: chunqiu <caopu@bupt.edu.cn>
This commit is contained in:
parent
ccdbb70cf0
commit
cd305b3b0c
16 changed files with 1055 additions and 507 deletions
|
|
@ -13,6 +13,7 @@ import type {
|
|||
TuiEvent,
|
||||
TuiModelChoice,
|
||||
TuiSessionList,
|
||||
TuiSessionCreateOptions,
|
||||
} from "../tui/tui-backend.js";
|
||||
import { runTui as defaultRunTui } from "../tui/tui.js";
|
||||
import type { CrestodianAssistantPlanner } from "./assistant.js";
|
||||
|
|
@ -227,6 +228,15 @@ class CrestodianTuiBackend implements TuiBackend {
|
|||
return { ok: true };
|
||||
}
|
||||
|
||||
async createSession(_opts: TuiSessionCreateOptions) {
|
||||
await this.resetSession();
|
||||
return {
|
||||
ok: true as const,
|
||||
key: CRESTODIAN_SESSION_KEY,
|
||||
entry: { sessionId: "crestodian", updatedAt: Date.now() },
|
||||
};
|
||||
}
|
||||
|
||||
async getGatewayStatus(): Promise<string> {
|
||||
const overview = await loadOverviewForTui(this.opts);
|
||||
return overview.gateway.reachable ? "Gateway reachable" : "Gateway unreachable";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
readStringValue,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
|
|
@ -34,11 +33,7 @@ import {
|
|||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { readAcpSessionMeta } from "../../acp/runtime/session-meta.js";
|
||||
import { resolveModelAgentRuntimeMetadata } from "../../agents/agent-runtime-metadata.js";
|
||||
import {
|
||||
listAgentIds,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
abortEmbeddedAgentRun,
|
||||
isEmbeddedAgentRunActive,
|
||||
|
|
@ -62,16 +57,10 @@ import {
|
|||
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
|
||||
import {
|
||||
applySessionPatchProjection,
|
||||
createSessionEntryWithTranscript,
|
||||
preflightSessionTranscriptForManualCompact,
|
||||
trimSessionTranscriptForManualCompact,
|
||||
} from "../../config/sessions/session-accessor.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
createInternalHookEvent,
|
||||
hasInternalHookListeners,
|
||||
triggerInternalHook,
|
||||
} from "../../hooks/internal-hooks.js";
|
||||
import {
|
||||
measureDiagnosticsTimelineSpan,
|
||||
measureDiagnosticsTimelineSpanSync,
|
||||
|
|
@ -79,12 +68,7 @@ import {
|
|||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { patchPluginSessionExtension } from "../../plugins/host-hook-state.js";
|
||||
import { isPluginJsonValue } from "../../plugins/host-hooks.js";
|
||||
import {
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
resolveAgentIdFromSessionKey,
|
||||
toAgentStoreSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import {
|
||||
interruptSessionWorkAdmissions,
|
||||
isSessionLifecycleMutationActive,
|
||||
|
|
@ -100,6 +84,11 @@ import {
|
|||
getSessionCompactionCheckpoint,
|
||||
listSessionCompactionCheckpoints,
|
||||
} from "../session-compaction-checkpoints.js";
|
||||
import {
|
||||
buildDashboardSessionKey,
|
||||
createGatewaySession,
|
||||
resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId,
|
||||
} from "../session-create-service.js";
|
||||
import { triggerSessionPatchHook } from "../session-patch-hooks.js";
|
||||
import {
|
||||
resolveSessionStoreAgentId,
|
||||
|
|
@ -130,7 +119,7 @@ import {
|
|||
type SessionsPreviewEntry,
|
||||
type SessionsPreviewResult,
|
||||
} from "../session-utils.js";
|
||||
import { applySessionsPatchToStore, projectSessionsPatchEntry } from "../sessions-patch.js";
|
||||
import { projectSessionsPatchEntry } from "../sessions-patch.js";
|
||||
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
|
||||
import { setGatewayDedupeEntry } from "./agent-wait-dedupe.js";
|
||||
import { chatHandlers } from "./chat.js";
|
||||
|
|
@ -178,38 +167,6 @@ function filterSessionStoreToConfiguredAgents(
|
|||
);
|
||||
}
|
||||
|
||||
function inheritSessionRuntimeSelection(
|
||||
parentEntry: SessionEntry | undefined,
|
||||
): Partial<SessionEntry> {
|
||||
if (!parentEntry) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
...(parentEntry.providerOverride ? { providerOverride: parentEntry.providerOverride } : {}),
|
||||
...(parentEntry.modelOverride ? { modelOverride: parentEntry.modelOverride } : {}),
|
||||
...(parentEntry.modelOverrideSource
|
||||
? { modelOverrideSource: parentEntry.modelOverrideSource }
|
||||
: {}),
|
||||
...(parentEntry.agentRuntimeOverride
|
||||
? { agentRuntimeOverride: parentEntry.agentRuntimeOverride }
|
||||
: {}),
|
||||
...(parentEntry.modelProvider ? { modelProvider: parentEntry.modelProvider } : {}),
|
||||
...(parentEntry.model ? { model: parentEntry.model } : {}),
|
||||
...(parentEntry.thinkingLevel ? { thinkingLevel: parentEntry.thinkingLevel } : {}),
|
||||
...(parentEntry.fastMode !== undefined ? { fastMode: parentEntry.fastMode } : {}),
|
||||
...(parentEntry.verboseLevel ? { verboseLevel: parentEntry.verboseLevel } : {}),
|
||||
...(parentEntry.traceLevel ? { traceLevel: parentEntry.traceLevel } : {}),
|
||||
...(parentEntry.reasoningLevel ? { reasoningLevel: parentEntry.reasoningLevel } : {}),
|
||||
...(parentEntry.elevatedLevel ? { elevatedLevel: parentEntry.elevatedLevel } : {}),
|
||||
...(parentEntry.authProfileOverride
|
||||
? { authProfileOverride: parentEntry.authProfileOverride }
|
||||
: {}),
|
||||
...(parentEntry.authProfileOverrideSource
|
||||
? { authProfileOverrideSource: parentEntry.authProfileOverrideSource }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
const loadSessionsRuntimeModule = createLazyRuntimeModule(() => import("./sessions.runtime.js"));
|
||||
|
||||
function requireSessionKey(key: unknown, respond: RespondFn): string | null {
|
||||
|
|
@ -348,10 +305,6 @@ function rejectWebchatSessionMutation(params: {
|
|||
return true;
|
||||
}
|
||||
|
||||
function buildDashboardSessionKey(agentId: string): string {
|
||||
return `agent:${agentId}:dashboard:${randomUUID()}`;
|
||||
}
|
||||
|
||||
function isAgentMainSessionKey(cfg: OpenClawConfig, sessionKey: string): boolean {
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
if (!parsed) {
|
||||
|
|
@ -530,61 +483,6 @@ function resolveSessionMessageSubscriptionKey(params: {
|
|||
: params.canonicalKey;
|
||||
}
|
||||
|
||||
type RequestedGlobalAgentIdResolution =
|
||||
| { ok: true; agentId?: string }
|
||||
| { ok: false; error: ReturnType<typeof errorShape> };
|
||||
|
||||
function resolveRequestedGlobalAgentId(
|
||||
cfg: OpenClawConfig,
|
||||
key: string,
|
||||
explicitAgentId?: string,
|
||||
): RequestedGlobalAgentIdResolution {
|
||||
const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey: key });
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
const requestedAgentId = normalizeOptionalString(explicitAgentId);
|
||||
if (requestedAgentId) {
|
||||
const agentId = normalizeAgentId(requestedAgentId);
|
||||
if (!listAgentIds(cfg).includes(agentId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${explicitAgentId}"`),
|
||||
};
|
||||
}
|
||||
if (parsed?.agentId && normalizeAgentId(parsed.agentId) !== agentId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"),
|
||||
};
|
||||
}
|
||||
if (canonicalKey !== "global") {
|
||||
const keyAgentId = parsed?.agentId
|
||||
? normalizeAgentId(parsed.agentId)
|
||||
: normalizeAgentId(resolveSessionStoreAgentId(cfg, canonicalKey));
|
||||
if (keyAgentId !== agentId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true, agentId };
|
||||
}
|
||||
if (!parsed?.agentId) {
|
||||
return { ok: true };
|
||||
}
|
||||
const inferredAgentId = normalizeAgentId(parsed.agentId);
|
||||
if (canonicalKey === "global" && !listAgentIds(cfg).includes(inferredAgentId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${parsed.agentId}"`),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
agentId: canonicalKey === "global" ? inferredAgentId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function interruptSessionRunIfActive(params: {
|
||||
req: GatewayRequestHandlerOptions["req"];
|
||||
context: GatewayRequestContext;
|
||||
|
|
@ -1280,239 +1178,77 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||
}
|
||||
const p = params;
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const requestedKey = normalizeOptionalString(p.key);
|
||||
const agentId = normalizeAgentId(
|
||||
normalizeOptionalString(p.agentId) ?? resolveDefaultAgentId(cfg),
|
||||
);
|
||||
if (requestedKey) {
|
||||
const requestedAgentId = parseAgentSessionKey(requestedKey)?.agentId;
|
||||
if (requestedAgentId && requestedAgentId !== agentId && normalizeOptionalString(p.agentId)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`sessions.create key agent (${requestedAgentId}) does not match agentId (${agentId})`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const parentSessionKey = normalizeOptionalString(p.parentSessionKey);
|
||||
let canonicalParentSessionKey: string | undefined;
|
||||
let parentSessionEntry: SessionEntry | undefined;
|
||||
let parentSelectedAgentId: string | undefined;
|
||||
if (parentSessionKey) {
|
||||
const parentCanonicalKey = resolveSessionStoreKey({ cfg, sessionKey: parentSessionKey });
|
||||
if (parentCanonicalKey === "global") {
|
||||
const parentRequestedAgent = resolveRequestedGlobalAgentId(
|
||||
cfg,
|
||||
parentSessionKey,
|
||||
p.agentId,
|
||||
);
|
||||
if (!parentRequestedAgent.ok) {
|
||||
respond(false, undefined, parentRequestedAgent.error);
|
||||
return;
|
||||
}
|
||||
parentSelectedAgentId = parentRequestedAgent.agentId;
|
||||
}
|
||||
const parent = loadSessionEntry(
|
||||
parentSessionKey,
|
||||
parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined,
|
||||
);
|
||||
if (!parent.entry?.sessionId) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `unknown parent session: ${parentSessionKey}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
canonicalParentSessionKey = parent.canonicalKey;
|
||||
parentSessionEntry = parent.entry;
|
||||
}
|
||||
if (
|
||||
canonicalParentSessionKey &&
|
||||
p.emitCommandHooks === true &&
|
||||
!requestedKey &&
|
||||
!resolveOptionalInitialSessionMessage(p) &&
|
||||
cfg.session?.dmScope === "main"
|
||||
) {
|
||||
const parentAgentId = normalizeAgentId(
|
||||
parentSelectedAgentId ??
|
||||
resolveAgentIdFromSessionKey(canonicalParentSessionKey) ??
|
||||
resolveDefaultAgentId(cfg),
|
||||
);
|
||||
const parentMainKey = resolveAgentMainSessionKey({ cfg, agentId: parentAgentId });
|
||||
if (canonicalParentSessionKey === parentMainKey) {
|
||||
const { performGatewaySessionReset } = await loadSessionsRuntimeModule();
|
||||
const resetResult = await performGatewaySessionReset({
|
||||
key: canonicalParentSessionKey,
|
||||
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
|
||||
? { agentId: parentSelectedAgentId }
|
||||
: {}),
|
||||
reason: "new",
|
||||
commandSource: "webchat",
|
||||
});
|
||||
if (!resetResult.ok) {
|
||||
respond(false, undefined, resetResult.error);
|
||||
return;
|
||||
}
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
key: resetResult.key,
|
||||
sessionId: resetResult.entry.sessionId,
|
||||
entry: resetResult.entry,
|
||||
runStarted: false,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
emitSessionsChanged(context, {
|
||||
sessionKey: resetResult.key,
|
||||
...(resetResult.key === "global" ? { agentId: resetResult.agentId } : {}),
|
||||
reason: "new",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (canonicalParentSessionKey && p.emitCommandHooks === true) {
|
||||
const { entry: parentEntry } = loadSessionEntry(
|
||||
canonicalParentSessionKey,
|
||||
parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined,
|
||||
);
|
||||
const parentAgentId = normalizeAgentId(
|
||||
parentSelectedAgentId ??
|
||||
resolveAgentIdFromSessionKey(canonicalParentSessionKey) ??
|
||||
resolveDefaultAgentId(cfg),
|
||||
);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, parentAgentId);
|
||||
if (hasInternalHookListeners("command", "new")) {
|
||||
const hookEvent = createInternalHookEvent("command", "new", canonicalParentSessionKey, {
|
||||
sessionEntry: parentEntry,
|
||||
previousSessionEntry: parentEntry,
|
||||
commandSource: "webchat",
|
||||
cfg,
|
||||
workspaceDir,
|
||||
});
|
||||
await triggerInternalHook(hookEvent);
|
||||
}
|
||||
const parentTarget = resolveGatewaySessionStoreTarget({
|
||||
cfg,
|
||||
key: canonicalParentSessionKey,
|
||||
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
|
||||
? { agentId: parentSelectedAgentId }
|
||||
: {}),
|
||||
});
|
||||
const { emitGatewayBeforeResetPluginHook } = await loadSessionsRuntimeModule();
|
||||
await emitGatewayBeforeResetPluginHook({
|
||||
cfg,
|
||||
key: canonicalParentSessionKey,
|
||||
target: parentTarget,
|
||||
storePath: parentTarget.storePath,
|
||||
entry: parentEntry,
|
||||
reason: "new",
|
||||
});
|
||||
}
|
||||
const loweredRequestedKey = normalizeOptionalLowercaseString(requestedKey);
|
||||
const key = requestedKey
|
||||
? loweredRequestedKey === "global" || loweredRequestedKey === "unknown"
|
||||
? loweredRequestedKey
|
||||
: toAgentStoreSessionKey({
|
||||
agentId,
|
||||
requestKey: requestedKey,
|
||||
mainKey: cfg.session?.mainKey,
|
||||
})
|
||||
: buildDashboardSessionKey(agentId);
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key, agentId });
|
||||
const targetAgentId = target.agentId;
|
||||
const created = await createSessionEntryWithTranscript(
|
||||
{
|
||||
agentId: targetAgentId,
|
||||
sessionKey: target.canonicalKey,
|
||||
storePath: target.storePath,
|
||||
},
|
||||
async ({ sessionEntries }) => {
|
||||
const patched = await applySessionsPatchToStore({
|
||||
cfg,
|
||||
store: sessionEntries,
|
||||
storeKey: target.canonicalKey,
|
||||
agentId: targetAgentId,
|
||||
patch: {
|
||||
key: target.canonicalKey,
|
||||
label: normalizeOptionalString(p.label),
|
||||
model: normalizeOptionalString(p.model),
|
||||
},
|
||||
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
|
||||
});
|
||||
if (!patched.ok || !canonicalParentSessionKey) {
|
||||
return patched;
|
||||
}
|
||||
const inheritedSelection = normalizeOptionalString(p.model)
|
||||
? {}
|
||||
: inheritSessionRuntimeSelection(parentSessionEntry);
|
||||
const nextEntry: SessionEntry = {
|
||||
...patched.entry,
|
||||
...inheritedSelection,
|
||||
parentSessionKey: canonicalParentSessionKey,
|
||||
};
|
||||
return {
|
||||
...patched,
|
||||
entry: nextEntry,
|
||||
};
|
||||
},
|
||||
);
|
||||
if (!created.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
created.phase === "transcript"
|
||||
? errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
`failed to create session transcript: ${created.error}`,
|
||||
)
|
||||
: created.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const createdEntry = created.entry;
|
||||
|
||||
const initialMessage = resolveOptionalInitialSessionMessage(p);
|
||||
let runPayload: Record<string, unknown> | undefined;
|
||||
let runError: unknown;
|
||||
let runMeta: Record<string, unknown> | undefined;
|
||||
const messageSeq = initialMessage
|
||||
? (await readSessionMessageCountAsync({
|
||||
agentId: target.agentId,
|
||||
sessionEntry: createdEntry,
|
||||
sessionId: createdEntry.sessionId,
|
||||
sessionKey: target.canonicalKey,
|
||||
storePath: target.storePath,
|
||||
})) + 1
|
||||
: undefined;
|
||||
|
||||
if (initialMessage) {
|
||||
await chatHandlers["chat.send"]({
|
||||
req,
|
||||
params: {
|
||||
sessionKey: target.canonicalKey,
|
||||
...(target.canonicalKey === "global" ? { agentId: target.agentId } : {}),
|
||||
message: initialMessage,
|
||||
idempotencyKey: randomUUID(),
|
||||
},
|
||||
respond: (ok, payload, error, meta) => {
|
||||
if (ok && payload && typeof payload === "object") {
|
||||
runPayload = payload as Record<string, unknown>;
|
||||
} else {
|
||||
runError = error;
|
||||
let messageSeq: number | undefined;
|
||||
const created = await createGatewaySession({
|
||||
cfg,
|
||||
key: p.key,
|
||||
agentId: p.agentId,
|
||||
label: p.label,
|
||||
model: p.model,
|
||||
parentSessionKey: p.parentSessionKey,
|
||||
emitCommandHooks: p.emitCommandHooks,
|
||||
resetMainWhenUnspecified: !initialMessage,
|
||||
commandSource: "webchat",
|
||||
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
|
||||
afterCreate: initialMessage
|
||||
? async ({ key, agentId, entry, storePath }) => {
|
||||
messageSeq =
|
||||
(await readSessionMessageCountAsync({
|
||||
agentId,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: key,
|
||||
storePath,
|
||||
})) + 1;
|
||||
await chatHandlers["chat.send"]({
|
||||
req,
|
||||
params: {
|
||||
sessionKey: key,
|
||||
...(key === "global" ? { agentId } : {}),
|
||||
message: initialMessage,
|
||||
idempotencyKey: randomUUID(),
|
||||
},
|
||||
respond: (ok, payload, error, meta) => {
|
||||
if (ok && payload && typeof payload === "object") {
|
||||
runPayload = payload as Record<string, unknown>;
|
||||
} else {
|
||||
runError = error;
|
||||
}
|
||||
runMeta = meta;
|
||||
},
|
||||
context,
|
||||
client,
|
||||
isWebchatConnect,
|
||||
});
|
||||
}
|
||||
runMeta = meta;
|
||||
: undefined,
|
||||
});
|
||||
if (!created.ok) {
|
||||
respond(false, undefined, created.error);
|
||||
return;
|
||||
}
|
||||
if (created.resetExisting) {
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
key: created.key,
|
||||
sessionId: created.entry.sessionId,
|
||||
entry: created.entry,
|
||||
runStarted: false,
|
||||
},
|
||||
context,
|
||||
client,
|
||||
isWebchatConnect,
|
||||
undefined,
|
||||
);
|
||||
emitSessionsChanged(context, {
|
||||
sessionKey: created.key,
|
||||
...(created.key === "global" ? { agentId: created.agentId } : {}),
|
||||
reason: "new",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const runStarted =
|
||||
|
|
@ -1526,9 +1262,9 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||
true,
|
||||
{
|
||||
ok: true,
|
||||
key: target.canonicalKey,
|
||||
sessionId: createdEntry.sessionId,
|
||||
entry: createdEntry,
|
||||
key: created.key,
|
||||
sessionId: created.entry.sessionId,
|
||||
entry: created.entry,
|
||||
runStarted,
|
||||
...(runPayload ? runPayload : {}),
|
||||
...(runStarted && typeof messageSeq === "number" ? { messageSeq } : {}),
|
||||
|
|
@ -1537,52 +1273,17 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||
undefined,
|
||||
);
|
||||
emitSessionsChanged(context, {
|
||||
sessionKey: target.canonicalKey,
|
||||
...(target.canonicalKey === "global" ? { agentId: target.agentId } : {}),
|
||||
sessionKey: created.key,
|
||||
...(created.key === "global" ? { agentId: created.agentId } : {}),
|
||||
reason: "create",
|
||||
});
|
||||
if (runStarted) {
|
||||
emitSessionsChanged(context, {
|
||||
sessionKey: target.canonicalKey,
|
||||
...(target.canonicalKey === "global" ? { agentId: target.agentId } : {}),
|
||||
sessionKey: created.key,
|
||||
...(created.key === "global" ? { agentId: created.agentId } : {}),
|
||||
reason: "send",
|
||||
});
|
||||
}
|
||||
if (canonicalParentSessionKey && p.emitCommandHooks === true) {
|
||||
const { entry: parentEntry } = loadSessionEntry(
|
||||
canonicalParentSessionKey,
|
||||
parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined,
|
||||
);
|
||||
const parentTarget = resolveGatewaySessionStoreTarget({
|
||||
cfg,
|
||||
key: canonicalParentSessionKey,
|
||||
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
|
||||
? { agentId: parentSelectedAgentId }
|
||||
: {}),
|
||||
});
|
||||
const { emitGatewaySessionEndPluginHook, emitGatewaySessionStartPluginHook } =
|
||||
await loadSessionsRuntimeModule();
|
||||
emitGatewaySessionEndPluginHook({
|
||||
cfg,
|
||||
sessionKey: canonicalParentSessionKey,
|
||||
sessionId: parentEntry?.sessionId,
|
||||
storePath: parentTarget.storePath,
|
||||
sessionFile: parentEntry?.sessionFile,
|
||||
agentId: parentTarget.agentId,
|
||||
reason: "new",
|
||||
nextSessionId: createdEntry.sessionId,
|
||||
nextSessionKey: target.canonicalKey,
|
||||
});
|
||||
emitGatewaySessionStartPluginHook({
|
||||
cfg,
|
||||
sessionKey: target.canonicalKey,
|
||||
sessionId: createdEntry.sessionId,
|
||||
resumedFrom: parentEntry?.sessionId,
|
||||
storePath: target.storePath,
|
||||
sessionFile: createdEntry.sessionFile,
|
||||
agentId: target.agentId,
|
||||
});
|
||||
}
|
||||
},
|
||||
"sessions.compaction.branch": async ({ params, respond, context }) => {
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -626,3 +626,22 @@ test("sessions.create can start the first agent turn from an initial task", asyn
|
|||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("sessions.create rejects replacing its parent key", async () => {
|
||||
await createSessionStoreDir();
|
||||
testState.agentsConfig = { list: [{ id: "main", default: true }] };
|
||||
await writeSessionStore({ entries: { main: sessionStoreEntry("sess-parent-task") } });
|
||||
|
||||
const created = await directSessionReq("sessions.create", {
|
||||
key: "main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
emitCommandHooks: true,
|
||||
task: "hello after replacing parent",
|
||||
});
|
||||
|
||||
expect(created.ok).toBe(false);
|
||||
expect(created.error).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "sessions.create key must differ from parentSessionKey",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { beginSessionWorkAdmission } from "../sessions/session-lifecycle-admission.js";
|
||||
import { embeddedRunMock, testState, writeSessionStore } from "./test-helpers.js";
|
||||
import {
|
||||
setupGatewaySessionsTestHarness,
|
||||
|
|
@ -647,6 +648,89 @@ test("sessions.create with emitCommandHooks=true emits reset lifecycle hooks aga
|
|||
expectStringWithPrefix(startEvent.sessionKey, "agent:main:dashboard:", "created session key");
|
||||
});
|
||||
|
||||
test("sessions.create waits for the parent run lifecycle before firing hooks", async () => {
|
||||
await createSessionStoreDir();
|
||||
await writeMainSessionEntry("sess-active-parent");
|
||||
embeddedRunMock.activeIds.add("sess-active-parent");
|
||||
|
||||
const result = await directSessionReq("sessions.create", {
|
||||
key: "tui-next",
|
||||
parentSessionKey: "main",
|
||||
emitCommandHooks: true,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatchObject({ code: "UNAVAILABLE" });
|
||||
expect(result.error?.message).toMatch(/parent session.*still active/i);
|
||||
expect(commandNewHookEvents()).toHaveLength(0);
|
||||
expect(beforeResetHookMocks.runBeforeReset).not.toHaveBeenCalled();
|
||||
expect(sessionLifecycleHookMocks.runSessionEnd).not.toHaveBeenCalled();
|
||||
expect(sessionLifecycleHookMocks.runSessionStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("sessions.create waits for the parent work admission to release", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
await writeMainSessionEntry("sess-finishing-parent");
|
||||
const admission = await beginSessionWorkAdmission({
|
||||
scope: storePath,
|
||||
identities: ["agent:main:main", "sess-finishing-parent"],
|
||||
assertAllowed: () => {},
|
||||
});
|
||||
try {
|
||||
const result = await directSessionReq("sessions.create", {
|
||||
key: "tui-next",
|
||||
parentSessionKey: "main",
|
||||
emitCommandHooks: true,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatchObject({ code: "UNAVAILABLE" });
|
||||
expect(commandNewHookEvents()).toHaveLength(0);
|
||||
expect(sessionLifecycleHookMocks.runSessionEnd).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
admission.release();
|
||||
}
|
||||
});
|
||||
|
||||
test("sessions.create fences new parent work while rollover hooks run", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
await writeMainSessionEntry("sess-parent-fenced");
|
||||
let releaseHook: (() => void) | undefined;
|
||||
sessionHookMocks.triggerInternalHook.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseHook = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const creating = directSessionReq("sessions.create", {
|
||||
key: "tui-next",
|
||||
parentSessionKey: "main",
|
||||
emitCommandHooks: true,
|
||||
});
|
||||
await vi.waitFor(() => expect(sessionHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1));
|
||||
|
||||
let admissionStarted = false;
|
||||
const admission = beginSessionWorkAdmission({
|
||||
scope: storePath,
|
||||
identities: ["agent:main:main", "sess-parent-fenced"],
|
||||
assertAllowed: () => {
|
||||
admissionStarted = true;
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(admissionStarted).toBe(false);
|
||||
|
||||
if (!releaseHook) {
|
||||
throw new Error("expected pending command:new hook");
|
||||
}
|
||||
releaseHook();
|
||||
expect((await creating).ok).toBe(true);
|
||||
const lease = await admission;
|
||||
expect(admissionStarted).toBe(true);
|
||||
lease.release();
|
||||
});
|
||||
|
||||
test("sessions.create with emitCommandHooks=true resets parent in place when session.dmScope is 'main' (#77434)", async () => {
|
||||
const { dir } = await createSessionStoreDir();
|
||||
const transcriptPath = await writeMessageTranscript({
|
||||
|
|
@ -666,6 +750,7 @@ test("sessions.create with emitCommandHooks=true resets parent in place when ses
|
|||
},
|
||||
},
|
||||
});
|
||||
embeddedRunMock.activeIds.add("sess-parent-dms");
|
||||
|
||||
const result = await directSessionReq<{
|
||||
ok: boolean;
|
||||
|
|
@ -696,6 +781,47 @@ test("sessions.create with emitCommandHooks=true resets parent in place when ses
|
|||
}
|
||||
});
|
||||
|
||||
test("sessions.create keeps an explicit TUI child key when session.dmScope is 'main'", async () => {
|
||||
const { dir } = await createSessionStoreDir();
|
||||
const transcriptPath = await writeMessageTranscript({
|
||||
dir,
|
||||
sessionId: "sess-parent-tui",
|
||||
content: "hello before TUI /new",
|
||||
});
|
||||
|
||||
testState.sessionConfig = { dmScope: "main" };
|
||||
try {
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-parent-tui",
|
||||
sessionFile: transcriptPath,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await directSessionReq<{ key: string; sessionId: string }>("sessions.create", {
|
||||
key: "tui-explicit",
|
||||
agentId: "main",
|
||||
parentSessionKey: "main",
|
||||
emitCommandHooks: true,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.payload?.key).toBe("agent:main:tui-explicit");
|
||||
expect(result.payload?.sessionId).not.toBe("sess-parent-tui");
|
||||
expect(expectSingleCommandNewHookEvent().context?.commandSource).toBe("webchat");
|
||||
const [endEvent] = firstHookCall(sessionLifecycleHookMocks.runSessionEnd);
|
||||
const [startEvent] = firstHookCall(sessionLifecycleHookMocks.runSessionStart);
|
||||
expect(endEvent.sessionKey).toBe("agent:main:main");
|
||||
expect(endEvent.nextSessionKey).toBe("agent:main:tui-explicit");
|
||||
expect(startEvent.sessionKey).toBe("agent:main:tui-explicit");
|
||||
} finally {
|
||||
testState.sessionConfig = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test("sessions.create without emitCommandHooks does not fire command:new hook (#76957)", async () => {
|
||||
const { dir } = await createSessionStoreDir();
|
||||
await writeSingleLineSession(dir, "sess-parent2", "hello from parent 2");
|
||||
|
|
|
|||
494
src/gateway/session-create-service.ts
Normal file
494
src/gateway/session-create-service.ts
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
errorShape,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
listAgentIds,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { isEmbeddedAgentRunActive } from "../agents/embedded-agent.js";
|
||||
import type { ModelCatalogEntry } from "../agents/model-catalog.types.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { resolveAgentMainSessionKey } from "../config/sessions/main-session.js";
|
||||
import { createSessionEntryWithTranscript } from "../config/sessions/session-accessor.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
createInternalHookEvent,
|
||||
hasInternalHookListeners,
|
||||
triggerInternalHook,
|
||||
} from "../hooks/internal-hooks.js";
|
||||
import {
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
resolveAgentIdFromSessionKey,
|
||||
toAgentStoreSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import {
|
||||
isSessionWorkAdmissionActive,
|
||||
runExclusiveSessionLifecycleMutation,
|
||||
} from "../sessions/session-lifecycle-admission.js";
|
||||
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
||||
import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "./session-store-key.js";
|
||||
import { loadSessionEntry, resolveGatewaySessionStoreTarget } from "./session-utils.js";
|
||||
import { applySessionsPatchToStore } from "./sessions-patch.js";
|
||||
|
||||
const loadSessionLifecycleRuntime = createLazyRuntimeModule(
|
||||
() => import("./server-methods/sessions.runtime.js"),
|
||||
);
|
||||
|
||||
type RequestedSessionAgentIdResolution =
|
||||
| { ok: true; agentId?: string }
|
||||
| { ok: false; error: ErrorShape };
|
||||
|
||||
export function resolveRequestedSessionAgentId(
|
||||
cfg: OpenClawConfig,
|
||||
key: string,
|
||||
explicitAgentId?: string,
|
||||
): RequestedSessionAgentIdResolution {
|
||||
const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey: key });
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
const requestedAgentId = normalizeOptionalString(explicitAgentId);
|
||||
if (requestedAgentId) {
|
||||
const agentId = normalizeAgentId(requestedAgentId);
|
||||
if (!listAgentIds(cfg).includes(agentId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${explicitAgentId}"`),
|
||||
};
|
||||
}
|
||||
if (parsed?.agentId && normalizeAgentId(parsed.agentId) !== agentId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"),
|
||||
};
|
||||
}
|
||||
if (canonicalKey !== "global") {
|
||||
const keyAgentId = parsed?.agentId
|
||||
? normalizeAgentId(parsed.agentId)
|
||||
: normalizeAgentId(resolveSessionStoreAgentId(cfg, canonicalKey));
|
||||
if (keyAgentId !== agentId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true, agentId };
|
||||
}
|
||||
if (!parsed?.agentId) {
|
||||
return { ok: true };
|
||||
}
|
||||
const inferredAgentId = normalizeAgentId(parsed.agentId);
|
||||
if (canonicalKey === "global" && !listAgentIds(cfg).includes(inferredAgentId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${parsed.agentId}"`),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
agentId: canonicalKey === "global" ? inferredAgentId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDashboardSessionKey(agentId: string): string {
|
||||
return `agent:${agentId}:dashboard:${randomUUID()}`;
|
||||
}
|
||||
|
||||
function inheritSessionRuntimeSelection(
|
||||
parentEntry: SessionEntry | undefined,
|
||||
): Partial<SessionEntry> {
|
||||
if (!parentEntry) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
...(parentEntry.providerOverride ? { providerOverride: parentEntry.providerOverride } : {}),
|
||||
...(parentEntry.modelOverride ? { modelOverride: parentEntry.modelOverride } : {}),
|
||||
...(parentEntry.modelOverrideSource
|
||||
? { modelOverrideSource: parentEntry.modelOverrideSource }
|
||||
: {}),
|
||||
...(parentEntry.agentRuntimeOverride
|
||||
? { agentRuntimeOverride: parentEntry.agentRuntimeOverride }
|
||||
: {}),
|
||||
...(parentEntry.modelProvider ? { modelProvider: parentEntry.modelProvider } : {}),
|
||||
...(parentEntry.model ? { model: parentEntry.model } : {}),
|
||||
...(parentEntry.thinkingLevel ? { thinkingLevel: parentEntry.thinkingLevel } : {}),
|
||||
...(parentEntry.fastMode !== undefined ? { fastMode: parentEntry.fastMode } : {}),
|
||||
...(parentEntry.verboseLevel ? { verboseLevel: parentEntry.verboseLevel } : {}),
|
||||
...(parentEntry.traceLevel ? { traceLevel: parentEntry.traceLevel } : {}),
|
||||
...(parentEntry.reasoningLevel ? { reasoningLevel: parentEntry.reasoningLevel } : {}),
|
||||
...(parentEntry.elevatedLevel ? { elevatedLevel: parentEntry.elevatedLevel } : {}),
|
||||
...(parentEntry.authProfileOverride
|
||||
? { authProfileOverride: parentEntry.authProfileOverride }
|
||||
: {}),
|
||||
...(parentEntry.authProfileOverrideSource
|
||||
? { authProfileOverrideSource: parentEntry.authProfileOverrideSource }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
type CreatedGatewaySession = {
|
||||
key: string;
|
||||
agentId: string;
|
||||
entry: SessionEntry;
|
||||
storePath: string;
|
||||
};
|
||||
|
||||
type CreateGatewaySessionResult =
|
||||
| {
|
||||
ok: true;
|
||||
key: string;
|
||||
agentId: string;
|
||||
entry: SessionEntry;
|
||||
resetExisting: boolean;
|
||||
}
|
||||
| { ok: false; error: ErrorShape };
|
||||
|
||||
export async function createGatewaySession(params: {
|
||||
cfg: OpenClawConfig;
|
||||
key?: string;
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
model?: string;
|
||||
parentSessionKey?: string;
|
||||
emitCommandHooks?: boolean;
|
||||
resetMainWhenUnspecified?: boolean;
|
||||
commandSource: string;
|
||||
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
|
||||
afterCreate?: (created: CreatedGatewaySession) => Promise<void>;
|
||||
}): Promise<CreateGatewaySessionResult> {
|
||||
const requestedKey = normalizeOptionalString(params.key);
|
||||
const agentId = normalizeAgentId(
|
||||
normalizeOptionalString(params.agentId) ?? resolveDefaultAgentId(params.cfg),
|
||||
);
|
||||
if (requestedKey) {
|
||||
const requestedAgentId = parseAgentSessionKey(requestedKey)?.agentId;
|
||||
if (
|
||||
requestedAgentId &&
|
||||
requestedAgentId !== agentId &&
|
||||
normalizeOptionalString(params.agentId)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`sessions.create key agent (${requestedAgentId}) does not match agentId (${agentId})`,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
const loweredRequestedKey = normalizeOptionalLowercaseString(requestedKey);
|
||||
const explicitTargetKey = requestedKey
|
||||
? loweredRequestedKey === "global" || loweredRequestedKey === "unknown"
|
||||
? loweredRequestedKey
|
||||
: toAgentStoreSessionKey({
|
||||
agentId,
|
||||
requestKey: requestedKey,
|
||||
mainKey: params.cfg.session?.mainKey,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const parentSessionKey = normalizeOptionalString(params.parentSessionKey);
|
||||
let canonicalParentSessionKey: string | undefined;
|
||||
let parentSessionEntry: SessionEntry | undefined;
|
||||
let parentSelectedAgentId: string | undefined;
|
||||
if (parentSessionKey) {
|
||||
const parentCanonicalKey = resolveSessionStoreKey({
|
||||
cfg: params.cfg,
|
||||
sessionKey: parentSessionKey,
|
||||
});
|
||||
if (parentCanonicalKey === "global") {
|
||||
const parentRequestedAgent = resolveRequestedSessionAgentId(
|
||||
params.cfg,
|
||||
parentSessionKey,
|
||||
params.agentId,
|
||||
);
|
||||
if (!parentRequestedAgent.ok) {
|
||||
return parentRequestedAgent;
|
||||
}
|
||||
parentSelectedAgentId = parentRequestedAgent.agentId;
|
||||
}
|
||||
const parent = loadSessionEntry(
|
||||
parentSessionKey,
|
||||
parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined,
|
||||
);
|
||||
if (!parent.entry?.sessionId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`unknown parent session: ${parentSessionKey}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
canonicalParentSessionKey = parent.canonicalKey;
|
||||
parentSessionEntry = parent.entry;
|
||||
}
|
||||
if (
|
||||
canonicalParentSessionKey &&
|
||||
explicitTargetKey &&
|
||||
resolveGatewaySessionStoreTarget({ cfg: params.cfg, key: explicitTargetKey, agentId })
|
||||
.canonicalKey === canonicalParentSessionKey
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"sessions.create key must differ from parentSessionKey",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
canonicalParentSessionKey &&
|
||||
params.emitCommandHooks === true &&
|
||||
!requestedKey &&
|
||||
params.resetMainWhenUnspecified === true &&
|
||||
params.cfg.session?.dmScope === "main"
|
||||
) {
|
||||
const parentAgentId = normalizeAgentId(
|
||||
parentSelectedAgentId ??
|
||||
resolveAgentIdFromSessionKey(canonicalParentSessionKey) ??
|
||||
resolveDefaultAgentId(params.cfg),
|
||||
);
|
||||
const parentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: parentAgentId });
|
||||
if (canonicalParentSessionKey === parentMainKey) {
|
||||
const { performGatewaySessionReset } = await loadSessionLifecycleRuntime();
|
||||
const resetResult = await performGatewaySessionReset({
|
||||
key: canonicalParentSessionKey,
|
||||
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
|
||||
? { agentId: parentSelectedAgentId }
|
||||
: {}),
|
||||
reason: "new",
|
||||
commandSource: params.commandSource,
|
||||
});
|
||||
if (!resetResult.ok) {
|
||||
return resetResult;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
key: resetResult.key,
|
||||
agentId: resetResult.agentId,
|
||||
entry: resetResult.entry,
|
||||
resetExisting: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let createdContext: CreatedGatewaySession | undefined;
|
||||
const createChildSession = async (): Promise<CreateGatewaySessionResult> => {
|
||||
let currentParentSessionEntry = parentSessionEntry;
|
||||
if (canonicalParentSessionKey && params.emitCommandHooks === true) {
|
||||
const currentParent = loadSessionEntry(
|
||||
canonicalParentSessionKey,
|
||||
parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined,
|
||||
);
|
||||
const currentParentEntry = currentParent.entry;
|
||||
if (
|
||||
!currentParentEntry?.sessionId ||
|
||||
currentParentEntry.sessionId !== parentSessionEntry?.sessionId
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`Parent session ${parentSessionKey} changed before /new; retry.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
currentParentSessionEntry = currentParentEntry;
|
||||
const parentTarget = resolveGatewaySessionStoreTarget({
|
||||
cfg: params.cfg,
|
||||
key: canonicalParentSessionKey,
|
||||
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
|
||||
? { agentId: parentSelectedAgentId }
|
||||
: {}),
|
||||
});
|
||||
const parentHasActiveWork =
|
||||
isEmbeddedAgentRunActive(currentParentEntry.sessionId) ||
|
||||
isSessionWorkAdmissionActive(parentTarget.storePath, [
|
||||
canonicalParentSessionKey,
|
||||
currentParentEntry.sessionId,
|
||||
]);
|
||||
if (parentHasActiveWork) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
`Parent session ${parentSessionKey} is still active; try again in a moment.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (canonicalParentSessionKey && params.emitCommandHooks === true) {
|
||||
const parentEntry = currentParentSessionEntry;
|
||||
const parentAgentId = normalizeAgentId(
|
||||
parentSelectedAgentId ??
|
||||
resolveAgentIdFromSessionKey(canonicalParentSessionKey) ??
|
||||
resolveDefaultAgentId(params.cfg),
|
||||
);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, parentAgentId);
|
||||
if (hasInternalHookListeners("command", "new")) {
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("command", "new", canonicalParentSessionKey, {
|
||||
sessionEntry: parentEntry,
|
||||
previousSessionEntry: parentEntry,
|
||||
commandSource: params.commandSource,
|
||||
cfg: params.cfg,
|
||||
workspaceDir,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const parentTarget = resolveGatewaySessionStoreTarget({
|
||||
cfg: params.cfg,
|
||||
key: canonicalParentSessionKey,
|
||||
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
|
||||
? { agentId: parentSelectedAgentId }
|
||||
: {}),
|
||||
});
|
||||
const { emitGatewayBeforeResetPluginHook } = await loadSessionLifecycleRuntime();
|
||||
await emitGatewayBeforeResetPluginHook({
|
||||
cfg: params.cfg,
|
||||
key: canonicalParentSessionKey,
|
||||
target: parentTarget,
|
||||
storePath: parentTarget.storePath,
|
||||
entry: parentEntry,
|
||||
reason: "new",
|
||||
});
|
||||
}
|
||||
|
||||
const key = explicitTargetKey ?? buildDashboardSessionKey(agentId);
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg: params.cfg, key, agentId });
|
||||
const created = await createSessionEntryWithTranscript<ErrorShape>(
|
||||
{
|
||||
agentId: target.agentId,
|
||||
sessionKey: target.canonicalKey,
|
||||
storePath: target.storePath,
|
||||
},
|
||||
async ({ sessionEntries }) => {
|
||||
const patched = await applySessionsPatchToStore({
|
||||
cfg: params.cfg,
|
||||
store: sessionEntries,
|
||||
storeKey: target.canonicalKey,
|
||||
agentId: target.agentId,
|
||||
patch: {
|
||||
key: target.canonicalKey,
|
||||
label: normalizeOptionalString(params.label),
|
||||
model: normalizeOptionalString(params.model),
|
||||
},
|
||||
loadGatewayModelCatalog: params.loadGatewayModelCatalog,
|
||||
});
|
||||
if (!patched.ok || !canonicalParentSessionKey) {
|
||||
return patched;
|
||||
}
|
||||
const inheritedSelection = normalizeOptionalString(params.model)
|
||||
? {}
|
||||
: inheritSessionRuntimeSelection(currentParentSessionEntry);
|
||||
return {
|
||||
...patched,
|
||||
entry: {
|
||||
...patched.entry,
|
||||
...inheritedSelection,
|
||||
parentSessionKey: canonicalParentSessionKey,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
if (!created.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
created.phase === "transcript"
|
||||
? errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
`failed to create session transcript: ${created.error}`,
|
||||
)
|
||||
: created.error,
|
||||
};
|
||||
}
|
||||
|
||||
createdContext = {
|
||||
key: target.canonicalKey,
|
||||
agentId: target.agentId,
|
||||
entry: created.entry,
|
||||
storePath: target.storePath,
|
||||
};
|
||||
|
||||
if (canonicalParentSessionKey && params.emitCommandHooks === true) {
|
||||
const parentEntry = currentParentSessionEntry;
|
||||
const parentTarget = resolveGatewaySessionStoreTarget({
|
||||
cfg: params.cfg,
|
||||
key: canonicalParentSessionKey,
|
||||
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
|
||||
? { agentId: parentSelectedAgentId }
|
||||
: {}),
|
||||
});
|
||||
const { emitGatewaySessionEndPluginHook, emitGatewaySessionStartPluginHook } =
|
||||
await loadSessionLifecycleRuntime();
|
||||
emitGatewaySessionEndPluginHook({
|
||||
cfg: params.cfg,
|
||||
sessionKey: canonicalParentSessionKey,
|
||||
sessionId: parentEntry?.sessionId,
|
||||
storePath: parentTarget.storePath,
|
||||
sessionFile: parentEntry?.sessionFile,
|
||||
agentId: parentTarget.agentId,
|
||||
reason: "new",
|
||||
nextSessionId: created.entry.sessionId,
|
||||
nextSessionKey: target.canonicalKey,
|
||||
});
|
||||
emitGatewaySessionStartPluginHook({
|
||||
cfg: params.cfg,
|
||||
sessionKey: target.canonicalKey,
|
||||
sessionId: created.entry.sessionId,
|
||||
resumedFrom: parentEntry?.sessionId,
|
||||
storePath: target.storePath,
|
||||
sessionFile: created.entry.sessionFile,
|
||||
agentId: target.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
key: target.canonicalKey,
|
||||
agentId: target.agentId,
|
||||
entry: created.entry,
|
||||
resetExisting: false,
|
||||
};
|
||||
};
|
||||
|
||||
if (
|
||||
canonicalParentSessionKey &&
|
||||
parentSessionEntry?.sessionId &&
|
||||
params.emitCommandHooks === true
|
||||
) {
|
||||
const parentTarget = resolveGatewaySessionStoreTarget({
|
||||
cfg: params.cfg,
|
||||
key: canonicalParentSessionKey,
|
||||
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
|
||||
? { agentId: parentSelectedAgentId }
|
||||
: {}),
|
||||
});
|
||||
const result = await runExclusiveSessionLifecycleMutation({
|
||||
scope: parentTarget.storePath,
|
||||
identities: [canonicalParentSessionKey, parentSessionEntry.sessionId],
|
||||
run: createChildSession,
|
||||
});
|
||||
if (result.ok && !result.resetExisting && createdContext) {
|
||||
await params.afterCreate?.(createdContext);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const result = await createChildSession();
|
||||
if (result.ok && !result.resetExisting && createdContext) {
|
||||
await params.afterCreate?.(createdContext);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ const updateSessionGoalStatusMock = vi.fn();
|
|||
const ensureRuntimePluginsLoadedMock = vi.fn();
|
||||
const ensureContextWindowCacheLoadedMock = vi.fn(async () => undefined);
|
||||
const runSessionStartupMigrationMock = vi.fn<() => Promise<void>>(async () => undefined);
|
||||
const createGatewaySessionMock = vi.fn();
|
||||
const listSessionsFromStoreAsyncMock = vi.fn(
|
||||
async (_options?: unknown): Promise<{ sessions: unknown[] }> => ({ sessions: [] }),
|
||||
);
|
||||
|
|
@ -206,6 +207,10 @@ vi.mock("../gateway/server-model-catalog.js", () => ({
|
|||
loadGatewayModelCatalog: (params?: unknown) => loadGatewayModelCatalogMock(params),
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/session-create-service.js", () => ({
|
||||
createGatewaySession: (...args: unknown[]) => createGatewaySessionMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/session-reset-service.js", () => ({
|
||||
performGatewaySessionReset: () => ({ ok: true, key: "agent:main:main", entry: {} }),
|
||||
}));
|
||||
|
|
@ -287,6 +292,13 @@ describe("EmbeddedTuiBackend", () => {
|
|||
ensureContextWindowCacheLoadedMock.mockResolvedValue(undefined);
|
||||
runSessionStartupMigrationMock.mockReset();
|
||||
runSessionStartupMigrationMock.mockResolvedValue(undefined);
|
||||
createGatewaySessionMock.mockReset();
|
||||
createGatewaySessionMock.mockResolvedValue({
|
||||
ok: true,
|
||||
key: "agent:main:tui-created",
|
||||
entry: { sessionId: "created-session" },
|
||||
resetExisting: false,
|
||||
});
|
||||
listSessionsFromStoreAsyncMock.mockReset();
|
||||
listSessionsFromStoreAsyncMock.mockResolvedValue({ sessions: [] });
|
||||
loadCombinedSessionStoreForGatewayMock.mockReset();
|
||||
|
|
@ -344,6 +356,34 @@ describe("EmbeddedTuiBackend", () => {
|
|||
defaultRuntime.error = originalRuntimeError;
|
||||
});
|
||||
|
||||
it("creates TUI sessions through the shared gateway lifecycle", async () => {
|
||||
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
|
||||
const backend = new EmbeddedTuiBackend();
|
||||
|
||||
const result = await backend.createSession({
|
||||
key: "tui-created",
|
||||
agentId: "main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
expect(createGatewaySessionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: {},
|
||||
key: "tui-created",
|
||||
agentId: "main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
emitCommandHooks: true,
|
||||
commandSource: "tui:embedded",
|
||||
loadGatewayModelCatalog: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
key: "agent:main:tui-created",
|
||||
entry: { sessionId: "created-session" },
|
||||
});
|
||||
});
|
||||
|
||||
it("bridges assistant and lifecycle events into chat events", async () => {
|
||||
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
|
||||
const pending = deferred<{
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
replaceOversizedChatHistoryMessages,
|
||||
} from "../gateway/server-methods/chat.js";
|
||||
import { loadGatewayModelCatalog } from "../gateway/server-model-catalog.js";
|
||||
import { createGatewaySession } from "../gateway/session-create-service.js";
|
||||
import { performGatewaySessionReset } from "../gateway/session-reset-service.js";
|
||||
import {
|
||||
capArrayByJsonBytes,
|
||||
|
|
@ -86,6 +87,7 @@ import type {
|
|||
TuiEvent,
|
||||
TuiModelChoice,
|
||||
TuiSessionList,
|
||||
TuiSessionCreateOptions,
|
||||
} from "./tui-backend.js";
|
||||
|
||||
type LocalRunState = {
|
||||
|
|
@ -673,6 +675,22 @@ export class EmbeddedTuiBackend implements TuiBackend {
|
|||
return { ok: true as const, key: result.key, entry: result.entry };
|
||||
}
|
||||
|
||||
async createSession(opts: TuiSessionCreateOptions) {
|
||||
await this.ready;
|
||||
const cfg = getRuntimeConfig();
|
||||
const result = await createGatewaySession({
|
||||
cfg,
|
||||
...opts,
|
||||
emitCommandHooks: Boolean(opts.parentSessionKey),
|
||||
commandSource: "tui:embedded",
|
||||
loadGatewayModelCatalog: () => loadEmbeddedTuiModelCatalog(cfg),
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
return { ok: true as const, key: result.key, entry: result.entry };
|
||||
}
|
||||
|
||||
private async runBtwTurn(params: {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import type {
|
|||
TuiModelChoice,
|
||||
TuiApprovalDecision,
|
||||
TuiSessionList,
|
||||
TuiSessionCreateOptions,
|
||||
TuiSessionMutationResult,
|
||||
TuiChatSendResult,
|
||||
} from "./tui-backend.js";
|
||||
|
|
@ -283,6 +284,13 @@ export class GatewayChatClient implements TuiBackend {
|
|||
return await this.client.request<SessionsPatchResult>("sessions.patch", opts);
|
||||
}
|
||||
|
||||
async createSession(opts: TuiSessionCreateOptions): Promise<TuiSessionMutationResult> {
|
||||
return await this.client.request<TuiSessionMutationResult>("sessions.create", {
|
||||
...opts,
|
||||
emitCommandHooks: Boolean(opts.parentSessionKey),
|
||||
});
|
||||
}
|
||||
|
||||
async resetSession(
|
||||
key: string,
|
||||
reason?: "new" | "reset",
|
||||
|
|
|
|||
|
|
@ -152,6 +152,13 @@ export type TuiSessionMutationResult = {
|
|||
};
|
||||
};
|
||||
|
||||
/** Options for creating a fresh TUI session through the backend lifecycle. */
|
||||
export type TuiSessionCreateOptions = {
|
||||
key: string;
|
||||
agentId?: string;
|
||||
parentSessionKey?: string;
|
||||
};
|
||||
|
||||
/** Minimal backend interface shared by Gateway and embedded local TUI modes. */
|
||||
export type TuiBackend = {
|
||||
connection: {
|
||||
|
|
@ -177,6 +184,7 @@ export type TuiBackend = {
|
|||
listSessions: (opts?: SessionsListParams) => Promise<TuiSessionList>;
|
||||
listAgents: () => Promise<TuiAgentsList>;
|
||||
patchSession: (opts: SessionsPatchParams) => Promise<SessionsPatchResult>;
|
||||
createSession: (opts: TuiSessionCreateOptions) => Promise<TuiSessionMutationResult>;
|
||||
resetSession: (
|
||||
key: string,
|
||||
reason?: "new" | "reset",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ type SelectableOverlay = {
|
|||
};
|
||||
type SetActivityStatusMock = ReturnType<typeof vi.fn> & ((text: string) => void);
|
||||
type SetSessionMock = ReturnType<typeof vi.fn> & ((key: string) => Promise<void>);
|
||||
type SetEmptySessionMock = ReturnType<typeof vi.fn> & ((key: string) => Promise<void>);
|
||||
type ConsumeCompletedRunMock = ReturnType<typeof vi.fn> & ((runId: string) => boolean);
|
||||
type FlushPendingHistoryRefreshMock = ReturnType<typeof vi.fn> & (() => void);
|
||||
|
||||
|
|
@ -81,11 +80,11 @@ function createHarness(params?: {
|
|||
listSessions?: ReturnType<typeof vi.fn>;
|
||||
listModels?: ReturnType<typeof vi.fn>;
|
||||
patchSession?: ReturnType<typeof vi.fn>;
|
||||
createSession?: ReturnType<typeof vi.fn>;
|
||||
resetSession?: ReturnType<typeof vi.fn>;
|
||||
runGoalCommand?: ReturnType<typeof vi.fn>;
|
||||
runAuthFlow?: RunAuthFlow;
|
||||
setSession?: SetSessionMock;
|
||||
setEmptySession?: SetEmptySessionMock;
|
||||
loadHistory?: LoadHistoryMock;
|
||||
refreshSessionInfo?: ReturnType<typeof vi.fn>;
|
||||
applySessionInfoFromPatch?: ReturnType<typeof vi.fn>;
|
||||
|
|
@ -112,11 +111,15 @@ function createHarness(params?: {
|
|||
const listSessions = params?.listSessions ?? vi.fn().mockResolvedValue({ sessions: [] });
|
||||
const listModels = params?.listModels ?? vi.fn().mockResolvedValue([]);
|
||||
const patchSession = params?.patchSession ?? vi.fn().mockResolvedValue({});
|
||||
const createSession =
|
||||
params?.createSession ??
|
||||
vi.fn().mockImplementation(async (opts: { key: string }) => ({
|
||||
ok: true,
|
||||
key: `agent:main:${opts.key}`,
|
||||
}));
|
||||
const resetSession = params?.resetSession ?? vi.fn().mockResolvedValue({ ok: true });
|
||||
const runGoalCommand = params?.runGoalCommand ?? vi.fn().mockResolvedValue({ text: "Goal" });
|
||||
const setSession = params?.setSession ?? (vi.fn().mockResolvedValue(undefined) as SetSessionMock);
|
||||
const setEmptySession =
|
||||
params?.setEmptySession ?? (vi.fn().mockResolvedValue(undefined) as SetEmptySessionMock);
|
||||
const addUser = vi.fn();
|
||||
const addPendingUser = vi.fn();
|
||||
const dropPendingUser = vi.fn();
|
||||
|
|
@ -159,13 +162,14 @@ function createHarness(params?: {
|
|||
sessionInfo: {},
|
||||
};
|
||||
|
||||
const { handleCommand, openSessionSelector } = createCommandHandlers({
|
||||
const { handleCommand, sendMessage, openSessionSelector } = createCommandHandlers({
|
||||
client: {
|
||||
sendChat,
|
||||
getGatewayStatus,
|
||||
listSessions,
|
||||
listModels,
|
||||
patchSession,
|
||||
createSession,
|
||||
resetSession,
|
||||
runGoalCommand,
|
||||
} as never,
|
||||
|
|
@ -187,7 +191,6 @@ function createHarness(params?: {
|
|||
refreshSessionInfo: refreshSessionInfo as never,
|
||||
loadHistory,
|
||||
setSession,
|
||||
setEmptySession,
|
||||
refreshAgents: vi.fn(),
|
||||
abortActive,
|
||||
setActivityStatus,
|
||||
|
|
@ -207,6 +210,7 @@ function createHarness(params?: {
|
|||
|
||||
return {
|
||||
handleCommand,
|
||||
sendMessage,
|
||||
getGatewayStatus,
|
||||
listSessions,
|
||||
listModels,
|
||||
|
|
@ -216,10 +220,10 @@ function createHarness(params?: {
|
|||
overlayHandle,
|
||||
closeOverlay,
|
||||
patchSession,
|
||||
createSession,
|
||||
resetSession,
|
||||
runGoalCommand,
|
||||
setSession,
|
||||
setEmptySession,
|
||||
addUser,
|
||||
addPendingUser,
|
||||
dropPendingUser,
|
||||
|
|
@ -948,7 +952,10 @@ describe("tui command handlers", () => {
|
|||
it("creates unique session for /new and resets shared session for /reset", async () => {
|
||||
const loadHistory = vi.fn().mockResolvedValue(undefined);
|
||||
const setSessionMock = vi.fn().mockResolvedValue(undefined) as SetSessionMock;
|
||||
const setEmptySessionMock = vi.fn().mockResolvedValue(undefined) as SetEmptySessionMock;
|
||||
const createSessionMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
key: "agent:main:tui-canonical",
|
||||
});
|
||||
const applySessionMutationResult = vi.fn().mockReturnValue(true);
|
||||
const refreshSessionInfo = vi.fn().mockResolvedValue(undefined);
|
||||
const resetResult = {
|
||||
|
|
@ -959,7 +966,8 @@ describe("tui command handlers", () => {
|
|||
const { handleCommand, resetSession } = createHarness({
|
||||
loadHistory,
|
||||
setSession: setSessionMock,
|
||||
setEmptySession: setEmptySessionMock,
|
||||
createSession: createSessionMock,
|
||||
currentSessionId: "old-session",
|
||||
applySessionMutationResult,
|
||||
refreshSessionInfo,
|
||||
resetSession: vi.fn().mockResolvedValue(resetResult),
|
||||
|
|
@ -969,18 +977,20 @@ describe("tui command handlers", () => {
|
|||
await handleCommand("/reset");
|
||||
|
||||
// /new creates a unique session key (isolates TUI client) (#39217)
|
||||
expect(setSessionMock).not.toHaveBeenCalled();
|
||||
expect(setEmptySessionMock).toHaveBeenCalledTimes(1);
|
||||
const newSessionKey = firstMockArg(setEmptySessionMock, "setEmptySession") as
|
||||
| string
|
||||
expect(createSessionMock).toHaveBeenCalledTimes(1);
|
||||
const createOptions = firstMockArg(createSessionMock, "createSession") as
|
||||
| { key?: string; agentId?: string; parentSessionKey?: string }
|
||||
| undefined;
|
||||
if (!newSessionKey) {
|
||||
throw new Error("expected /new to set a TUI session key");
|
||||
if (!createOptions?.key) {
|
||||
throw new Error("expected /new to create a TUI session key");
|
||||
}
|
||||
expect(newSessionKey.startsWith("tui-")).toBe(true);
|
||||
const uuidParts: string[] = newSessionKey.slice("tui-".length).split("-");
|
||||
expect(createOptions.agentId).toBe("main");
|
||||
expect(createOptions.parentSessionKey).toBe("agent:main:main");
|
||||
expect(createOptions.key.startsWith("tui-")).toBe(true);
|
||||
const uuidParts: string[] = createOptions.key.slice("tui-".length).split("-");
|
||||
expect(uuidParts.map((part) => part.length)).toEqual([8, 4, 4, 4, 12]);
|
||||
expect(uuidParts.every((part) => /^[0-9a-f]+$/.test(part))).toBe(true);
|
||||
expect(setSessionMock).toHaveBeenCalledWith("agent:main:tui-canonical");
|
||||
// /reset still resets the shared session
|
||||
expect(resetSession).toHaveBeenCalledTimes(1);
|
||||
expect(resetSession).toHaveBeenCalledWith("agent:main:main", "reset", undefined);
|
||||
|
|
@ -989,6 +999,101 @@ describe("tui command handlers", () => {
|
|||
expect(loadHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "omits an unknown parent for the first session",
|
||||
currentSessionKey: "agent:main:main",
|
||||
currentAgentId: "main",
|
||||
currentSessionId: null,
|
||||
expectedParent: undefined,
|
||||
},
|
||||
{
|
||||
name: "attributes a global session to the selected agent",
|
||||
currentSessionKey: "global",
|
||||
currentAgentId: "work",
|
||||
currentSessionId: "global-session",
|
||||
expectedParent: "global",
|
||||
},
|
||||
])("$name", async ({ currentSessionKey, currentAgentId, currentSessionId, expectedParent }) => {
|
||||
const createSession = vi.fn().mockResolvedValue({ ok: true, key: "agent:work:tui-next" });
|
||||
const { handleCommand } = createHarness({
|
||||
createSession,
|
||||
currentSessionKey,
|
||||
currentAgentId,
|
||||
currentSessionId,
|
||||
});
|
||||
|
||||
await handleCommand("/new");
|
||||
|
||||
expect(createSession).toHaveBeenCalledWith({
|
||||
key: expect.stringMatching(/^tui-/),
|
||||
agentId: currentAgentId,
|
||||
...(expectedParent ? { parentSessionKey: expectedParent } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
activeChatRunId: "active-run",
|
||||
pendingChatRunId: null,
|
||||
pendingOptimisticUserMessage: false,
|
||||
activityStatus: "running",
|
||||
},
|
||||
{
|
||||
activeChatRunId: null,
|
||||
pendingChatRunId: "pending-run",
|
||||
pendingOptimisticUserMessage: false,
|
||||
activityStatus: "sending",
|
||||
},
|
||||
{
|
||||
activeChatRunId: null,
|
||||
pendingChatRunId: null,
|
||||
pendingOptimisticUserMessage: true,
|
||||
activityStatus: "sending",
|
||||
},
|
||||
{
|
||||
activeChatRunId: null,
|
||||
pendingChatRunId: null,
|
||||
pendingOptimisticUserMessage: false,
|
||||
activityStatus: "finishing context",
|
||||
},
|
||||
])("blocks /new while the current session lifecycle is unfinished", async (runState) => {
|
||||
const createSession = vi.fn();
|
||||
const { handleCommand, addSystem } = createHarness({ createSession, ...runState });
|
||||
|
||||
await handleCommand("/new");
|
||||
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
expect(addSystem).toHaveBeenCalledWith("abort the current run before /new");
|
||||
});
|
||||
|
||||
it("serializes input until /new adopts the created session", async () => {
|
||||
let resolveCreate: ((value: { ok: true; key: string }) => void) | undefined;
|
||||
const createSession = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise<{ ok: true; key: string }>((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
}),
|
||||
);
|
||||
const { handleCommand, sendMessage, sendChat, addSystem } = createHarness({ createSession });
|
||||
|
||||
const creating = handleCommand("/new");
|
||||
await Promise.resolve();
|
||||
await sendMessage("must not reach parent");
|
||||
await handleCommand("/new");
|
||||
|
||||
expect(sendChat).not.toHaveBeenCalled();
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(addSystem).toHaveBeenCalledWith("session change in progress; message not sent");
|
||||
expect(addSystem).toHaveBeenCalledWith("session change in progress; wait for /new to finish");
|
||||
|
||||
if (!resolveCreate) {
|
||||
throw new Error("expected pending session creation");
|
||||
}
|
||||
resolveCreate({ ok: true, key: "agent:main:tui-created" });
|
||||
await creating;
|
||||
});
|
||||
|
||||
it("reloads history after /reset when the backend does not return a session entry", async () => {
|
||||
const loadHistory = vi.fn().mockResolvedValue(undefined);
|
||||
const applySessionMutationResult = vi.fn().mockReturnValue(false);
|
||||
|
|
@ -1101,10 +1206,10 @@ describe("tui command handlers", () => {
|
|||
});
|
||||
|
||||
it("sanitizes control sequences in /new and /reset failures", async () => {
|
||||
const setEmptySession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m"));
|
||||
const createSession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m"));
|
||||
const resetSession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m"));
|
||||
const { handleCommand, addSystem } = createHarness({
|
||||
setEmptySession,
|
||||
createSession,
|
||||
resetSession,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ type CommandHandlerContext = {
|
|||
refreshSessionInfo: () => Promise<void>;
|
||||
loadHistory: () => Promise<void>;
|
||||
setSession: (key: string) => Promise<void>;
|
||||
setEmptySession: (key: string) => Promise<void>;
|
||||
refreshAgents: () => Promise<void>;
|
||||
abortActive: (params?: { preferActive?: boolean }) => Promise<void>;
|
||||
setActivityStatus: (text: string) => void;
|
||||
|
|
@ -129,7 +128,6 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||
refreshSessionInfo,
|
||||
loadHistory,
|
||||
setSession,
|
||||
setEmptySession,
|
||||
refreshAgents,
|
||||
abortActive,
|
||||
setActivityStatus,
|
||||
|
|
@ -146,6 +144,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||
runAuthFlow,
|
||||
requestExit,
|
||||
} = context;
|
||||
let sessionCreationInFlight = false;
|
||||
|
||||
const addUnsupportedLocalCommand = (name: string) => {
|
||||
chatLog.addSystem(`/${name} is not available in local embedded mode; message not sent`);
|
||||
|
|
@ -165,6 +164,9 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||
const hasTrackedAbortTarget = () =>
|
||||
Boolean(state.activeChatRunId || state.pendingChatRunId || state.pendingOptimisticUserMessage);
|
||||
|
||||
const hasUnsafeSessionRollover = () =>
|
||||
hasTrackedAbortTarget() || state.activityStatus === "finishing context";
|
||||
|
||||
const currentSessionPatchTarget = () => ({
|
||||
key: state.currentSessionKey,
|
||||
...(state.currentSessionKey === "global" ? { agentId: state.currentAgentId } : {}),
|
||||
|
|
@ -359,6 +361,11 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||
if (!name) {
|
||||
return;
|
||||
}
|
||||
if (sessionCreationInFlight && name !== "exit" && name !== "quit") {
|
||||
chatLog.addSystem("session change in progress; wait for /new to finish");
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
switch (name) {
|
||||
case "help":
|
||||
chatLog.addSystem(
|
||||
|
|
@ -704,6 +711,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||
break;
|
||||
}
|
||||
case "new":
|
||||
if (hasUnsafeSessionRollover()) {
|
||||
chatLog.addSystem("abort the current run before /new");
|
||||
tui.requestRender();
|
||||
break;
|
||||
}
|
||||
sessionCreationInFlight = true;
|
||||
try {
|
||||
// Clear token counts immediately to avoid stale display (#1523)
|
||||
state.sessionInfo.inputTokens = null;
|
||||
|
|
@ -711,14 +724,21 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||
state.sessionInfo.totalTokens = null;
|
||||
tui.requestRender();
|
||||
|
||||
// Generate unique session key to isolate this TUI client (#39217)
|
||||
// This ensures /new creates a fresh session that doesn't broadcast
|
||||
// to other connected TUI clients sharing the original session key.
|
||||
const uniqueKey = `tui-${randomUUID()}`;
|
||||
await setEmptySession(uniqueKey);
|
||||
chatLog.addSystem(`new session: ${uniqueKey}`);
|
||||
const result = await client.createSession({
|
||||
key: uniqueKey,
|
||||
agentId: state.currentAgentId,
|
||||
...(state.currentSessionId ? { parentSessionKey: state.currentSessionKey } : {}),
|
||||
});
|
||||
if (!result.key) {
|
||||
throw new Error("sessions.create returned no session key");
|
||||
}
|
||||
await setSession(result.key);
|
||||
chatLog.addSystem(`new session: ${result.key}`);
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`new session failed: ${sanitizeRenderableText(String(err))}`);
|
||||
} finally {
|
||||
sessionCreationInFlight = false;
|
||||
}
|
||||
break;
|
||||
case "reset":
|
||||
|
|
@ -782,6 +802,11 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (sessionCreationInFlight) {
|
||||
chatLog.addSystem("session change in progress; message not sent");
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
const isBtw = isBtwCommand(text);
|
||||
const busy = Boolean(
|
||||
state.activeChatRunId || state.pendingChatRunId || state.pendingOptimisticUserMessage,
|
||||
|
|
|
|||
|
|
@ -318,6 +318,12 @@ async function writeTuiPtyFixtureScript(dir: string) {
|
|||
};
|
||||
}
|
||||
|
||||
async createSession(opts: Parameters<TuiBackend["createSession"]>[0]) {
|
||||
record("createSession", opts);
|
||||
const key = "agent:main:" + opts.key;
|
||||
return { ok: true, key, entry: { ...sessionEntry(key), sessionId: "created-session" } };
|
||||
}
|
||||
|
||||
async resetSession(key: string, reason?: "new" | "reset") {
|
||||
record("resetSession", { key, reason });
|
||||
return {};
|
||||
|
|
@ -640,6 +646,24 @@ describe.sequential("TUI PTY harness", () => {
|
|||
TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"creates a backend session from /new and adopts its canonical key",
|
||||
async () => {
|
||||
await fixture.run.write("/new\r", { delay: false });
|
||||
await fixture.run.waitForOutput("new session: agent:main:tui-");
|
||||
const created = await fixture.waitForLogEntry((entry) => entry.method === "createSession");
|
||||
expect(created.payload).toMatchObject({ agentId: "main" });
|
||||
expect(created.payload).not.toHaveProperty("parentSessionKey");
|
||||
|
||||
await fixture.run.write("after new\r", { delay: false });
|
||||
const sent = await fixture.waitForLogEntry(
|
||||
(entry) => entry.method === "sendChat" && objectFieldEquals(entry, "message", "after new"),
|
||||
);
|
||||
expect(sent.payload).toMatchObject({ sessionKey: expect.stringMatching(/^agent:main:tui-/) });
|
||||
},
|
||||
TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"resets the current session from /reset",
|
||||
async () => {
|
||||
|
|
@ -654,7 +678,7 @@ describe.sequential("TUI PTY harness", () => {
|
|||
return false;
|
||||
}
|
||||
const key = (entry.payload as Record<string, unknown>).key;
|
||||
return key === "main" || key === "agent:main:main";
|
||||
return typeof key === "string" && (key === "main" || key.startsWith("agent:main:"));
|
||||
});
|
||||
},
|
||||
TEST_TIMEOUT_MS,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,17 @@ const LOCAL_OUTPUT_TIMEOUT_MS = 120_000;
|
|||
const LOCAL_EXIT_TIMEOUT_MS = 4_000;
|
||||
const LOCAL_TEST_TIMEOUT_MS = 150_000;
|
||||
|
||||
async function waitForOutputAfter(run: PtyRun, needle: string, offset: number) {
|
||||
await waitFor({
|
||||
timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS,
|
||||
read: () => (run.output().slice(offset).includes(needle) ? true : null),
|
||||
onTimeout: () =>
|
||||
new Error(
|
||||
`timed out waiting for ${JSON.stringify(needle)} after offset ${offset}\n${run.output()}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function readRequestBody(req: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
|
|
@ -414,6 +425,24 @@ describe("TUI PTY real backends", () => {
|
|||
expect(request?.body.model).toBe("gpt-5.5");
|
||||
await fixture.run.waitForOutput("LOCAL_PTY_RESPONSE");
|
||||
|
||||
// Text deltas arrive before the terminal lifecycle event. Wait for the
|
||||
// finished run to become idle so /new exercises session creation.
|
||||
const responseOffset = fixture.run.output().lastIndexOf("LOCAL_PTY_RESPONSE");
|
||||
await waitForOutputAfter(fixture.run, "| idle", responseOffset);
|
||||
|
||||
await fixture.run.write("/new\r", { delay: false });
|
||||
await fixture.run.waitForOutput("new session: agent:main:tui-");
|
||||
await fixture.run.write("send after local new\r");
|
||||
await waitFor({
|
||||
timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS,
|
||||
read: () => (fixture.mockModel.requests().length === 2 ? true : null),
|
||||
onTimeout: () =>
|
||||
new Error(`post-/new prompt did not reach the model\n${fixture.run.output()}`),
|
||||
});
|
||||
expect(JSON.stringify(fixture.mockModel.requests()[1]?.body)).toContain(
|
||||
"send after local new",
|
||||
);
|
||||
|
||||
await fixture.run.write("/exit\r", { delay: false });
|
||||
const exit = await fixture.run.waitForExit();
|
||||
expect(exit.exitCode).toBe(0);
|
||||
|
|
@ -424,6 +453,45 @@ describe("TUI PTY real backends", () => {
|
|||
LOCAL_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"creates and adopts a fresh session through the real Gateway backend",
|
||||
async () => {
|
||||
const fixture = await startGatewayModeTui({
|
||||
queueMode: "followup",
|
||||
firstResponseDelayMs: 0,
|
||||
});
|
||||
try {
|
||||
await fixture.run.waitForOutput("gateway connected", LOCAL_STARTUP_TIMEOUT_MS);
|
||||
await fixture.run.write("seed gateway session\r");
|
||||
await fixture.run.waitForOutput("FIRST_RUN_ACTIVE");
|
||||
|
||||
const responseOffset = fixture.run.output().lastIndexOf("FIRST_RUN_ACTIVE");
|
||||
await waitForOutputAfter(fixture.run, "| idle", responseOffset);
|
||||
|
||||
await fixture.run.write("/new\r", { delay: false });
|
||||
await fixture.run.waitForOutput("new session: agent:main:tui-");
|
||||
await fixture.run.write("send after gateway new\r");
|
||||
await waitFor({
|
||||
timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS,
|
||||
read: () => (fixture.mockModel.requests().length === 2 ? true : null),
|
||||
onTimeout: () =>
|
||||
new Error(
|
||||
`post-/new Gateway prompt did not reach the model\n${fixture.gateway.logs()}\n${fixture.run.output()}`,
|
||||
),
|
||||
});
|
||||
const freshRequest = JSON.stringify(fixture.mockModel.requests()[1]?.body);
|
||||
expect(freshRequest).toContain("send after gateway new");
|
||||
expect(freshRequest).not.toContain("seed gateway session");
|
||||
|
||||
await fixture.run.write("/exit\r", { delay: false });
|
||||
expect((await fixture.run.waitForExit()).exitCode).toBe(0);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
},
|
||||
LOCAL_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"forwards an active-run prompt through the real Gateway followup queue",
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ describe("tui session actions", () => {
|
|||
sessions: [
|
||||
{
|
||||
key: "agent:main:main",
|
||||
sessionId: "session-old",
|
||||
model: "old",
|
||||
modelProvider: "anthropic",
|
||||
},
|
||||
|
|
@ -141,6 +142,7 @@ describe("tui session actions", () => {
|
|||
sessions: [
|
||||
{
|
||||
key: "agent:main:main",
|
||||
sessionId: "session-current",
|
||||
model: "Minimax-M2.7",
|
||||
modelProvider: "minimax",
|
||||
},
|
||||
|
|
@ -150,6 +152,7 @@ describe("tui session actions", () => {
|
|||
await Promise.all([first, second]);
|
||||
|
||||
expect(state.sessionInfo.model).toBe("Minimax-M2.7");
|
||||
expect(state.currentSessionId).toBe("session-current");
|
||||
expect(updateAutocompleteProvider).toHaveBeenCalledTimes(2);
|
||||
expect(updateFooter).toHaveBeenCalledTimes(2);
|
||||
expect(requestRender).toHaveBeenCalledTimes(2);
|
||||
|
|
@ -762,71 +765,6 @@ describe("tui session actions", () => {
|
|||
expect(state.pendingChatRunId).toBeNull();
|
||||
});
|
||||
|
||||
it("starts an empty session without loading gateway history", async () => {
|
||||
const loadHistory = vi.fn().mockResolvedValue({ messages: [] });
|
||||
const listSessions = vi.fn().mockResolvedValue({ sessions: [] });
|
||||
const addSystem = vi.fn();
|
||||
const clearAll = vi.fn();
|
||||
const requestRender = vi.fn();
|
||||
const rememberSessionKey = vi.fn();
|
||||
const state = createBaseState({
|
||||
activeChatRunId: "run-1",
|
||||
pendingChatRunId: "run-2",
|
||||
pendingOptimisticUserMessage: true,
|
||||
currentSessionId: "old-session",
|
||||
historyLoaded: false,
|
||||
sessionInfo: {
|
||||
model: "old-model",
|
||||
modelProvider: "old-provider",
|
||||
contextTokens: 99,
|
||||
thinkingLevel: "high",
|
||||
fastMode: false,
|
||||
verboseLevel: "debug",
|
||||
inputTokens: 1,
|
||||
outputTokens: 2,
|
||||
totalTokens: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const { setEmptySession } = createTestSessionActions({
|
||||
client: { listSessions, loadHistory } as unknown as TuiBackend,
|
||||
chatLog: {
|
||||
addSystem,
|
||||
clearAll,
|
||||
} as unknown as import("./components/chat-log.js").ChatLog,
|
||||
tui: { requestRender } as unknown as import("@earendil-works/pi-tui").TUI,
|
||||
state,
|
||||
rememberSessionKey,
|
||||
emptySessionInfoDefaults: {
|
||||
verboseLevel: "on",
|
||||
},
|
||||
});
|
||||
|
||||
await setEmptySession("agent:main:tui-empty");
|
||||
|
||||
expect(loadHistory).not.toHaveBeenCalled();
|
||||
expect(listSessions).not.toHaveBeenCalled();
|
||||
expect(state.currentSessionKey).toBe("agent:main:tui-empty");
|
||||
expect(state.currentSessionId).toBeNull();
|
||||
expect(state.activeChatRunId).toBeNull();
|
||||
expect(state.pendingChatRunId).toBeNull();
|
||||
expect(state.pendingOptimisticUserMessage).toBe(false);
|
||||
expect(state.historyLoaded).toBe(true);
|
||||
expect(state.sessionInfo.model).toBeUndefined();
|
||||
expect(state.sessionInfo.modelProvider).toBeUndefined();
|
||||
expect(state.sessionInfo.contextTokens).toBeNull();
|
||||
expect(state.sessionInfo.thinkingLevel).toBeUndefined();
|
||||
expect(state.sessionInfo.fastMode).toBeUndefined();
|
||||
expect(state.sessionInfo.verboseLevel).toBe("on");
|
||||
expect(state.sessionInfo.inputTokens).toBeNull();
|
||||
expect(state.sessionInfo.outputTokens).toBeNull();
|
||||
expect(state.sessionInfo.totalTokens).toBeNull();
|
||||
expect(clearAll).toHaveBeenCalled();
|
||||
expect(addSystem).toHaveBeenCalledWith("session agent:main:tui-empty");
|
||||
expect(rememberSessionKey).toHaveBeenCalledWith("agent:main:tui-empty");
|
||||
expect(requestRender).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies reset mutation result without reloading gateway history", () => {
|
||||
const loadHistory = vi.fn().mockResolvedValue({ messages: [] });
|
||||
const addSystem = vi.fn();
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ type SessionActionContext = {
|
|||
setActivityStatus: (text: string) => void;
|
||||
clearLocalRunIds?: () => void;
|
||||
rememberSessionKey?: (sessionKey: string) => void | Promise<void>;
|
||||
emptySessionInfoDefaults?: SessionInfo;
|
||||
};
|
||||
|
||||
type SessionInfoDefaults = {
|
||||
|
|
@ -118,7 +117,6 @@ export function createSessionActions(context: SessionActionContext) {
|
|||
setActivityStatus,
|
||||
clearLocalRunIds,
|
||||
rememberSessionKey,
|
||||
emptySessionInfoDefaults,
|
||||
} = context;
|
||||
let refreshSessionInfoInFlight: Promise<void> | null = null;
|
||||
let refreshSessionInfoQueued = false;
|
||||
|
|
@ -340,6 +338,7 @@ export function createSessionActions(context: SessionActionContext) {
|
|||
state.currentSessionKey = entry.key;
|
||||
updateHeader();
|
||||
}
|
||||
state.currentSessionId = typeof entry?.sessionId === "string" ? entry.sessionId : null;
|
||||
applySessionInfo({
|
||||
entry,
|
||||
defaults: result.defaults,
|
||||
|
|
@ -583,37 +582,6 @@ export function createSessionActions(context: SessionActionContext) {
|
|||
await loadHistory();
|
||||
};
|
||||
|
||||
const setEmptySession = async (rawKey: string) => {
|
||||
const nextKey = resolveSessionKey(rawKey);
|
||||
updateAgentFromSessionKey(nextKey);
|
||||
state.currentSessionKey = nextKey;
|
||||
state.activeChatRunId = null;
|
||||
state.pendingChatRunId = null;
|
||||
state.pendingOptimisticUserMessage = false;
|
||||
state.pendingSubmitDraft = null;
|
||||
setActivityStatus("idle");
|
||||
state.currentSessionId = null;
|
||||
const defaults = lastSessionDefaults;
|
||||
state.sessionInfo = {
|
||||
...emptySessionInfoDefaults,
|
||||
modelProvider: defaults?.modelProvider ?? undefined,
|
||||
model: defaults?.model ?? undefined,
|
||||
contextTokens: defaults?.contextTokens ?? null,
|
||||
thinkingLevels: defaults?.thinkingLevels ?? emptySessionInfoDefaults?.thinkingLevels,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
totalTokens: null,
|
||||
goal: undefined,
|
||||
updatedAt: null,
|
||||
displayName: undefined,
|
||||
};
|
||||
clearLocalRunIds?.();
|
||||
updateHeader();
|
||||
updateAutocompleteProvider();
|
||||
updateFooter();
|
||||
clearDisplayedSession();
|
||||
};
|
||||
|
||||
const abortActive = async (params?: { preferActive?: boolean }) => {
|
||||
if (
|
||||
opts.local === true &&
|
||||
|
|
@ -674,7 +642,6 @@ export function createSessionActions(context: SessionActionContext) {
|
|||
applySessionMutationResult,
|
||||
loadHistory,
|
||||
setSession,
|
||||
setEmptySession,
|
||||
abortActive,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1305,7 +1305,6 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
|
|||
setActivityStatus,
|
||||
clearLocalRunIds,
|
||||
rememberSessionKey: rememberCurrentSessionKey,
|
||||
emptySessionInfoDefaults,
|
||||
});
|
||||
const {
|
||||
refreshAgents,
|
||||
|
|
@ -1314,7 +1313,6 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
|
|||
applySessionMutationResult,
|
||||
loadHistory,
|
||||
setSession,
|
||||
setEmptySession,
|
||||
abortActive,
|
||||
} = sessionActions;
|
||||
|
||||
|
|
@ -1411,7 +1409,6 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
|
|||
applySessionMutationResult,
|
||||
loadHistory,
|
||||
setSession,
|
||||
setEmptySession,
|
||||
refreshAgents,
|
||||
abortActive,
|
||||
setActivityStatus,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue