Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .github/workflows/fro-bot.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,13 @@ jobs:
model: ${{ vars.FRO_BOT_MODEL }}
omo-providers: ${{ secrets.OMO_PROVIDERS }}
prompt: ${{ env.PROMPT }}

- if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: opencode-logs-${{ github.run_id }}-${{ github.run_attempt }}
path: ~/.local/share/opencode/log
retention-days: 7
compression-level: 9
include-hidden-files: true
if-no-files-found: warn
107 changes: 56 additions & 51 deletions dist/main.js

Large diffs are not rendered by default.

662 changes: 662 additions & 0 deletions docs/plans/2026-03-22-feat-agent-cohesion-session-continuity-plan.md

Large diffs are not rendered by default.

36 changes: 30 additions & 6 deletions src/features/agent/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,22 @@ export async function executeOpenCode(
server = opencode.server
} else client = serverHandle.client

const sessionResponse = await client.session.create()
if (sessionResponse.data == null || sessionResponse.error != null)
throw new Error(
`Failed to create session: ${sessionResponse.error == null ? 'No data returned' : String(sessionResponse.error)}`,
)
const sessionId = sessionResponse.data.id
let sessionId: string
if (config?.continueSessionId == null) {
const createPayload =
config?.sessionTitle == null ? undefined : ({body: {title: config.sessionTitle}} as Record<string, unknown>)
const sessionResponse =
createPayload == null ? await client.session.create() : await client.session.create(createPayload)
if (sessionResponse.data == null || sessionResponse.error != null)
throw new Error(
`Failed to create session: ${sessionResponse.error == null ? 'No data returned' : String(sessionResponse.error)}`,
)
sessionId = sessionResponse.data.id
logger.info('Created new OpenCode session', {sessionId, sessionTitle: config?.sessionTitle ?? null})
} else {
sessionId = config.continueSessionId
logger.info('Continuing existing OpenCode session', {sessionId})
}
const initialPrompt = buildAgentPrompt({...promptOptions, sessionId}, logger)
const directory = getGitHubWorkspace()

Expand Down Expand Up @@ -109,6 +119,20 @@ export async function executeOpenCode(
const result = await sendPromptToSession(client, sessionId, prompt, files, directory, config, logger)
if (result.success) {
final = result.eventStreamResult

// Best-effort title re-assertion: OpenCode may auto-overwrite session titles
// based on first message content. Re-set to preserve deterministic lookup.
if (config?.sessionTitle != null) {
try {
await (client.session as unknown as {update: (args: Record<string, unknown>) => Promise<unknown>}).update({
sessionID: sessionId,
title: config.sessionTitle,
})
} catch {
logger.debug('Best-effort session title re-assertion failed', {sessionId})
}
}

return {
success: true,
exitCode: 0,
Expand Down
58 changes: 58 additions & 0 deletions src/features/agent/prompt-thread.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type {LogicalSessionKey} from '../../services/session/logical-key.js'

export function buildNonNegotiableRulesSection(): string {
return [
'## Critical Rules (NON-NEGOTIABLE)',
'- You are a NON-INTERACTIVE CI agent. Do NOT ask questions. Make decisions autonomously.',
'- Post EXACTLY ONE comment or review per invocation. Never multiple.',
'- Include the Run Summary marker block in your comment.',
'- Use `gh` CLI for all GitHub operations. Do not use the GitHub API directly.',
'- Mark your comment with the bot identification marker.',
].join('\n')
}

export function buildThreadIdentitySection(
logicalKey: LogicalSessionKey | null,
isContinuation: boolean,
threadSummary: string | null,
): string {
if (logicalKey == null) {
return ''
}

const lines = ['## Thread Identity']
lines.push(`**Logical Thread**: \`${logicalKey.key}\` (${logicalKey.entityType} #${logicalKey.entityId})`)

if (isContinuation) {
lines.push('**Status**: Continuing previous conversation thread.')
if (threadSummary != null && threadSummary.length > 0) {
lines.push('')
lines.push('**Thread Summary**:')
lines.push(threadSummary)
}
} else {
lines.push('**Status**: Fresh conversation — no prior thread found for this entity.')
}

return lines.join('\n')
}

export function buildCurrentThreadContextSection(priorWorkContext: string | null): string {
if (priorWorkContext == null || priorWorkContext.length === 0) {
return ''
}

return [
'## Current Thread Context',
'This is work from your PREVIOUS runs on this same entity:',
'',
priorWorkContext,
].join('\n')
}

export function buildConstraintReminderSection(): string {
return [
'## Reminder: Critical Rules',
'- ONE comment/review only. Include Run Summary marker. Use `gh` CLI only.',
].join('\n')
}
99 changes: 99 additions & 0 deletions src/features/agent/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type {LogicalSessionKey} from '../../services/session/logical-key.js'
import type {SessionSearchResult, SessionSummary} from '../../services/session/types.js'
import type {Logger} from '../../shared/logger.js'
import type {TriggerContext} from '../triggers/types.js'
Expand Down Expand Up @@ -54,6 +55,15 @@ function createMockSearchResult(overrides: Partial<SessionSearchResult> = {}): S
}
}

function createMockLogicalKey(overrides: Partial<LogicalSessionKey> = {}): LogicalSessionKey {
return {
key: 'pr-42',
entityType: 'pr',
entityId: '42',
...overrides,
}
}

describe('buildAgentPrompt', () => {
let mockLogger: Logger

Expand Down Expand Up @@ -82,6 +92,95 @@ describe('buildAgentPrompt', () => {
expect(prompt).toContain('**Cache Status:** hit')
})

it('includes non-negotiable rules at top and constraint reminder at end', () => {
// #given
const options: PromptOptions = {
context: createMockContext(),
customPrompt: null,
cacheStatus: 'hit',
}

// #when
const prompt = buildAgentPrompt(options, mockLogger)

// #then
expect(prompt).toContain('## Critical Rules (NON-NEGOTIABLE)')
expect(prompt).toContain('## Reminder: Critical Rules')

const criticalRulesIndex = prompt.indexOf('## Critical Rules (NON-NEGOTIABLE)')
const taskIndex = prompt.indexOf('## Task')
const reminderIndex = prompt.indexOf('## Reminder: Critical Rules')
const ghOpsIndex = prompt.indexOf('## GitHub Operations (Use gh CLI)')

expect(criticalRulesIndex).toBe(0)
expect(taskIndex).toBeGreaterThan(criticalRulesIndex)
expect(reminderIndex).toBeGreaterThan(ghOpsIndex)
})

it('includes thread identity section when logical key is provided', () => {
// #given
const options: PromptOptions = {
context: createMockContext(),
customPrompt: null,
cacheStatus: 'hit',
logicalKey: createMockLogicalKey(),
isContinuation: true,
}

// #when
const prompt = buildAgentPrompt(options, mockLogger)

// #then
expect(prompt).toContain('## Thread Identity')
expect(prompt).toContain('**Logical Thread**: `pr-42` (pr #42)')
expect(prompt).toContain('**Status**: Continuing previous conversation thread.')
})

it('places current thread context above environment and historical context for continuation runs', () => {
// #given
const sessionContext: SessionContext = {
recentSessions: [createMockSessionSummary()],
priorWorkContext: [
createMockSearchResult({
sessionId: 'ses_current',
matches: [{messageId: 'msg_1', partId: 'part_1', role: 'assistant', excerpt: 'Current thread prior work'}],
}),
createMockSearchResult({
sessionId: 'ses_other',
matches: [{messageId: 'msg_2', partId: 'part_2', role: 'assistant', excerpt: 'Other thread context'}],
}),
],
}
const options: PromptOptions = {
context: createMockContext(),
customPrompt: null,
cacheStatus: 'hit',
sessionContext,
logicalKey: createMockLogicalKey(),
isContinuation: true,
currentThreadSessionId: 'ses_current',
}

// #when
const prompt = buildAgentPrompt(options, mockLogger)

// #then
expect(prompt).toContain('## Current Thread Context')
expect(prompt).toContain('Current thread prior work')
expect(prompt).toContain('## Related Historical Context')
expect(prompt).toContain('Other thread context')

const currentThreadIndex = prompt.indexOf('## Current Thread Context')
const environmentIndex = prompt.indexOf('## Environment')
const relatedHistoryIndex = prompt.indexOf('## Related Historical Context')
const agentContextIndex = prompt.indexOf('# Agent Context')

expect(currentThreadIndex).toBeGreaterThan(-1)
expect(currentThreadIndex).toBeLessThan(environmentIndex)
expect(relatedHistoryIndex).toBeGreaterThan(environmentIndex)
expect(agentContextIndex).toBeGreaterThan(relatedHistoryIndex)
})

it('includes CI environment awareness with operating environment section', () => {
// #given
const options: PromptOptions = {
Expand Down
Loading
Loading