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
6 changes: 6 additions & 0 deletions src/main/core/agent-hooks/agent-hook-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { telemetryService } from '@main/lib/telemetry';
import { agentEventChannel, type AgentEvent } from '@shared/events/agentEvents';
import { conversationChangedChannel } from '@shared/events/conversationEvents';
import { enrichEvent } from './event-enricher';
import { handleProviderSessionHook } from './handle-provider-session-hook';
import { HookServer } from './hook-server';
import { isAppFocused, maybeShowNotification } from './notification';

Expand All @@ -14,6 +15,11 @@ class AgentHookService implements IInitializable, IDisposable {

async initialize(): Promise<void> {
await this.server.start(async (raw) => {
if (raw.type === 'session') {
await handleProviderSessionHook(raw);
return;
}

const event = await enrichEvent(raw);
event.source = 'hook';
const appFocused = isAppFocused();
Expand Down
3 changes: 3 additions & 0 deletions src/main/core/agent-hooks/agent-notify-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ describe('makeOpenCodePluginContent', () => {
const content = makeOpenCodePluginContent();

expect(content).toContain('EMDASH_HOOK_PORT');
expect(content).toContain("event.type === 'session.created'");
expect(content).toContain('event.properties?.info?.parentID');
expect(content).toContain('event.properties?.info?.id');
expect(content).toContain("event.type === 'session.idle'");
expect(content).toContain("event.type === 'session.error'");
expect(content).toContain("'X-Emdash-Event-Type': payload.type");
Expand Down
27 changes: 27 additions & 0 deletions src/main/core/agent-hooks/handle-provider-session-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { saveProviderSessionId } from '@main/core/conversations/save-provider-session-id';
import { log } from '@main/lib/logger';
import { parsePtyId } from '@shared/ptyId';
import type { RawHookRequest } from './hook-server';

export async function handleProviderSessionHook(raw: RawHookRequest): Promise<void> {
const parsed = parsePtyId(raw.ptyId);
if (!parsed || parsed.providerId !== 'opencode') return;

let body: Record<string, unknown> = {};
if (raw.body) {
try {
const value: unknown = JSON.parse(raw.body);
if (typeof value === 'object' && value !== null) {
body = value as Record<string, unknown>;
}
} catch {
log.warn('handleProviderSessionHook: invalid JSON body', { ptyId: raw.ptyId });
return;
}
}

const providerSessionId = body.provider_session_id ?? body.providerSessionId;
if (typeof providerSessionId !== 'string' || providerSessionId.length === 0) return;

await saveProviderSessionId(parsed.conversationId, providerSessionId);
}
12 changes: 12 additions & 0 deletions src/main/core/agent-hooks/opencode-notifications-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ export const EmdashNotifications = async () => ({
});

function toEmdashPayload(event) {
if (event.type === 'session.created') {
if (event.properties?.info?.parentID) return;
const sessionID = event.properties?.info?.id;
if (typeof sessionID !== 'string' || sessionID.length === 0) return;
return {
type: 'session',
body: {
provider_session_id: sessionID,
},
};
}

if (event.type === 'session.idle') {
return {
type: 'notification',
Expand Down
3 changes: 2 additions & 1 deletion src/main/core/conversations/createConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { db } from '@main/db/client';
import { conversations } from '@main/db/schema';
import { log } from '@main/lib/logger';
import { telemetryService } from '@main/lib/telemetry';
import { serializeConversationConfig } from '@shared/conversation-config';
import { type Conversation, type CreateConversationParams } from '@shared/conversations';
import { resolveTask } from '../projects/utils';
import { conversationEvents } from './conversation-events';
Expand All @@ -21,7 +22,7 @@ export async function createConversation(params: CreateConversationParams): Prom
const config =
params.autoApprove === undefined
? undefined
: JSON.stringify({ autoApprove: params.autoApprove });
: serializeConversationConfig({ autoApprove: params.autoApprove });

const [row] = await db
.insert(conversations)
Expand Down
2 changes: 1 addition & 1 deletion src/main/core/conversations/impl/agent-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ describe('buildAgentCommand', () => {
{
providerId: 'opencode',
freshArgs: ['--prompt', 'Fix the bug'],
resumeArgs: ['--continue'],
resumeArgs: ['--session', 'conv-1'],
},
{ providerId: 'grok', freshArgs: [], resumeArgs: ['-r'] },
{ providerId: 'copilot', freshArgs: ['Fix the bug'], resumeArgs: ['--resume'] },
Expand Down
6 changes: 4 additions & 2 deletions src/main/core/conversations/impl/local-conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { Conversation } from '@shared/conversations';
import { agentSessionExitedChannel } from '@shared/events/agentEvents';
import { makePtyId } from '@shared/ptyId';
import { makePtySessionId } from '@shared/ptySessionId';
import { resolveAgentSessionCommandArgs } from '../resolve-agent-session-command';
import { buildAgentSessionCommand } from './agent-command';
import { scheduleInitialPromptInjection } from './keystroke-injection';
import { resolveProviderEnv } from './provider-env';
Expand Down Expand Up @@ -97,12 +98,13 @@ export class LocalConversationProvider implements ConversationProvider {

const providerConfig = await providerOverrideSettings.getItem(conversation.providerId);
const providerDef = getProvider(conversation.providerId);
const agentSession = resolveAgentSessionCommandArgs(conversation, isResuming);
const { command, args } = buildAgentSessionCommand({
providerId: conversation.providerId,
providerConfig,
autoApprove: conversation.autoApprove,
sessionId: conversation.id,
isResuming,
sessionId: agentSession.sessionId,
isResuming: agentSession.isResuming,
initialPrompt,
});
const providerEnv = resolveProviderEnv(providerConfig, {
Expand Down
8 changes: 5 additions & 3 deletions src/main/core/conversations/impl/ssh-conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { AgentSessionConfig } from '@shared/agent-session';
import type { Conversation } from '@shared/conversations';
import { agentSessionExitedChannel } from '@shared/events/agentEvents';
import { makePtySessionId } from '@shared/ptySessionId';
import { resolveAgentSessionCommandArgs } from '../resolve-agent-session-command';
import { buildAgentSessionCommand } from './agent-command';
import { scheduleInitialPromptInjection } from './keystroke-injection';
import { resolveProviderEnv } from './provider-env';
Expand Down Expand Up @@ -90,12 +91,13 @@ export class SshConversationProvider implements ConversationProvider {
});

const providerConfig = await providerOverrideSettings.getItem(conversation.providerId);
const agentSession = resolveAgentSessionCommandArgs(conversation, isResuming);
const { command, args } = buildAgentSessionCommand({
providerId: conversation.providerId,
providerConfig,
autoApprove: conversation.autoApprove,
sessionId: conversation.id,
isResuming,
sessionId: agentSession.sessionId,
isResuming: agentSession.isResuming,
initialPrompt,
});
const providerEnv = resolveProviderEnv(providerConfig, {
Expand All @@ -115,7 +117,7 @@ export class SshConversationProvider implements ConversationProvider {
shellSetup: this.shellSetup,
tmuxSessionName,
autoApprove: conversation.autoApprove ?? false,
resume: isResuming,
resume: agentSession.isResuming,
};

const profile = await this.proxy.getRemoteShellProfile();
Expand Down
40 changes: 40 additions & 0 deletions src/main/core/conversations/resolve-agent-session-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import type { Conversation } from '@shared/conversations';
import { resolveAgentSessionCommandArgs } from './resolve-agent-session-command';

function makeConversation(overrides: Partial<Conversation> = {}): Conversation {
return {
id: 'conv-1',
projectId: 'proj-1',
taskId: 'task-1',
providerId: 'opencode',
title: 'Test',
lastInteractedAt: null,
isInitialConversation: false,
...overrides,
};
}

describe('resolveAgentSessionCommandArgs', () => {
it('uses stored OpenCode session id when resuming', () => {
expect(
resolveAgentSessionCommandArgs(makeConversation({ providerSessionId: 'ses_abc123' }), true)
).toEqual({ sessionId: 'ses_abc123', isResuming: true });
});

it('starts fresh when resuming OpenCode without a stored session id', () => {
expect(resolveAgentSessionCommandArgs(makeConversation(), true)).toEqual({
sessionId: 'conv-1',
isResuming: false,
});
});

it('passes through for non-OpenCode providers', () => {
expect(
resolveAgentSessionCommandArgs(
makeConversation({ providerId: 'claude', providerSessionId: 'ses_ignored' }),
true
)
).toEqual({ sessionId: 'conv-1', isResuming: true });
});
});
22 changes: 22 additions & 0 deletions src/main/core/conversations/resolve-agent-session-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { log } from '@main/lib/logger';
import type { Conversation } from '@shared/conversations';

/** OpenCode `--continue` resumes the last global session, not per Emdash chat. */
export function resolveAgentSessionCommandArgs(
conversation: Conversation,
isResuming: boolean
): { sessionId: string; isResuming: boolean } {
if (conversation.providerId === 'opencode' && isResuming) {
if (conversation.providerSessionId) {
return { sessionId: conversation.providerSessionId, isResuming: true };
}
log.warn('resolveAgentSessionCommandArgs: OpenCode resume skipped — no providerSessionId', {
conversationId: conversation.id,
taskId: conversation.taskId,
projectId: conversation.projectId,
});
return { sessionId: conversation.id, isResuming: false };
Comment on lines +12 to +18
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.

P2 Silent fresh-start fallback has no observability

When an OpenCode conversation is resumed but providerSessionId is not yet stored (e.g. the session.created webhook was never received), the function quietly downgrades to a fresh session. A user who clicks "Resume" would see a brand-new chat with no indication that resumption was skipped. Adding a log.warn here (similar to the warning in saveProviderSessionId) would make this case visible in logs without changing the user-facing behaviour.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/main/core/conversations/resolve-agent-session-command.ts
Line: 11-12

Comment:
**Silent fresh-start fallback has no observability**

When an OpenCode conversation is resumed but `providerSessionId` is not yet stored (e.g. the `session.created` webhook was never received), the function quietly downgrades to a fresh session. A user who clicks "Resume" would see a brand-new chat with no indication that resumption was skipped. Adding a `log.warn` here (similar to the warning in `saveProviderSessionId`) would make this case visible in logs without changing the user-facing behaviour.

How can I resolve this? If you propose a fix, please make it concise.

}

return { sessionId: conversation.id, isResuming };
}
56 changes: 56 additions & 0 deletions src/main/core/conversations/save-provider-session-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { eq } from 'drizzle-orm';
import { db } from '@main/db/client';
import { conversations } from '@main/db/schema';
import { events } from '@main/lib/events';
import { log } from '@main/lib/logger';
import {
isOpenCodeProviderSessionId,
parseConversationConfig,
serializeConversationConfig,
} from '@shared/conversation-config';
import { conversationChangedChannel } from '@shared/events/conversationEvents';

export async function saveProviderSessionId(
conversationId: string,
providerSessionId: string
): Promise<void> {
if (!isOpenCodeProviderSessionId(providerSessionId)) {
log.warn('saveProviderSessionId: ignored invalid OpenCode session id', {
conversationId,
providerSessionId,
});
return;
}

const [row] = await db
.select({
config: conversations.config,
projectId: conversations.projectId,
taskId: conversations.taskId,
})
.from(conversations)
.where(eq(conversations.id, conversationId))
.limit(1);

if (!row) return;

const config = parseConversationConfig(row.config);
if (config.providerSessionId === providerSessionId) return;

const nextConfig = serializeConversationConfig({
...config,
providerSessionId,
});

await db
.update(conversations)
.set({ config: nextConfig, updatedAt: new Date().toISOString() })
.where(eq(conversations.id, conversationId));

events.emit(conversationChangedChannel, {
conversationId,
taskId: row.taskId,
projectId: row.projectId,
changes: { providerSessionId },
});
}
5 changes: 4 additions & 1 deletion src/main/core/conversations/utils.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { type ConversationRow } from '@main/db/schema';
import { type AgentProviderId } from '@shared/agent-provider-registry';
import { parseConversationConfig } from '@shared/conversation-config';
import { type Conversation } from '@shared/conversations';

export function mapConversationRowToConversation(
row: ConversationRow,
resume: boolean = false
): Conversation {
const config = parseConversationConfig(row.config);
return {
id: row.id,
title: row.title,
taskId: row.taskId,
projectId: row.projectId,
providerId: row.provider as AgentProviderId,
autoApprove: row.config ? JSON.parse(row.config).autoApprove : undefined,
autoApprove: config.autoApprove,
providerSessionId: config.providerSessionId,
resume: resume,
lastInteractedAt: row.lastInteractedAt ?? null,
isInitialConversation: row.isInitialConversation,
Expand Down
4 changes: 3 additions & 1 deletion src/shared/agent-provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,9 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
versionArgs: ['--version'],
cli: 'opencode',
initialPromptFlag: '--prompt',
resumeFlag: '--continue',
resumeFlag: '--session',
sessionIdFlag: '--session',
sessionIdOnResumeOnly: true,
icon: 'opencode.png',
alt: 'OpenCode CLI',
invertInDark: true,
Expand Down
42 changes: 42 additions & 0 deletions src/shared/conversation-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import {
isOpenCodeProviderSessionId,
parseConversationConfig,
serializeConversationConfig,
} from './conversation-config';

describe('parseConversationConfig', () => {
it('parses autoApprove and providerSessionId', () => {
expect(
parseConversationConfig(JSON.stringify({ autoApprove: true, providerSessionId: 'ses_abc' }))
).toEqual({ autoApprove: true, providerSessionId: 'ses_abc' });
});

it('returns empty config for invalid JSON', () => {
expect(parseConversationConfig('not-json')).toEqual({});
});
});

describe('isOpenCodeProviderSessionId', () => {
it('accepts OpenCode session ids', () => {
expect(isOpenCodeProviderSessionId('ses_0123456789abcdef')).toBe(true);
});

it('rejects non-OpenCode ids', () => {
expect(isOpenCodeProviderSessionId('conv-1')).toBe(false);
expect(isOpenCodeProviderSessionId('ses_')).toBe(false);
});
});

describe('serializeConversationConfig', () => {
it('round-trips known fields', () => {
const raw = serializeConversationConfig({
autoApprove: false,
providerSessionId: 'ses_xyz',
});
expect(parseConversationConfig(raw)).toEqual({
autoApprove: false,
providerSessionId: 'ses_xyz',
});
});
});
32 changes: 32 additions & 0 deletions src/shared/conversation-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const OPENCODE_SESSION_ID_PATTERN = /^ses_.+/;

export function isOpenCodeProviderSessionId(value: string): boolean {
return OPENCODE_SESSION_ID_PATTERN.test(value);
}

export type ConversationConfig = {
autoApprove?: boolean;
/** Provider-native session id (e.g. OpenCode `ses_*`) for resuming the correct chat. */
providerSessionId?: string;
};

export function parseConversationConfig(raw: string | null | undefined): ConversationConfig {
if (!raw) return {};
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null) return {};
const record = parsed as Record<string, unknown>;
return {
...(typeof record.autoApprove === 'boolean' ? { autoApprove: record.autoApprove } : {}),
...(typeof record.providerSessionId === 'string'
? { providerSessionId: record.providerSessionId }
: {}),
};
} catch {
return {};
}
}

export function serializeConversationConfig(config: ConversationConfig): string {
return JSON.stringify(config);
}
2 changes: 2 additions & 0 deletions src/shared/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type Conversation = {
lastInteractedAt: string | null;
resume?: boolean;
autoApprove?: boolean;
/** OpenCode `ses_*` id captured from the provider for per-chat resume. */
providerSessionId?: string;
isInitialConversation: boolean | null;
};

Expand Down
Loading
Loading