Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/stores/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,23 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
}

// Guard: during an active send, don't let a history poll replace the local
// conversation with fewer messages from the gateway. This can happen when
// the gateway is reconnecting after a brief disconnect or hasn't fully
// persisted the conversation yet. Without this guard, the history poll
// causes chat history to vanish mid-conversation (issue #709).
{
const preApplyState = get();
if (
preApplyState.sending &&
preApplyState.lastUserMessageAt &&
finalMessages.length < preApplyState.messages.length &&
preApplyState.messages.length > 1
) {
finalMessages = preApplyState.messages;
Comment on lines +1399 to +1402

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exempt truncated history polls from the send-time length guard

loadHistory always requests chat.history with limit: 200, but this new guard drops any polled result whose finalMessages.length is smaller than the current local list during sending. In long threads, sending an optimistic user turn can make local state 201 messages while the gateway response is capped at 200, so every poll is discarded even when it contains the new assistant output. In the no-streaming fallback path this prevents completion detection and can leave the run stuck until timeout instead of converging to gateway history.

Useful? React with 👍 / 👎.

}
}

set({ messages: finalMessages, thinkingLevel, loading: false });

// Extract first user message text as a session label for display in the toolbar.
Expand Down
2 changes: 1 addition & 1 deletion src/stores/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
// Merge with ClawHub results
if (clawhubResult.success && clawhubResult.results) {
clawhubResult.results.forEach((cs: ClawHubListResult) => {
const existing = combinedSkills.find(s => s.id === cs.slug);
const existing = combinedSkills.find(s => s.id === cs.slug || s.slug === cs.slug);
if (existing) {
if (!existing.baseDir && cs.baseDir) {
existing.baseDir = cs.baseDir;
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/skills-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,34 @@ vi.mock('@/stores/gateway', () => ({
},
}));

describe('skills store slug matching', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});

it('matches ClawHub skill to gateway skill when gateway slug differs from skillKey', async () => {
// Gateway returns skillKey "foo-v2" but slug "foo"
rpcMock.mockResolvedValueOnce({
skills: [{ skillKey: 'foo-v2', slug: 'foo', name: 'Foo Skill', description: 'A skill', disabled: false }],
});
// ClawHub lists "foo" as installed (matching by slug, not skillKey)
hostApiFetchMock
.mockResolvedValueOnce({ success: true, results: [{ slug: 'foo', version: '1.0.0' }] })
.mockResolvedValueOnce({});

const { useSkillsStore } = await import('@/stores/skills');
await useSkillsStore.getState().fetchSkills();

const skills = useSkillsStore.getState().skills;
// Should be exactly one skill, not two (no placeholder duplicate)
expect(skills).toHaveLength(1);
// The skill should be the gateway skill (not the "Recently installed" placeholder)
expect(skills[0].name).toBe('Foo Skill');
expect(skills[0].description).not.toBe('Recently installed, initializing...');
});
});

describe('skills store error mapping', () => {
beforeEach(() => {
vi.resetModules();
Expand Down
Loading