mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-22 07:05:41 +00:00
refactor: seed session lifetime instead of querying the workspace handler
The session title service must not depend on the Workspace-tier handler registry. The handler now seeds each session scope with an abort signal, fires it synchronously when a close begins, and the title service carries the signal on its request, drops the write-back once aborted, and drains an in-flight generation through the onWillCloseSession hook.
This commit is contained in:
parent
531dbd3774
commit
5e16893b4c
8 changed files with 184 additions and 42 deletions
|
|
@ -91,6 +91,11 @@ const DOMAIN_LAYER = new Map([
|
|||
// create-source/close-reason vocabulary; a pure contract with no IO, so it
|
||||
// sits in L1 beside `sessionContext`.
|
||||
['sessionLifecycleHooks', 1],
|
||||
// `sessionLifetime` is the per-session liveness seed (`AbortSignal`
|
||||
// aborted by the Workspace-scope `workspaceHandler` when the session's
|
||||
// close begins); a pure contract with no IO, so it sits in L1 beside
|
||||
// `sessionLifecycleHooks`.
|
||||
['sessionLifetime', 1],
|
||||
// `scopeContext` is the Agent-scope seeded immutable facts value
|
||||
// (`agentId` plus a persistence scope helper); a pure seed with no IO, so it
|
||||
// sits in L1 beside `sessionContext`.
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ export * from '#/workspace/workspaceHandler/workspaceHandler';
|
|||
export * from '#/workspace/workspaceHandler/workspaceHandlerService';
|
||||
export * from '#/workspace/workspaceHandler/addressing';
|
||||
export * from '#/session/sessionLifecycleHooks/sessionLifecycleHooks';
|
||||
export * from '#/session/sessionLifetime/sessionLifetime';
|
||||
export * from '#/session/externalHooks/externalHooks';
|
||||
export * from '#/session/externalHooks/externalHooksService';
|
||||
import '#/app/sessionExport/errors';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* `sessionLifetime` domain (L1) — per-session liveness seed.
|
||||
*
|
||||
* Defines the `ISessionLifetime` carrying the session scope's liveness
|
||||
* `AbortSignal`: the Workspace-scope `workspaceHandler` creates one
|
||||
* `AbortController` per materialized session, seeds the signal into the
|
||||
* Session scope, and aborts it synchronously when the session's close
|
||||
* begins — before any async `onWillCloseSession` hook runs. Session-scope
|
||||
* consumers with in-flight async work (e.g. `sessionTitle`) treat an
|
||||
* aborted signal as "this scope is being torn down": cancel the work and
|
||||
* drop its write-back. Pure contract — no IO, no store. Session-scoped.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import type { ScopeSeed } from '#/_base/di/scope';
|
||||
|
||||
export interface ISessionLifetime {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
export const ISessionLifetime: ServiceIdentifier<ISessionLifetime> =
|
||||
createDecorator<ISessionLifetime>('sessionLifetime');
|
||||
|
||||
export function sessionLifetimeSeed(lifetime: ISessionLifetime): ScopeSeed {
|
||||
return [[ISessionLifetime as ServiceIdentifier<unknown>, lifetime]];
|
||||
}
|
||||
|
|
@ -9,8 +9,11 @@
|
|||
* (the kap-server route), gated by a managed Kimi Code OAuth login; any
|
||||
* failure degrades to keeping the current title, and a custom title set by
|
||||
* the user is never overwritten. An already-generated title is not
|
||||
* regenerated unless forced, and the write-back is dropped when this scope
|
||||
* is no longer the live session in `workspaceHandler`. Provider config comes
|
||||
* regenerated unless forced, and the write-back is dropped once this
|
||||
* scope's `sessionLifetime` signal fires — the in-flight request carries
|
||||
* the signal, and a close drains the pending generation through the
|
||||
* `sessionLifecycleHooks` `onWillCloseSession` slot before the scope is
|
||||
* disposed. Provider config comes
|
||||
* from `provider`, the bearer token from `auth`, host identity headers from
|
||||
* `model`, prompt history from `agentLifecycle`/`sessionTitle`, and logs
|
||||
* through `log`. Bound at Session scope.
|
||||
|
|
@ -29,13 +32,18 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/
|
|||
import { ILogService } from '#/_base/log/log';
|
||||
import { IOAuthService } from '#/app/auth/auth';
|
||||
import { IEventService } from '#/app/event/event';
|
||||
import type { Hooks } from '#/hooks';
|
||||
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';
|
||||
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import { IProviderService } from '#/kosong/provider/provider';
|
||||
import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition';
|
||||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import {
|
||||
ISessionLifecycleHooks,
|
||||
type SessionLifecycleHookSlots,
|
||||
} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks';
|
||||
import { ISessionLifetime } from '#/session/sessionLifetime/sessionLifetime';
|
||||
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
|
||||
import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler';
|
||||
|
||||
import { IAgentTitlePromptSource } from './agentTitlePromptSource';
|
||||
import { ISessionTitleService } from './sessionTitle';
|
||||
|
|
@ -54,16 +62,27 @@ export class SessionTitleService implements ISessionTitleService {
|
|||
constructor(
|
||||
@ISessionContext private readonly ctx: ISessionContext,
|
||||
@ISessionMetadata private readonly metadata: ISessionMetadata,
|
||||
@ISessionLifetime private readonly lifetime: ISessionLifetime,
|
||||
@ISessionLifecycleHooks
|
||||
lifecycleHooks: Hooks<SessionLifecycleHookSlots>,
|
||||
@IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService,
|
||||
@IEventService private readonly eventService: IEventService,
|
||||
@IWorkspaceHandlerService private readonly workspaceHandler: IWorkspaceHandlerService,
|
||||
@IProviderService private readonly providers: IProviderService,
|
||||
@IOAuthService private readonly oauth: IOAuthService,
|
||||
@IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders,
|
||||
@ILogService private readonly log: ILogService,
|
||||
) {}
|
||||
) {
|
||||
// A close aborts the lifetime signal first, so the in-flight generation
|
||||
// settles fast; draining it here keeps its metadata write ordered before
|
||||
// the scope's disposal (and any later resume re-reading the document).
|
||||
lifecycleHooks.onWillCloseSession.register('sessionTitle', async (_event, next) => {
|
||||
await this._generation?.catch(() => undefined);
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
async generateTitle(options?: { readonly force?: boolean }): Promise<string | undefined> {
|
||||
if (this.lifetime.signal.aborted) return undefined;
|
||||
const current = await this.metadata.read();
|
||||
if (current.titleKind === 'custom') return undefined;
|
||||
if (current.titleKind === 'generated' && options?.force !== true) return undefined;
|
||||
|
|
@ -122,9 +141,10 @@ export class SessionTitleService implements ISessionTitleService {
|
|||
...this.hostHeaders.headers,
|
||||
...provider.customHeaders,
|
||||
},
|
||||
signal: this.lifetime.signal,
|
||||
});
|
||||
let result = await requestTitle(token);
|
||||
if (result.kind === 'error' && result.status === 401) {
|
||||
if (result.kind === 'error' && result.status === 401 && !this.lifetime.signal.aborted) {
|
||||
try {
|
||||
token = await tokenProvider.getAccessToken({ force: true });
|
||||
} catch (error) {
|
||||
|
|
@ -138,10 +158,7 @@ export class SessionTitleService implements ISessionTitleService {
|
|||
this.log.debug(`chat_title request failed: ${result.message}`);
|
||||
return undefined;
|
||||
}
|
||||
const live = this.workspaceHandler.get(this.ctx.sessionId);
|
||||
if (live === undefined || live.accessor.get(ISessionMetadata) !== this.metadata) {
|
||||
return undefined;
|
||||
}
|
||||
if (this.lifetime.signal.aborted) return undefined;
|
||||
const title = result.title.slice(0, MAX_GENERATED_TITLE_LENGTH);
|
||||
const applied = await this.metadata.setGeneratedTitleIfUncustomized(title);
|
||||
if (!applied) return undefined;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@
|
|||
* Owns the registry of THIS handler's open Session child scopes, creating
|
||||
* them through the DI scope tree (children of the handler's Workspace
|
||||
* scope) and seeding each with its identity, storage addressing derived
|
||||
* from the handler's `workspaceContext.persistenceScope`, and a per-session
|
||||
* `sessionLifecycleHooks` slots instance it runs around create/close,
|
||||
* from the handler's `workspaceContext.persistenceScope`, a per-session
|
||||
* `sessionLifecycleHooks` slots instance it runs around create/close, and
|
||||
* a per-session `sessionLifetime` abort signal it fires synchronously when
|
||||
* a close/archive begins — before any async close hook — so in-flight
|
||||
* session work can cancel itself and drop its write-back,
|
||||
* tearing sessions down on close/archive — archiving flags the session's
|
||||
* `sessionMetadata`, removes its `agentLifecycle` agents, restoring clears
|
||||
* the archived flag, and broadcasts through `event`; session start and
|
||||
|
|
@ -106,6 +109,7 @@ import {
|
|||
sessionLifecycleHooksSeed,
|
||||
type SessionLifecycleHookSlots,
|
||||
} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks';
|
||||
import { sessionLifetimeSeed } from '#/session/sessionLifetime/sessionLifetime';
|
||||
import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
|
||||
import { ISessionProcessRunner } from '#/session/process/processRunner';
|
||||
import { sessionSkillCatalogDataSeed } from '#/session/sessionSkillCatalog/skillCatalogData';
|
||||
|
|
@ -165,6 +169,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
private readonly _onDidForkSession = this._register(new Emitter<SessionForkedEvent>());
|
||||
readonly onDidForkSession: Event<SessionForkedEvent> = this._onDidForkSession.event;
|
||||
private readonly resuming = new Map<string, Promise<ISessionScopeHandle | undefined>>();
|
||||
private readonly sessionLifetimes = new Map<string, AbortController>();
|
||||
|
||||
constructor(
|
||||
@IInstantiationService private readonly instantiation: IInstantiationService,
|
||||
|
|
@ -230,6 +235,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
// Roll back ONLY the session directory — the handler stays live.
|
||||
const sessionDir = handle.accessor.get(ISessionContext).sessionDir;
|
||||
this.sessions.delete(sessionId);
|
||||
this.invalidateSessionLifetime(sessionId);
|
||||
await this.drainAgents(handle).catch(() => {});
|
||||
handle.dispose();
|
||||
await this.hostFs.remove(sessionDir).catch(() => {});
|
||||
|
|
@ -266,6 +272,8 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
'onDidCreateSession',
|
||||
'onWillCloseSession',
|
||||
]);
|
||||
const lifetime = new AbortController();
|
||||
this.sessionLifetimes.set(opts.sessionId, lifetime);
|
||||
await this.hostEnv.ready;
|
||||
const handle = createScopedChildHandle(
|
||||
this.instantiation,
|
||||
|
|
@ -275,6 +283,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
extra: [
|
||||
...sessionContextSeed(ctx),
|
||||
...sessionLifecycleHooksSeed(hooks),
|
||||
...sessionLifetimeSeed({ _serviceBrand: undefined, signal: lifetime.signal }),
|
||||
[ITelemetryService, this.telemetry.withContext({ sessionId: opts.sessionId })],
|
||||
// Workspace resource seeds (the §3.5 injection contracts): the
|
||||
// handler's shared skill / agent-profile catalogs, AGENTS.md
|
||||
|
|
@ -309,6 +318,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
]);
|
||||
await this.mcp.ready;
|
||||
} catch (error) {
|
||||
this.invalidateSessionLifetime(opts.sessionId);
|
||||
handle.dispose();
|
||||
// Re-arm the explicit agent-profile loader after a fatal rejection:
|
||||
// its `ready` tracks the latest load pass, so without a reload the
|
||||
|
|
@ -409,6 +419,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
async close(sessionId: string): Promise<void> {
|
||||
const handle = this.sessions.get(sessionId);
|
||||
if (handle === undefined) return;
|
||||
this.invalidateSessionLifetime(sessionId);
|
||||
await this.announceWillClose({ sessionId, handle, reason: 'exit' });
|
||||
this.sessions.delete(sessionId);
|
||||
await this.drainAgents(handle);
|
||||
|
|
@ -419,6 +430,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
async archive(sessionId: string): Promise<void> {
|
||||
const handle = this.sessions.get(sessionId);
|
||||
if (handle === undefined) return;
|
||||
this.invalidateSessionLifetime(sessionId);
|
||||
const meta = handle.accessor.get(ISessionMetadata);
|
||||
await meta.setArchived(true);
|
||||
await this.drainAgents(handle);
|
||||
|
|
@ -445,6 +457,18 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
.onWillCloseSession.run({ reason: event.reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously fires the session's liveness abort signal. Runs before any
|
||||
* async close work (hooks, agent drain) so in-flight session services see
|
||||
* the teardown immediately; idempotent — later calls are no-ops.
|
||||
*/
|
||||
private invalidateSessionLifetime(sessionId: string): void {
|
||||
const lifetime = this.sessionLifetimes.get(sessionId);
|
||||
if (lifetime === undefined) return;
|
||||
this.sessionLifetimes.delete(sessionId);
|
||||
lifetime.abort();
|
||||
}
|
||||
|
||||
private async drainAgents(handle: ISessionScopeHandle): Promise<void> {
|
||||
const agentLifecycle = handle.accessor.get(IAgentLifecycleService);
|
||||
for (const agent of agentLifecycle.list()) {
|
||||
|
|
@ -545,6 +569,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
} catch (error) {
|
||||
if (targetId !== undefined) {
|
||||
this.sessions.delete(targetId);
|
||||
this.invalidateSessionLifetime(targetId);
|
||||
}
|
||||
if (target !== undefined) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ import {
|
|||
ISessionLifecycleHooks,
|
||||
type SessionLifecycleHookSlots,
|
||||
} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks';
|
||||
import { ISessionLifetime } from '#/session/sessionLifetime/sessionLifetime';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { WireService } from '#/wire/wireService';
|
||||
|
|
@ -1177,6 +1178,10 @@ export class AgentTestContext {
|
|||
'onWillCloseSession',
|
||||
]),
|
||||
);
|
||||
reg.defineInstance(ISessionLifetime, {
|
||||
_serviceBrand: undefined,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
reg.defineInstance(ISessionInteractionService, this.createInteractionService());
|
||||
reg.defineInstance(ISessionApprovalService, this.createApprovalService());
|
||||
reg.defineInstance(ISessionQuestionService, this.createQuestionService());
|
||||
|
|
|
|||
|
|
@ -12,17 +12,15 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite
|
|||
import { OAuthConnectionError, OAuthUnauthorizedError } from '@moonshot-ai/kimi-code-oauth';
|
||||
|
||||
import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle';
|
||||
import type { ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import {
|
||||
LifecycleScope,
|
||||
type IAgentScopeHandle,
|
||||
type ISessionScopeHandle,
|
||||
} from '#/_base/di/scope';
|
||||
import { createServices, type TestInstantiationService } from '#/_base/di/test';
|
||||
import { Emitter } from '#/_base/event';
|
||||
import { IOAuthService } from '#/app/auth/auth';
|
||||
import { type DomainEvent, IEventService } from '#/app/event/event';
|
||||
import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler';
|
||||
import { createHooks, type Hooks } from '#/hooks';
|
||||
import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import {
|
||||
IProviderService,
|
||||
|
|
@ -34,6 +32,11 @@ import {
|
|||
IAgentLifecycleService,
|
||||
MAIN_AGENT_ID,
|
||||
} from '#/session/agentLifecycle/agentLifecycle';
|
||||
import {
|
||||
ISessionLifecycleHooks,
|
||||
type SessionLifecycleHookSlots,
|
||||
} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks';
|
||||
import { ISessionLifetime } from '#/session/sessionLifetime/sessionLifetime';
|
||||
import {
|
||||
IAgentTitlePromptSource,
|
||||
} from '#/session/sessionTitle/agentTitlePromptSource';
|
||||
|
|
@ -137,20 +140,6 @@ function createPendingFetch() {
|
|||
};
|
||||
}
|
||||
|
||||
function makeLiveSessionHandle(meta: ISessionMetadata): ISessionScopeHandle {
|
||||
return {
|
||||
id: SESSION_ID,
|
||||
kind: LifecycleScope.Session,
|
||||
accessor: {
|
||||
get: <T>(id: ServiceIdentifier<T>) => {
|
||||
if (id === ISessionMetadata) return meta as T;
|
||||
throw new Error(`unexpected service ${String(id)}`);
|
||||
},
|
||||
},
|
||||
dispose: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SessionTitleService', () => {
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
|
|
@ -162,7 +151,8 @@ describe('SessionTitleService', () => {
|
|||
let forceTokenError: Error | undefined;
|
||||
let resolvedOAuthRefs: Array<OAuthRef | undefined>;
|
||||
let titlePrompts: readonly string[];
|
||||
let liveSession: ISessionScopeHandle | undefined;
|
||||
let lifetimeController: AbortController;
|
||||
let lifecycleHooks: Hooks<SessionLifecycleHookSlots>;
|
||||
let tokenCalls: boolean[];
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -173,7 +163,11 @@ describe('SessionTitleService', () => {
|
|||
tokenCalls = [];
|
||||
providers = { 'managed:kimi-code': MANAGED_PROVIDER };
|
||||
metadata = new FakeSessionMetadata();
|
||||
liveSession = makeLiveSessionHandle(metadata);
|
||||
lifetimeController = new AbortController();
|
||||
lifecycleHooks = createHooks<SessionLifecycleHookSlots, keyof SessionLifecycleHookSlots>([
|
||||
'onDidCreateSession',
|
||||
'onWillCloseSession',
|
||||
]);
|
||||
events = new FakeEventService();
|
||||
fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(
|
||||
async () =>
|
||||
|
|
@ -213,9 +207,11 @@ describe('SessionTitleService', () => {
|
|||
get: () => mainAgent,
|
||||
});
|
||||
reg.defineInstance(IEventService, events);
|
||||
reg.definePartialInstance(IWorkspaceHandlerService, {
|
||||
get: () => liveSession,
|
||||
reg.defineInstance(ISessionLifetime, {
|
||||
_serviceBrand: undefined,
|
||||
signal: lifetimeController.signal,
|
||||
});
|
||||
reg.defineInstance(ISessionLifecycleHooks, lifecycleHooks);
|
||||
reg.defineInstance(IProviderService, stubProviderService(providers));
|
||||
reg.definePartialInstance(IOAuthService, {
|
||||
resolveTokenProvider: (_provider, oauthRef) => {
|
||||
|
|
@ -362,27 +358,85 @@ describe('SessionTitleService', () => {
|
|||
expect(metadata.meta.title).toBe('user 取的标题');
|
||||
});
|
||||
|
||||
it('drops the write-back when the session scope was superseded mid-flight', async () => {
|
||||
it('drops the write-back when the lifetime signal aborts after the response arrived', async () => {
|
||||
const pendingFetch = createPendingFetch();
|
||||
fetchMock.mockImplementationOnce(pendingFetch.fetch);
|
||||
const applySpy = vi.spyOn(metadata, 'setGeneratedTitleIfUncustomized');
|
||||
titlePrompts = ['hello'];
|
||||
liveSession = makeLiveSessionHandle(new FakeSessionMetadata());
|
||||
|
||||
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const generation = ix.get(ISessionTitleService).generateTitle();
|
||||
await pendingFetch.started;
|
||||
lifetimeController.abort();
|
||||
pendingFetch.resolve(
|
||||
new Response(JSON.stringify({ title: '生成的标题' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(generation).resolves.toBeUndefined();
|
||||
expect(applySpy).not.toHaveBeenCalled();
|
||||
expect(metadata.meta.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops the write-back while the session is still resuming', async () => {
|
||||
it('cancels the in-flight request when the lifetime signal aborts', async () => {
|
||||
fetchMock.mockImplementationOnce(
|
||||
async (_url, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
});
|
||||
}),
|
||||
);
|
||||
const applySpy = vi.spyOn(metadata, 'setGeneratedTitleIfUncustomized');
|
||||
titlePrompts = ['hello'];
|
||||
liveSession = undefined;
|
||||
|
||||
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
|
||||
const generation = ix.get(ISessionTitleService).generateTitle();
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
lifetimeController.abort();
|
||||
|
||||
await expect(generation).resolves.toBeUndefined();
|
||||
expect(applySpy).not.toHaveBeenCalled();
|
||||
expect(metadata.meta.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns unavailable without fetching when the scope is already closing', async () => {
|
||||
lifetimeController.abort();
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains an in-flight generation through onWillCloseSession', async () => {
|
||||
const pendingFetch = createPendingFetch();
|
||||
fetchMock.mockImplementationOnce(pendingFetch.fetch);
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
const generation = ix.get(ISessionTitleService).generateTitle();
|
||||
await pendingFetch.started;
|
||||
|
||||
let drained = false;
|
||||
const closeRun = lifecycleHooks.onWillCloseSession.run({ reason: 'exit' }).then(() => {
|
||||
drained = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(drained).toBe(false);
|
||||
|
||||
pendingFetch.resolve(
|
||||
new Response(JSON.stringify({ title: '生成的标题' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
await closeRun;
|
||||
expect(drained).toBe(true);
|
||||
await expect(generation).resolves.toBe('生成的标题');
|
||||
expect(metadata.meta.title).toBe('生成的标题');
|
||||
});
|
||||
|
||||
it('keeps the current title when the backend request fails', async () => {
|
||||
fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 }));
|
||||
titlePrompts = ['hello'];
|
||||
|
|
|
|||
|
|
@ -34,9 +34,14 @@ export async function fetchChatTitle(
|
|||
url: string,
|
||||
accessToken: string,
|
||||
chatContent: string,
|
||||
opts: { timeoutMs?: number; headers?: Record<string, string> } = {},
|
||||
opts: { timeoutMs?: number; headers?: Record<string, string>; signal?: AbortSignal } = {},
|
||||
): Promise<FetchChatTitleResult> {
|
||||
const controller = new AbortController();
|
||||
if (opts.signal !== undefined) {
|
||||
const external = opts.signal;
|
||||
if (external.aborted) controller.abort();
|
||||
else external.addEventListener('abort', () => { controller.abort(); }, { once: true });
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, opts.timeoutMs ?? 8000);
|
||||
|
|
@ -71,7 +76,9 @@ export async function fetchChatTitle(
|
|||
return { kind: 'ok', title };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return { kind: 'error', message: 'Failed to generate session title: request timed out.' };
|
||||
const reason =
|
||||
opts.signal?.aborted === true ? 'request aborted.' : 'request timed out.';
|
||||
return { kind: 'error', message: `Failed to generate session title: ${reason}` };
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return { kind: 'error', message: `Failed to generate session title: ${msg}` };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue