mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-22 07:05:41 +00:00
refactor(agent-core-v2): drop session close-awareness from title generation
Auto title is best-effort: a generation racing session close no longer cancels its fetch or guards its write-back, so the per-session sessionLifetime AbortSignal seed, the onWillCloseSession drain, and the close-time invalidation go away. The newest-request-wins write-back predicate stays.
This commit is contained in:
parent
7292ccc018
commit
1a23797226
7 changed files with 7 additions and 214 deletions
|
|
@ -91,11 +91,6 @@ 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,7 +366,6 @@ 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';
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
/**
|
||||
* `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]];
|
||||
}
|
||||
|
|
@ -11,11 +11,7 @@
|
|||
* the user is never overwritten. An already-generated title is not
|
||||
* regenerated unless forced. Plain calls coalesce onto one shared
|
||||
* in-flight generation while a forced regeneration always runs on its
|
||||
* own; the newest request always wins the write-back, which the
|
||||
* serialized metadata update drops once the scope's `sessionLifetime`
|
||||
* signal fires or a newer request supersedes it. Every active generation
|
||||
* is drained through the `sessionLifecycleHooks` `onWillCloseSession`
|
||||
* slot before the scope is disposed.
|
||||
* own; the newest request always wins the write-back.
|
||||
* Provider config comes
|
||||
* from `provider`, the bearer token from `auth`, host identity headers from
|
||||
* `model`, prompt history from `agentLifecycle`/`sessionTitle`, and logs
|
||||
|
|
@ -35,17 +31,11 @@ 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 { IAgentTitlePromptSource } from './agentTitlePromptSource';
|
||||
|
|
@ -61,36 +51,24 @@ export class SessionTitleService implements ISessionTitleService {
|
|||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private _shared: Promise<string | undefined> | undefined;
|
||||
private readonly _active = new Set<Promise<string | undefined>>();
|
||||
private _generationSeq = 0;
|
||||
|
||||
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,
|
||||
@IProviderService private readonly providers: IProviderService,
|
||||
@IOAuthService private readonly oauth: IOAuthService,
|
||||
@IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders,
|
||||
@ILogService private readonly log: ILogService,
|
||||
) {
|
||||
lifecycleHooks.onWillCloseSession.register('sessionTitle', async (_event, next) => {
|
||||
await Promise.allSettled([...this._active]);
|
||||
await next();
|
||||
});
|
||||
}
|
||||
) {}
|
||||
|
||||
async generateTitle(options?: { readonly force?: boolean }): Promise<string | undefined> {
|
||||
if (this.lifetime.signal.aborted) return undefined;
|
||||
if (options?.force !== true && this._shared !== undefined) return this._shared;
|
||||
const tracked = this.generateTitleOnce(options).finally(() => {
|
||||
this._active.delete(tracked);
|
||||
if (this._shared === tracked) this._shared = undefined;
|
||||
});
|
||||
this._active.add(tracked);
|
||||
if (options?.force !== true) this._shared = tracked;
|
||||
return tracked;
|
||||
}
|
||||
|
|
@ -147,10 +125,9 @@ 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 && !this.lifetime.signal.aborted) {
|
||||
if (result.kind === 'error' && result.status === 401) {
|
||||
try {
|
||||
token = await tokenProvider.getAccessToken({ force: true });
|
||||
} catch (error) {
|
||||
|
|
@ -164,11 +141,10 @@ export class SessionTitleService implements ISessionTitleService {
|
|||
this.log.debug(`chat_title request failed: ${result.message}`);
|
||||
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,
|
||||
() => seq === this._generationSeq && !this.lifetime.signal.aborted,
|
||||
() => seq === this._generationSeq,
|
||||
);
|
||||
if (!applied) return undefined;
|
||||
this.eventService.publish({
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@
|
|||
* 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`, 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. A close/archive
|
||||
* from the handler's `workspaceContext.persistenceScope`, and a
|
||||
* per-session `sessionLifecycleHooks` slots instance it runs around
|
||||
* create/close. A close/archive
|
||||
* is tracked in a closing registry from that first synchronous step until
|
||||
* the scope is disposed: `get`/`list` hide the session while it is
|
||||
* closing, `resume` waits the close out and re-materializes instead of
|
||||
|
|
@ -122,7 +120,6 @@ 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';
|
||||
|
|
@ -188,7 +185,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
readonly onDidForkSession: Event<SessionForkedEvent> = this._onDidForkSession.event;
|
||||
private readonly resuming = new Map<string, Promise<ISessionScopeHandle | undefined>>();
|
||||
private readonly closing = new Map<string, ClosingEntry>();
|
||||
private readonly sessionLifetimes = new Map<string, AbortController>();
|
||||
private readonly reservedTargets = new Set<string>();
|
||||
private readonly pendingArchive = new Set<string>();
|
||||
|
||||
|
|
@ -275,7 +271,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
const sessionDir = handle.accessor.get(ISessionContext).sessionDir;
|
||||
if (this.sessions.get(sessionId) === handle) {
|
||||
this.sessions.delete(sessionId);
|
||||
this.invalidateSessionLifetime(sessionId);
|
||||
}
|
||||
await this.drainAgents(handle).catch(() => {});
|
||||
handle.dispose();
|
||||
|
|
@ -313,8 +308,6 @@ 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,
|
||||
|
|
@ -324,7 +317,6 @@ 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
|
||||
|
|
@ -359,7 +351,6 @@ 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
|
||||
|
|
@ -473,7 +464,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
}
|
||||
|
||||
private async doClose(sessionId: string, handle: ISessionScopeHandle): Promise<void> {
|
||||
this.invalidateSessionLifetime(sessionId);
|
||||
try {
|
||||
await this.announceWillClose({ sessionId, handle, reason: 'exit' });
|
||||
} finally {
|
||||
|
|
@ -525,7 +515,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
}
|
||||
|
||||
private async doArchive(sessionId: string, handle: ISessionScopeHandle): Promise<void> {
|
||||
this.invalidateSessionLifetime(sessionId);
|
||||
const meta = handle.accessor.get(ISessionMetadata);
|
||||
await meta.setArchived(true);
|
||||
this.event.publish({
|
||||
|
|
@ -580,13 +569,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
.onWillCloseSession.run({ reason: event.reason });
|
||||
}
|
||||
|
||||
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()) {
|
||||
|
|
@ -701,7 +683,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
|
|||
this.sessions.get(targetId) === target
|
||||
) {
|
||||
this.sessions.delete(targetId);
|
||||
this.invalidateSessionLifetime(targetId);
|
||||
}
|
||||
if (target !== undefined) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -153,7 +153,6 @@ 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';
|
||||
|
|
@ -1178,10 +1177,6 @@ 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());
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ 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 { createHooks, type Hooks } from '#/hooks';
|
||||
import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import {
|
||||
IProviderService,
|
||||
|
|
@ -32,11 +31,6 @@ 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';
|
||||
|
|
@ -153,8 +147,6 @@ describe('SessionTitleService', () => {
|
|||
let resolvedOAuthRefs: Array<OAuthRef | undefined>;
|
||||
let titlePrompts: readonly string[];
|
||||
let promptSourceImpl: (limit: number) => Promise<readonly string[]>;
|
||||
let lifetimeController: AbortController;
|
||||
let lifecycleHooks: Hooks<SessionLifecycleHookSlots>;
|
||||
let tokenCalls: boolean[];
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -166,11 +158,6 @@ describe('SessionTitleService', () => {
|
|||
tokenCalls = [];
|
||||
providers = { 'managed:kimi-code': MANAGED_PROVIDER };
|
||||
metadata = new FakeSessionMetadata();
|
||||
lifetimeController = new AbortController();
|
||||
lifecycleHooks = createHooks<SessionLifecycleHookSlots, keyof SessionLifecycleHookSlots>([
|
||||
'onDidCreateSession',
|
||||
'onWillCloseSession',
|
||||
]);
|
||||
events = new FakeEventService();
|
||||
fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(
|
||||
async () =>
|
||||
|
|
@ -210,11 +197,6 @@ describe('SessionTitleService', () => {
|
|||
get: () => mainAgent,
|
||||
});
|
||||
reg.defineInstance(IEventService, events);
|
||||
reg.defineInstance(ISessionLifetime, {
|
||||
_serviceBrand: undefined,
|
||||
signal: lifetimeController.signal,
|
||||
});
|
||||
reg.defineInstance(ISessionLifecycleHooks, lifecycleHooks);
|
||||
reg.defineInstance(IProviderService, stubProviderService(providers));
|
||||
reg.definePartialInstance(IOAuthService, {
|
||||
resolveTokenProvider: (_provider, oauthRef) => {
|
||||
|
|
@ -361,113 +343,6 @@ describe('SessionTitleService', () => {
|
|||
expect(metadata.meta.title).toBe('user 取的标题');
|
||||
});
|
||||
|
||||
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'];
|
||||
|
||||
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('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'];
|
||||
|
||||
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 reachedTerminal = false;
|
||||
const closeRun = lifecycleHooks.onWillCloseSession
|
||||
.run({ reason: 'exit' }, async () => {
|
||||
reachedTerminal = true;
|
||||
})
|
||||
.then(() => reachedTerminal);
|
||||
// The hook chain must be parked on the pending generation, microtask-
|
||||
// deterministically: the terminal callback cannot run while the fetch
|
||||
// is unresolved.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(reachedTerminal).toBe(false);
|
||||
|
||||
pendingFetch.resolve(
|
||||
new Response(JSON.stringify({ title: '生成的标题' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
await expect(closeRun).resolves.toBe(true);
|
||||
await expect(generation).resolves.toBe('生成的标题');
|
||||
expect(metadata.meta.title).toBe('生成的标题');
|
||||
});
|
||||
|
||||
it('drains a generation that is still collecting prompts through onWillCloseSession', async () => {
|
||||
let resolvePrompts!: (prompts: readonly string[]) => void;
|
||||
promptSourceImpl = async (limit) =>
|
||||
new Promise<readonly string[]>((resolve) => {
|
||||
resolvePrompts = (prompts) => resolve(prompts.slice(0, limit));
|
||||
});
|
||||
|
||||
const generation = ix.get(ISessionTitleService).generateTitle();
|
||||
let reachedTerminal = false;
|
||||
const closeRun = lifecycleHooks.onWillCloseSession
|
||||
.run({ reason: 'exit' }, async () => {
|
||||
reachedTerminal = true;
|
||||
})
|
||||
.then(() => reachedTerminal);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(reachedTerminal).toBe(false);
|
||||
|
||||
resolvePrompts(['hello']);
|
||||
await expect(closeRun).resolves.toBe(true);
|
||||
await expect(generation).resolves.toBe('生成的标题');
|
||||
});
|
||||
|
||||
it('keeps the current title when the backend request fails', async () => {
|
||||
fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 }));
|
||||
titlePrompts = ['hello'];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue