feat(chat): handle credit-gated send responses

This commit is contained in:
2026-06-30 14:17:14 +08:00
parent f4d0f76466
commit 958a0f8ffd
12 changed files with 303 additions and 17 deletions
@@ -38,6 +38,12 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
pendingReplyCount: 1,
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
cannotSendReason: null,
creditBalance: 0,
creditsCharged: 0,
requiredCredits: 0,
shortfallCredits: 0,
isLoadingMore: false,
hasMore: true,
historyOffset: 0,
@@ -168,6 +174,96 @@ describe("applyHttpSendOutput", () => {
expect(nextState.upgradePromptVisible).toBe(true);
expect(nextState.upgradeReason).toBe("weekly_limit");
});
it("stores insufficient credit state and removes failed optimistic messages", () => {
const context = makeChatState({
messages: [
{
content: "hello",
isFromAI: false,
date: "2026-06-25",
},
],
});
const nextState = applyHttpSendOutput(context, {
response: makeResponse({
reply: "",
messageId: "",
canSendMessage: false,
cannotSendReason: "insufficient_credits",
creditBalance: 0,
creditsCharged: 0,
requiredCredits: 2,
shortfallCredits: 2,
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "insufficient_credits",
hint: "Insufficient credits.",
detail: {
requiredCredits: 2,
currentCredits: 0,
shortfallCredits: 2,
},
},
}),
reply: null,
});
expect(nextState.messages).toEqual([]);
expect(nextState.isReplyingAI).toBe(false);
expect(nextState.upgradePromptVisible).toBe(true);
expect(nextState.upgradeReason).toBe("insufficient_credits");
expect(nextState.canSendMessage).toBe(false);
expect(nextState.cannotSendReason).toBe("insufficient_credits");
expect(nextState.requiredCredits).toBe(2);
expect(nextState.shortfallCredits).toBe(2);
});
it("keeps a valid reply while marking future sends as unavailable", () => {
const context = makeChatState({
messages: [
{
content: "hello",
isFromAI: false,
date: "2026-06-25",
},
],
});
const reply = sendResponseToUiMessage(
makeResponse({
reply: "This one went through, but you need credits next.",
canSendMessage: false,
cannotSendReason: "insufficient_credits",
creditBalance: 0,
requiredCredits: 2,
shortfallCredits: 2,
}),
);
const nextState = applyHttpSendOutput(context, {
response: makeResponse({
reply: "This one went through, but you need credits next.",
canSendMessage: false,
cannotSendReason: "insufficient_credits",
creditBalance: 0,
requiredCredits: 2,
shortfallCredits: 2,
}),
reply,
});
expect(nextState.messages).toHaveLength(2);
expect(nextState.messages?.[1]).toMatchObject({
content: "This one went through, but you need credits next.",
isFromAI: true,
});
expect(nextState.upgradePromptVisible).toBe(true);
expect(nextState.upgradeReason).toBe("insufficient_credits");
expect(nextState.canSendMessage).toBe(false);
});
});
describe("localMessagesToUi", () => {
@@ -467,6 +467,11 @@ describe("chatMachine transitions", () => {
limit: 3,
},
},
canSendMessage: false,
cannotSendReason: "insufficient_credits",
creditBalance: 0,
requiredCredits: 2,
shortfallCredits: 2,
}),
reply: null,
},
@@ -474,11 +479,14 @@ describe("chatMachine transitions", () => {
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(true);
expect(actor.getSnapshot().context.upgradeReason).toBe("weekly_limit");
expect(actor.getSnapshot().context.canSendMessage).toBe(false);
actor.send({ type: "ChatPaymentSucceeded" });
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(false);
expect(actor.getSnapshot().context.upgradeReason).toBeNull();
expect(actor.getSnapshot().context.canSendMessage).toBe(true);
expect(actor.getSnapshot().context.cannotSendReason).toBeNull();
actor.stop();
});